Skip to content

Commit 2a2faba

Browse files
committed
fix(runtime,elevenlabs): make the two red CI legs green
Both predate this PR; fixing them here so the branch can go green. Browser E2E (all 10 specs timed out on `workflowRunnerReady`) ------------------------------------------------------------ Three runtime modules read bare `process.env` at MODULE scope, so importing them threw `ReferenceError: process is not defined` in the browser bundle and took the whole harness down at import time -- not at the call that wanted the variable. `python-bridge-base`, `python-websocket-bridge` and `python-stdio-bridge` now read env through a shared `safeProcessEnv()`, promoted from `ProcessingContext` (where it already existed for exactly this reason) into `@nodetool-ai/config` next to `IS_NODE`. The harness config says it deliberately does not shim `process`, because the real web app does not either -- so the guard belongs in the modules, not in a bundler define. elevenlabs-nodes (vi.mock hoisting) ----------------------------------- `vi.mock("ws")` is hoisted above the module body, and the factory fires while the runtime's python-websocket-bridge -- a static `ws` importer -- is evaluated, before this file's own `class MockWebSocket` initializes. Hence `Cannot access 'MockWebSocket' before initialization`. The mock class and the state it records now live in `vi.hoisted`, which runs first by construction. Also from review ---------------- - `BoundedHandle` no longer calls the handle at all once the run is cancelled. Decisions are serialized, so a cancel mid-decision leaves a queue behind it; waking the agent for each would spend money deciding the fate of a run that is already over. Cancel is free, not merely fast. - A non-finite provider cost is recorded as spending rather than dropped. "Cannot add this up" must not read as "this was free", or the invocation would look cost-free and re-earn a retry after a real charge. - `end_stream` now emits `generation_complete`. The invocation committed early, but it did commit, and skipping the marker would hide the kept outputs from replay and asset autosave -- the silent data loss the PRD forbids. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011yMUYSSrceG3dTcX3XmsGJ
1 parent 07888ea commit 2a2faba

13 files changed

Lines changed: 294 additions & 176 deletions

packages/config/src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
export { IS_NODE, importNodeBuiltin, importHidden } from "./node-import.js";
1+
export {
2+
IS_NODE,
3+
safeProcessEnv,
4+
importNodeBuiltin,
5+
importHidden
6+
} from "./node-import.js";
27

38
export {
49
loadEnvironment,
@@ -14,7 +19,6 @@ export {
1419
GOOGLE_WORKSPACE_NAMESPACE
1520
} from "./google-workspace.js";
1621

17-
1822
export {
1923
registerSetting,
2024
getSettings,

packages/config/src/node-import.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ export const IS_NODE =
1616
typeof (process as { versions?: { node?: string } }).versions?.node ===
1717
"string";
1818

19+
/**
20+
* `process` is undefined in browser/edge runtimes, where a bare `process.env`
21+
* read throws `ReferenceError` — including at module scope, which takes the
22+
* whole bundle down at import time rather than at the call that wanted the
23+
* variable. Any module that can end up in a browser graph reads env through
24+
* this instead.
25+
*/
26+
export const safeProcessEnv = (): Record<string, string | undefined> =>
27+
typeof process !== "undefined" && process.env ? process.env : {};
28+
1929
/**
2030
* Dynamic import that bundlers can't statically resolve.
2131
*

packages/elevenlabs-nodes/tests/realtime-stt.test.ts

Lines changed: 57 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,70 @@
1-
import { EventEmitter } from "node:events";
21
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
32

4-
const sentMessages: Array<Record<string, unknown>> = [];
5-
const lifecycleEvents: string[] = [];
3+
/**
4+
* `vi.mock` factories are hoisted above the module body, and the `ws` mock
5+
* is pulled in while the runtime's python-websocket-bridge is evaluated —
6+
* before this file's own declarations initialize. So the mock and the state
7+
* it records live in `vi.hoisted`, which runs first by construction.
8+
*/
9+
const { sentMessages, lifecycleEvents, MockWebSocket } = await vi.hoisted(
10+
async () => {
11+
const { EventEmitter } = await import("node:events");
612

7-
class MockWebSocket extends EventEmitter {
8-
static readonly OPEN = 1;
9-
static readonly CLOSED = 3;
13+
const sentMessages: Array<Record<string, unknown>> = [];
14+
const lifecycleEvents: string[] = [];
1015

11-
readyState = 0;
16+
class MockWebSocket extends EventEmitter {
17+
static readonly OPEN = 1;
18+
static readonly CLOSED = 3;
1219

13-
constructor(_url: string, _opts?: Record<string, unknown>) {
14-
super();
20+
readyState = 0;
1521

16-
setTimeout(() => {
17-
this.readyState = MockWebSocket.OPEN;
18-
this.emit("open");
19-
setTimeout(() => {
20-
this.emit(
21-
"message",
22-
Buffer.from(JSON.stringify({ message_type: "session_started" }))
23-
);
24-
}, 0);
25-
}, 0);
26-
}
22+
constructor(_url: string, _opts?: Record<string, unknown>) {
23+
super();
24+
25+
setTimeout(() => {
26+
this.readyState = MockWebSocket.OPEN;
27+
this.emit("open");
28+
setTimeout(() => {
29+
this.emit(
30+
"message",
31+
Buffer.from(JSON.stringify({ message_type: "session_started" }))
32+
);
33+
}, 0);
34+
}, 0);
35+
}
36+
37+
send(payload: string): void {
38+
const parsed = JSON.parse(payload) as Record<string, unknown>;
39+
sentMessages.push(parsed);
40+
if (parsed.commit === true) {
41+
lifecycleEvents.push("commit");
42+
setTimeout(() => {
43+
this.emit(
44+
"message",
45+
Buffer.from(
46+
JSON.stringify({
47+
message_type: "committed_transcript",
48+
text: "final transcript"
49+
})
50+
)
51+
);
52+
}, 0);
53+
}
54+
}
2755

28-
send(payload: string): void {
29-
const parsed = JSON.parse(payload) as Record<string, unknown>;
30-
sentMessages.push(parsed);
31-
if (parsed.commit === true) {
32-
lifecycleEvents.push("commit");
33-
setTimeout(() => {
34-
this.emit(
35-
"message",
36-
Buffer.from(
37-
JSON.stringify({
38-
message_type: "committed_transcript",
39-
text: "final transcript"
40-
})
41-
)
42-
);
43-
}, 0);
56+
close(): void {
57+
lifecycleEvents.push("close");
58+
this.readyState = MockWebSocket.CLOSED;
59+
setTimeout(() => {
60+
this.emit("close", 1000, Buffer.from(""));
61+
}, 0);
62+
}
4463
}
45-
}
4664

47-
close(): void {
48-
lifecycleEvents.push("close");
49-
this.readyState = MockWebSocket.CLOSED;
50-
setTimeout(() => {
51-
this.emit("close", 1000, Buffer.from(""));
52-
}, 0);
65+
return { sentMessages, lifecycleEvents, MockWebSocket };
5366
}
54-
}
67+
);
5568

5669
vi.mock("ws", () => ({
5770
WebSocket: MockWebSocket

packages/elevenlabs-nodes/tests/realtime-tts.test.ts

Lines changed: 72 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,79 @@
1-
import { EventEmitter } from "node:events";
21
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
32

4-
const sentMessages: Array<Record<string, unknown>> = [];
5-
const lifecycleEvents: string[] = [];
6-
const wsUrls: string[] = [];
7-
8-
class MockWebSocket extends EventEmitter {
9-
static readonly OPEN = 1;
10-
static readonly CLOSED = 3;
11-
12-
readyState = 0;
13-
14-
constructor(url: string, _opts?: Record<string, unknown>) {
15-
super();
16-
wsUrls.push(url);
17-
18-
setTimeout(() => {
19-
this.readyState = MockWebSocket.OPEN;
20-
this.emit("open");
21-
}, 0);
22-
}
23-
24-
send(payload: string): void {
25-
const parsed = JSON.parse(payload) as Record<string, unknown>;
26-
sentMessages.push(parsed);
27-
28-
// Respond to EOS (empty text) with isFinal
29-
if (parsed.text === "") {
30-
lifecycleEvents.push("eos");
31-
setTimeout(() => {
32-
this.emit("message", Buffer.from(JSON.stringify({ isFinal: true })));
33-
}, 0);
34-
return;
35-
}
3+
/**
4+
* `vi.mock` factories are hoisted above the module body, and the `ws` mock
5+
* is pulled in while the runtime's python-websocket-bridge is evaluated —
6+
* before this file's own declarations initialize. So the mock and the state
7+
* it records live in `vi.hoisted`, which runs first by construction.
8+
*/
9+
const { sentMessages, lifecycleEvents, wsUrls, MockWebSocket } =
10+
await vi.hoisted(async () => {
11+
const { EventEmitter } = await import("node:events");
12+
13+
const sentMessages: Array<Record<string, unknown>> = [];
14+
const lifecycleEvents: string[] = [];
15+
const wsUrls: string[] = [];
16+
17+
class MockWebSocket extends EventEmitter {
18+
static readonly OPEN = 1;
19+
static readonly CLOSED = 3;
20+
21+
readyState = 0;
22+
23+
constructor(url: string, _opts?: Record<string, unknown>) {
24+
super();
25+
wsUrls.push(url);
26+
27+
setTimeout(() => {
28+
this.readyState = MockWebSocket.OPEN;
29+
this.emit("open");
30+
}, 0);
31+
}
32+
33+
send(payload: string): void {
34+
const parsed = JSON.parse(payload) as Record<string, unknown>;
35+
sentMessages.push(parsed);
3636

37-
// Respond to non-init text with an audio chunk
38-
const text = parsed.text as string | undefined;
39-
if (text && text.trim() && text !== " ") {
40-
setTimeout(() => {
41-
this.emit(
42-
"message",
43-
Buffer.from(
44-
JSON.stringify({
45-
audio: "ZmFrZS1hdWRpbw==", // base64: "fake-audio"
46-
isFinal: false
47-
})
48-
)
49-
);
50-
}, 0);
37+
// Respond to EOS (empty text) with isFinal
38+
if (parsed.text === "") {
39+
lifecycleEvents.push("eos");
40+
setTimeout(() => {
41+
this.emit(
42+
"message",
43+
Buffer.from(JSON.stringify({ isFinal: true }))
44+
);
45+
}, 0);
46+
return;
47+
}
48+
49+
// Respond to non-init text with an audio chunk
50+
const text = parsed.text as string | undefined;
51+
if (text && text.trim() && text !== " ") {
52+
setTimeout(() => {
53+
this.emit(
54+
"message",
55+
Buffer.from(
56+
JSON.stringify({
57+
audio: "ZmFrZS1hdWRpbw==", // base64: "fake-audio"
58+
isFinal: false
59+
})
60+
)
61+
);
62+
}, 0);
63+
}
64+
}
65+
66+
close(): void {
67+
lifecycleEvents.push("close");
68+
this.readyState = MockWebSocket.CLOSED;
69+
setTimeout(() => {
70+
this.emit("close", 1000, Buffer.from(""));
71+
}, 0);
72+
}
5173
}
52-
}
53-
54-
close(): void {
55-
lifecycleEvents.push("close");
56-
this.readyState = MockWebSocket.CLOSED;
57-
setTimeout(() => {
58-
this.emit("close", 1000, Buffer.from(""));
59-
}, 0);
60-
}
61-
}
74+
75+
return { sentMessages, lifecycleEvents, wsUrls, MockWebSocket };
76+
});
6277

6378
vi.mock("ws", () => ({
6479
WebSocket: MockWebSocket

packages/kernel/src/actor.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,6 +1454,11 @@ export class NodeActor {
14541454
case "end_stream":
14551455
this._completeOutputSlots();
14561456
this._latestResult = { ...(this._streamingCollectedOutputs ?? {}) };
1457+
// `end_stream` keeps what the stream produced, so the invocation
1458+
// committed — it just committed early. Skipping the commit marker
1459+
// would hide those outputs from replay and asset autosave, which
1460+
// is the "silent data loss dressed as success" the PRD forbids.
1461+
this._emitGenerationComplete(this._latestResult, inputs);
14571462
return;
14581463
default:
14591464
throw err;

packages/kernel/src/supervisor.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,18 @@ export class BoundedHandle implements SupervisorHandle {
292292
return { verdict: { action: "fail" }, decidedBy: "bounds" };
293293
}
294294

295-
// The decision signal is the run signal plus a timeout, so `cancel()` is
296-
// instant *and* free: a pending decision is killed, not un-awaited.
295+
// Cancel is free, not merely fast. Decisions are serialized, so a cancel
296+
// during a slow decision leaves a queue behind it; calling the handle for
297+
// each of those would spend real money deciding the fate of a run that is
298+
// already over.
299+
if (runSignal.aborted) {
300+
return { verdict: { action: "fail" }, decidedBy: "bounds" };
301+
}
302+
303+
// The decision signal is the run signal plus a timeout, so an in-flight
304+
// decision is killed rather than un-awaited.
297305
const timeout = new AbortController();
298306
const onRunAbort = () => timeout.abort();
299-
if (runSignal.aborted) timeout.abort();
300307
runSignal.addEventListener("abort", onRunAbort, { once: true });
301308
const timer = setTimeout(
302309
() => timeout.abort(),

packages/kernel/tests/supervisor-handle.test.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -124,20 +124,22 @@ describe("BoundedHandle — decisions and retries are capped", () => {
124124
}
125125
});
126126

127-
it("passes an already-aborted run signal straight through to the handle", async () => {
128-
const seen: AbortSignal[] = [];
127+
it("does not call the handle at all once the run is cancelled", async () => {
128+
// Decisions are serialized, so a cancel mid-decision leaves a queue behind
129+
// it. Waking the agent for each of those would spend money deciding the
130+
// fate of a run that is already over.
131+
let calls = 0;
129132
const bounded = new BoundedHandle({
130-
async decide(_e, signal): Promise<DecisionOutcome> {
131-
seen.push(signal);
133+
async decide(): Promise<DecisionOutcome> {
134+
calls++;
132135
return { verdict: { action: "skip" }, decidedBy: "agent" };
133136
},
134137
close() {}
135138
});
136-
const cancelled = AbortSignal.abort();
137-
const decision = await bounded.decide(escalation(), cancelled);
138-
expect(seen[0].aborted).toBe(true);
139-
// A verdict decided against a run that is already over is not applied.
139+
const decision = await bounded.decide(escalation(), AbortSignal.abort());
140+
expect(calls).toBe(0);
140141
expect(decision.verdict.action).toBe("fail");
142+
expect(decision.decidedBy).toBe("bounds");
141143
});
142144

143145
it("serializes decisions", async () => {

packages/kernel/tests/supervisor.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,13 @@ describe("supervisor — streaming", () => {
782782
// one the PRD reports as "kept the chunks it had finished".
783783
expect(result.status).toBe("completed");
784784
expect(received).toEqual(["chunk-0", "chunk-1"]);
785+
// The invocation committed early, but it did commit: the marker replay and
786+
// asset autosave key off must carry what the stream kept.
787+
const committed = result.messages.filter(
788+
(m) => m.type === "generation_complete" && m.node_id === "work"
789+
);
790+
expect(committed).toHaveLength(1);
791+
expect(committed[0].outputs).toEqual({ value: "chunk-1" });
785792
});
786793

787794
it("fails the node when routing a frame throws, without escalating", async () => {

packages/runtime/src/invocation-account.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,20 @@ export function inInvocationAccount<T>(
4545
return store.run(account, fn);
4646
}
4747

48-
/** Charge the invocation currently on the async stack, if any. */
48+
/**
49+
* Charge the invocation currently on the async stack, if any.
50+
*
51+
* A non-finite amount means a provider reported something we cannot add up —
52+
* and "cannot add up" must not read as "free", or the invocation would look
53+
* cost-free and re-earn a retry after a real charge. It is recorded as a
54+
* nominal charge instead: the number is meaningless, the fact of spending is
55+
* not. `costUsd` stays finite so the escalation record remains JSON-safe.
56+
*/
4957
export function recordInvocationCost(usd: number | undefined | null): void {
50-
if (!usd) return;
58+
if (usd === undefined || usd === null) return;
5159
const account = store.getStore();
52-
if (account) account.costUsd += usd;
60+
if (!account) return;
61+
account.costUsd += Number.isFinite(usd) ? usd : Number.MIN_VALUE;
5362
}
5463

5564
/** Mark the invocation currently on the async stack as having written an asset. */

0 commit comments

Comments
 (0)