@ai-sdk/harness 1.0.38 → 1.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,10 +9,17 @@ import type {
9
9
  HarnessAgentSkill,
10
10
  } from './harness-agent-types';
11
11
  import type {
12
+ Arrayable,
13
+ Context,
12
14
  Experimental_SandboxSession as SandboxSession,
13
15
  ToolSet,
14
16
  } from '@ai-sdk/provider-utils';
15
- import type { ActiveTools, TelemetryOptions, ToolApprovalStatus } from 'ai';
17
+ import type {
18
+ ActiveTools,
19
+ StopCondition,
20
+ TelemetryOptions,
21
+ ToolApprovalStatus,
22
+ } from 'ai';
16
23
  import type { HarnessAllTools } from './harness-agent-tool-types';
17
24
 
18
25
  export type HarnessAgentToolApprovalConfiguration = Readonly<
@@ -91,6 +98,7 @@ type HarnessAgentToolFilteringSettings<TOOLS extends ToolSet> =
91
98
  export type HarnessAgentSettings<
92
99
  THarness extends HarnessAgentAdapter<any> = HarnessAgentAdapter,
93
100
  TUserTools extends ToolSet = {},
101
+ RUNTIME_CONTEXT extends Context = Context,
94
102
  > = {
95
103
  /**
96
104
  * The harness adapter driving the underlying agent runtime. Its
@@ -130,6 +138,22 @@ export type HarnessAgentSettings<
130
138
  */
131
139
  readonly instructions?: string;
132
140
 
141
+ /**
142
+ * Conditions that stop the current result after a completed harness tool
143
+ * step that can continue into another model step. The underlying turn remains
144
+ * unfinished and can be suspended and continued.
145
+ *
146
+ * A terminal text-only step finishes naturally and is not stopped early.
147
+ *
148
+ * When omitted, the harness runs until the turn naturally finishes or pauses.
149
+ */
150
+ readonly stopWhen?: Arrayable<
151
+ StopCondition<
152
+ NoInfer<HarnessAllTools<THarness, TUserTools>>,
153
+ RUNTIME_CONTEXT
154
+ >
155
+ >;
156
+
133
157
  /**
134
158
  * Built-in tool permission mode. Defaults to `'allow-all'`, preserving the
135
159
  * existing bypass-permissions behavior unless users opt in.
@@ -6,6 +6,7 @@ import type {
6
6
  HarnessV1SandboxProvider,
7
7
  } from '../v1';
8
8
  import {
9
+ asArray,
9
10
  asSchema,
10
11
  generateId,
11
12
  type Context,
@@ -19,6 +20,7 @@ import type {
19
20
  GenerateTextResult,
20
21
  ReasoningFileOutput,
21
22
  ReasoningOutput,
23
+ StopCondition,
22
24
  StreamTextResult,
23
25
  } from 'ai';
24
26
  import type {
@@ -133,7 +135,14 @@ export class HarnessAgent<
133
135
  */
134
136
  readonly tools: HarnessAllTools<THarness, TUserTools>;
135
137
 
136
- private readonly settings: HarnessAgentSettings<THarness, TUserTools>;
138
+ private readonly settings: HarnessAgentSettings<
139
+ THarness,
140
+ TUserTools,
141
+ RUNTIME_CONTEXT
142
+ >;
143
+ private readonly stopConditions: Array<
144
+ StopCondition<HarnessAllTools<THarness, TUserTools>, RUNTIME_CONTEXT>
145
+ >;
137
146
  private readonly sandboxConfig: HarnessAgentSandboxConfig;
138
147
  private readonly activeUserTools: TUserTools;
139
148
  private readonly builtinToolFiltering:
@@ -141,10 +150,14 @@ export class HarnessAgent<
141
150
  | undefined;
142
151
  private readonly permissionMode: HarnessAgentPermissionMode;
143
152
 
144
- constructor(settings: HarnessAgentSettings<THarness, TUserTools>) {
153
+ constructor(
154
+ settings: HarnessAgentSettings<THarness, TUserTools, RUNTIME_CONTEXT>,
155
+ ) {
145
156
  const sandboxConfig = resolveSandboxConfig(settings);
146
157
  validateSandboxBootstrapSettings(sandboxConfig);
147
158
  this.settings = settings;
159
+ this.stopConditions =
160
+ settings.stopWhen == null ? [] : asArray(settings.stopWhen);
148
161
  this.sandboxConfig = sandboxConfig;
149
162
  this.id = settings.id;
150
163
  const userTools = settings.tools ?? ({} as TUserTools);
@@ -528,6 +541,7 @@ export class HarnessAgent<
528
541
  runtimeContext: input.runtimeContext,
529
542
  abortSignal: input.abortSignal,
530
543
  telemetry: this.settings.telemetry,
544
+ stopConditions: this.stopConditions,
531
545
  toolApprovalContinuations: input.turnInput.toolApprovalContinuations,
532
546
  toolResultContinuations: input.turnInput.toolResultContinuations,
533
547
  });
@@ -546,6 +560,7 @@ export class HarnessAgent<
546
560
  runtimeContext: input.runtimeContext,
547
561
  abortSignal: input.abortSignal,
548
562
  telemetry: this.settings.telemetry,
563
+ stopConditions: this.stopConditions,
549
564
  });
550
565
  }
551
566
 
@@ -460,6 +460,25 @@ export class HarnessStreamTextResult<
460
460
  this.fullStreamController.close();
461
461
  }
462
462
 
463
+ /**
464
+ * Settle the turn as user-aborted: emit a final `abort` part — matching
465
+ * `streamText`'s abort contract — and close the stream, instead of
466
+ * surfacing an `error` part. `toUIMessageStream` consumers then observe
467
+ * an `abort` chunk (and `isAborted: true`) rather than a spurious
468
+ * `onError`. The delayed promise accessors still reject with the
469
+ * underlying abort error so awaiting consumers do not hang. Idempotent.
470
+ */
471
+ abort(input: { error: unknown; reason?: string }): void {
472
+ if (this.settled) return;
473
+ this.settled = true;
474
+ this.fullStreamController.enqueue({
475
+ type: 'abort',
476
+ ...(input.reason !== undefined ? { reason: input.reason } : {}),
477
+ } as TextStreamPart<TOOLS>);
478
+ this.fullStreamController.close();
479
+ this.rejectDelayedPromises(input.error);
480
+ }
481
+
463
482
  /**
464
483
  * Surface a fatal error as a stream `error` part + reject every delayed
465
484
  * promise so awaiting consumers stop hanging. Idempotent.
@@ -472,6 +491,10 @@ export class HarnessStreamTextResult<
472
491
  error,
473
492
  } as TextStreamPart<TOOLS>);
474
493
  this.fullStreamController.close();
494
+ this.rejectDelayedPromises(error);
495
+ }
496
+
497
+ private rejectDelayedPromises(error: unknown): void {
475
498
  for (const dp of [
476
499
  this._content,
477
500
  this._text,
@@ -605,6 +628,7 @@ export class HarnessStreamTextResult<
605
628
  toUIMessageStream<UI_MESSAGE extends UIMessage>({
606
629
  originalMessages,
607
630
  generateMessageId,
631
+ onEnd,
608
632
  onFinish,
609
633
  messageMetadata,
610
634
  sendReasoning,
@@ -621,6 +645,7 @@ export class HarnessStreamTextResult<
621
645
  tools: this.tools,
622
646
  originalMessages,
623
647
  generateMessageId,
648
+ onEnd,
624
649
  onFinish,
625
650
  messageMetadata,
626
651
  sendReasoning,
@@ -643,6 +668,7 @@ export class HarnessStreamTextResult<
643
668
  toUIMessageStreamResponse<UI_MESSAGE extends UIMessage>({
644
669
  originalMessages,
645
670
  generateMessageId,
671
+ onEnd,
646
672
  onFinish,
647
673
  messageMetadata,
648
674
  sendReasoning,
@@ -660,6 +686,7 @@ export class HarnessStreamTextResult<
660
686
  stream: this.toUIMessageStream<UI_MESSAGE>({
661
687
  originalMessages,
662
688
  generateMessageId,
689
+ onEnd,
663
690
  onFinish,
664
691
  messageMetadata,
665
692
  sendReasoning,
@@ -21,16 +21,18 @@ import {
21
21
  type Experimental_SandboxSession as SandboxSession,
22
22
  type ToolSet,
23
23
  } from '@ai-sdk/provider-utils';
24
- import type {
25
- LanguageModelV4FinishReason,
26
- LanguageModelV4ToolCall,
27
- LanguageModelV4Usage,
24
+ import {
25
+ getErrorMessage,
26
+ type LanguageModelV4FinishReason,
27
+ type LanguageModelV4ToolCall,
28
+ type LanguageModelV4Usage,
28
29
  } from '@ai-sdk/provider';
29
30
  import { parseToolCall } from 'ai/internal';
30
31
  import type {
31
32
  ContentPart,
32
33
  ProviderMetadata,
33
34
  StepResult,
35
+ StopCondition,
34
36
  TelemetryOptions,
35
37
  TextStreamPart,
36
38
  } from 'ai';
@@ -40,9 +42,14 @@ import type { HarnessAgentToolApprovalConfiguration } from '../harness-agent-set
40
42
  import { HarnessStreamTextResult } from './harness-stream-text-result';
41
43
  import { translateStreamPart } from './translate-stream-part';
42
44
  import { stripWorkDir } from './strip-work-dir';
43
- import { createTurnTelemetry, type TurnContentPart } from './turn-telemetry';
45
+ import {
46
+ createTurnTelemetry,
47
+ type TurnContentPart,
48
+ type TurnTelemetry,
49
+ } from './turn-telemetry';
44
50
  import { resolveCustomToolApproval } from './permission-mode';
45
51
  import { logBridgeError } from '../../utils/bridge-diagnostics';
52
+ import { pinSandboxChannelEventCheckpoint } from '../../utils/sandbox-channel';
46
53
 
47
54
  /**
48
55
  * Drive one prompt turn end-to-end:
@@ -80,6 +87,7 @@ export function runPrompt<
80
87
  runtimeContext: RUNTIME_CONTEXT;
81
88
  abortSignal: AbortSignal | undefined;
82
89
  telemetry?: TelemetryOptions | undefined;
90
+ stopConditions?: ReadonlyArray<StopCondition<TOOLS, RUNTIME_CONTEXT>>;
83
91
  toolApproval?: HarnessAgentToolApprovalConfiguration | undefined;
84
92
  pendingToolApprovals?: readonly HarnessV1PendingToolApproval[];
85
93
  pendingToolResults?: readonly HarnessV1PendingToolResult[];
@@ -95,6 +103,7 @@ export function runPrompt<
95
103
  onToolResultSettled?: (toolCallId: string) => void;
96
104
  onTurnFinished?: () => void;
97
105
  onTurnFailed?: () => void;
106
+ onStopConditionMet?: () => Promise<void>;
98
107
  }): {
99
108
  result: HarnessStreamTextResult<TOOLS, RUNTIME_CONTEXT>;
100
109
  done: Promise<void>;
@@ -124,6 +133,29 @@ export function runPrompt<
124
133
  runtimeContext: input.runtimeContext,
125
134
  });
126
135
 
136
+ /*
137
+ * Settle a failed turn. When the caller's own `abortSignal` has fired, the
138
+ * failure is a user-initiated stop, not an error: surface it as an `abort`
139
+ * stream part — matching `streamText`'s abort contract — so
140
+ * `toUIMessageStream` consumers observe an `abort` chunk and
141
+ * `isAborted: true` instead of a spurious `onError`. Every other failure
142
+ * stays a real `error` part. Both outcomes notify `onTurnFailed` so the
143
+ * session's turn tracking returns to idle and the session stays usable.
144
+ */
145
+ const settleFailure = (err: unknown) => {
146
+ input.onTurnFailed?.();
147
+ if (input.abortSignal?.aborted) {
148
+ result.abort({
149
+ error: err,
150
+ ...(input.abortSignal.reason !== undefined
151
+ ? { reason: getErrorMessage(input.abortSignal.reason) }
152
+ : {}),
153
+ });
154
+ return;
155
+ }
156
+ result.fail(err);
157
+ };
158
+
127
159
  const done = (async () => {
128
160
  let bridge: Awaited<ReturnType<typeof toHarnessStream>>;
129
161
  try {
@@ -159,8 +191,7 @@ export function runPrompt<
159
191
  context: 'failed to start harness turn',
160
192
  error: err,
161
193
  });
162
- input.onTurnFailed?.();
163
- result.fail(err);
194
+ settleFailure(err);
164
195
  return;
165
196
  }
166
197
 
@@ -197,9 +228,21 @@ export function runPrompt<
197
228
  );
198
229
  const settledHostToolCallIds = new Set<string>();
199
230
  let closingResumedStep = false;
231
+ let pendingStopBoundary:
232
+ | {
233
+ finishReason: LanguageModelV4FinishReason;
234
+ usage: LanguageModelV4Usage;
235
+ releaseCheckpoint: (() => void) | undefined;
236
+ }
237
+ | undefined;
200
238
  let finalFinish:
201
239
  | Extract<HarnessV1StreamPart, { type: 'finish' }>
202
240
  | undefined;
241
+ const completedSteps: Array<StepResult<TOOLS, RUNTIME_CONTEXT>> = [];
242
+ const releasePendingStopBoundary = (): void => {
243
+ pendingStopBoundary?.releaseCheckpoint?.();
244
+ pendingStopBoundary = undefined;
245
+ };
203
246
 
204
247
  // Accumulate the model's output content per step so telemetry can record
205
248
  // `gen_ai.output.messages` and reporters can log what was actually said.
@@ -247,12 +290,14 @@ export function runPrompt<
247
290
  content: buildStepContent(),
248
291
  });
249
292
  resetStepContent();
250
- return result.finishStep({
293
+ const step = result.finishStep({
251
294
  finishReason: input.finishReason,
252
295
  usage: input.usage,
253
296
  providerMetadata: input.providerMetadata,
254
297
  warnings: [],
255
298
  });
299
+ completedSteps.push(step);
300
+ return step;
256
301
  };
257
302
  const finishForHostInputPause = async (options: {
258
303
  completeCurrentStep: boolean;
@@ -390,9 +435,16 @@ export function runPrompt<
390
435
  input: approval.input,
391
436
  } satisfies Extract<HarnessV1StreamPart, { type: 'tool-call' }>);
392
437
 
438
+ telemetry.start(input.session.modelId);
439
+ await telemetry.toolStart({
440
+ toolCallId: rawToolCall.toolCallId,
441
+ toolName: rawToolCall.toolName,
442
+ input: rawToolCall.input,
443
+ });
393
444
  const execution = await maybeExecuteHostTool({
394
445
  event: rawToolCall,
395
446
  tools: activeTools,
447
+ wrappedExecuteTool: telemetry.executeTool,
396
448
  sandboxSession: input.sandboxSession,
397
449
  abortSignal: input.abortSignal,
398
450
  control,
@@ -455,9 +507,35 @@ export function runPrompt<
455
507
 
456
508
  while (true) {
457
509
  const { value, done } = await reader.read();
458
- if (done) break;
510
+ if (done) {
511
+ releasePendingStopBoundary();
512
+ break;
513
+ }
459
514
  if (value == null) continue;
460
515
 
516
+ if (pendingStopBoundary != null) {
517
+ if (value.type === 'finish') {
518
+ releasePendingStopBoundary();
519
+ } else if (
520
+ (
521
+ await Promise.all(
522
+ input.stopConditions!.map(condition =>
523
+ condition({ steps: completedSteps }),
524
+ ),
525
+ )
526
+ ).some(Boolean)
527
+ ) {
528
+ await input.onStopConditionMet?.();
529
+ const { finishReason, usage } = pendingStopBoundary;
530
+ releasePendingStopBoundary();
531
+ telemetry.end({ finishReason, usage });
532
+ await result.finish();
533
+ return;
534
+ } else {
535
+ releasePendingStopBoundary();
536
+ }
537
+ }
538
+
461
539
  // Begin the operation span on stream-start, using the runtime-resolved
462
540
  // model the adapter reports (falling back to the session's model).
463
541
  if (value.type === 'stream-start') {
@@ -531,6 +609,28 @@ export function runPrompt<
531
609
  }
532
610
  }
533
611
 
612
+ /*
613
+ * Settle failures before the translate-and-forward step below: the
614
+ * translated `error` part must not reach the consumer stream, or the
615
+ * turn surfaces BOTH a forwarded `error` part and the settle-owned
616
+ * terminal part (an `abort` part for user stops via `settleFailure`,
617
+ * or a second `error` part from `fail`).
618
+ */
619
+ if (value.type === 'error' && displayValue.type === 'error') {
620
+ // Telemetry and stderr diagnostics keep the raw error (absolute
621
+ // paths help debugging); the consumer-facing settle uses the
622
+ // workDir-stripped one, like every other forwarded part.
623
+ telemetry.error(value.error);
624
+ logBridgeError({
625
+ harnessId: input.harness.harnessId,
626
+ sessionId: input.session.sessionId,
627
+ context: 'harness stream error',
628
+ error: value.error,
629
+ });
630
+ settleFailure(displayValue.error);
631
+ return;
632
+ }
633
+
534
634
  // Forward to consumer as soon as possible.
535
635
  for (const part of translateStreamPart<TOOLS>(displayValue)) {
536
636
  result.enqueue(part);
@@ -564,7 +664,7 @@ export function runPrompt<
564
664
  toolName: value.toolName,
565
665
  input: value.input,
566
666
  });
567
- telemetry.toolStart({
667
+ await telemetry.toolStart({
568
668
  toolCallId: value.toolCallId,
569
669
  toolName: value.toolName,
570
670
  input: value.input,
@@ -641,6 +741,13 @@ export function runPrompt<
641
741
  usage: value.usage,
642
742
  providerMetadata: value.harnessMetadata,
643
743
  });
744
+ if (input.stopConditions != null && input.stopConditions.length > 0) {
745
+ pendingStopBoundary = {
746
+ finishReason: value.finishReason,
747
+ usage: value.usage,
748
+ releaseCheckpoint: pinSandboxChannelEventCheckpoint(value),
749
+ };
750
+ }
644
751
  }
645
752
 
646
753
  if (value.type === 'finish') {
@@ -758,6 +865,7 @@ export function runPrompt<
758
865
  const execution = await maybeExecuteHostTool({
759
866
  event: toolCall,
760
867
  tools: activeTools,
868
+ wrappedExecuteTool: telemetry.executeTool,
761
869
  sandboxSession: input.sandboxSession,
762
870
  abortSignal: input.abortSignal,
763
871
  control,
@@ -799,19 +907,6 @@ export function runPrompt<
799
907
  }
800
908
  telemetry.toolEnd(toolCall.toolCallId, execution.outcome);
801
909
  }
802
-
803
- if (value.type === 'error') {
804
- telemetry.error(value.error);
805
- logBridgeError({
806
- harnessId: input.harness.harnessId,
807
- sessionId: input.session.sessionId,
808
- context: 'harness stream error',
809
- error: value.error,
810
- });
811
- input.onTurnFailed?.();
812
- result.fail(value.error);
813
- return;
814
- }
815
910
  }
816
911
  if (finalFinish != null) {
817
912
  input.onTurnFinished?.();
@@ -835,9 +930,9 @@ export function runPrompt<
835
930
  context: 'harness turn failed',
836
931
  error: err,
837
932
  });
838
- input.onTurnFailed?.();
839
- result.fail(err);
933
+ settleFailure(err);
840
934
  } finally {
935
+ releasePendingStopBoundary();
841
936
  reader.releaseLock();
842
937
  }
843
938
  })();
@@ -890,6 +985,7 @@ function hasTool(input: { tools: ToolSet; toolName: string }): boolean {
890
985
  async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
891
986
  event: { toolCallId: string; toolName: string; input: string };
892
987
  tools: TOOLS;
988
+ wrappedExecuteTool: TurnTelemetry['executeTool'];
893
989
  sandboxSession: SandboxSession;
894
990
  abortSignal: AbortSignal | undefined;
895
991
  control: HarnessV1PromptControl;
@@ -918,25 +1014,31 @@ async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
918
1014
  * back to the model — preliminary values are surfaced to the consumer
919
1015
  * stream alone, matching how the AI SDK treats `onPreliminaryToolResult`.
920
1016
  */
921
- let output: unknown;
922
- const stream = executeTool({
923
- tool,
924
- input: args as never,
925
- options: {
926
- toolCallId: input.event.toolCallId,
927
- messages: [],
928
- abortSignal: input.abortSignal,
929
- context: undefined as never,
930
- experimental_sandbox: input.sandboxSession,
1017
+ const output = await input.wrappedExecuteTool({
1018
+ toolCallId: input.event.toolCallId,
1019
+ execute: async () => {
1020
+ let output: unknown;
1021
+ const stream = executeTool({
1022
+ tool,
1023
+ input: args as never,
1024
+ options: {
1025
+ toolCallId: input.event.toolCallId,
1026
+ messages: [],
1027
+ abortSignal: input.abortSignal,
1028
+ context: undefined as never,
1029
+ experimental_sandbox: input.sandboxSession,
1030
+ },
1031
+ });
1032
+ for await (const part of stream) {
1033
+ if (part.type === 'preliminary') {
1034
+ input.onPreliminaryResult(part.output);
1035
+ } else {
1036
+ output = part.output;
1037
+ }
1038
+ }
1039
+ return output;
931
1040
  },
932
1041
  });
933
- for await (const part of stream) {
934
- if (part.type === 'preliminary') {
935
- input.onPreliminaryResult(part.output);
936
- } else {
937
- output = part.output;
938
- }
939
- }
940
1042
 
941
1043
  await input.control.submitToolResult({
942
1044
  toolCallId: input.event.toolCallId,
@@ -58,7 +58,12 @@ export interface TurnTelemetry {
58
58
  toolCallId: string;
59
59
  toolName: string;
60
60
  input: unknown;
61
- }): void;
61
+ }): void | Promise<void>;
62
+ /** Execute a host tool through each telemetry integration's context wrapper. */
63
+ executeTool<T>(input: {
64
+ toolCallId: string;
65
+ execute: () => PromiseLike<T>;
66
+ }): Promise<T>;
62
67
  /**
63
68
  * A tool execution completed (on its `tool-result` or after host execution).
64
69
  * Idempotent per `toolCallId` — the first caller wins, so provider-executed
@@ -78,7 +83,10 @@ const NOOP: TurnTelemetry = {
78
83
  start() {},
79
84
  ensureStepOpen() {},
80
85
  stepFinish() {},
81
- toolStart() {},
86
+ async toolStart() {},
87
+ async executeTool({ execute }) {
88
+ return await execute();
89
+ },
82
90
  toolEnd() {},
83
91
  end() {},
84
92
  error() {},
@@ -258,10 +266,11 @@ export function createTurnTelemetry(opts: {
258
266
  stepNumber += 1;
259
267
  },
260
268
 
261
- toolStart(call) {
269
+ async toolStart(call) {
262
270
  ensureStepOpen();
271
+ if (openTools.has(call.toolCallId)) return;
263
272
  openTools.set(call.toolCallId, call);
264
- dispatcher.onToolExecutionStart?.(
273
+ await dispatcher.onToolExecutionStart?.(
265
274
  cast<'onToolExecutionStart'>({
266
275
  callId,
267
276
  messages: [],
@@ -277,6 +286,11 @@ export function createTurnTelemetry(opts: {
277
286
  );
278
287
  },
279
288
 
289
+ async executeTool({ toolCallId, execute }) {
290
+ if (dispatcher.executeTool == null) return await execute();
291
+ return await dispatcher.executeTool({ callId, toolCallId, execute });
292
+ },
293
+
280
294
  toolEnd(toolCallId, output) {
281
295
  const call = openTools.get(toolCallId);
282
296
  if (call == null) return;
@@ -234,6 +234,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
234
234
  let currentBoundPort = 0;
235
235
  let currentTurnState: BridgeState = 'init';
236
236
  let activeSocket: WebSocket | undefined;
237
+ let activeSocketReadyForLiveEvents = false;
237
238
  let isFirstTurn = true;
238
239
  let turnAbort: AbortController | undefined;
239
240
  let currentUserMessages: string[] | undefined;
@@ -389,7 +390,10 @@ export async function runBridge<TStart extends { type: 'start' }>(
389
390
  eventLog.push({ seq, line });
390
391
  diskBuffer += `${line}\n`;
391
392
  scheduleEventFlush();
392
- if (activeSocket?.readyState === WS_OPEN) {
393
+ if (
394
+ activeSocketReadyForLiveEvents &&
395
+ activeSocket?.readyState === WS_OPEN
396
+ ) {
393
397
  try {
394
398
  activeSocket.send(line);
395
399
  } catch {
@@ -522,6 +526,8 @@ export async function runBridge<TStart extends { type: 'start' }>(
522
526
  ): Promise<void> => {
523
527
  switch (msg.type) {
524
528
  case 'start': {
529
+ if (activeSocket !== ws) return;
530
+ activeSocketReadyForLiveEvents = true;
525
531
  const firstTurn = isFirstTurn;
526
532
  isFirstTurn = false;
527
533
  eventLog = []; // clear previous turn; keep seqCounter monotonic
@@ -643,7 +649,9 @@ export async function runBridge<TStart extends { type: 'start' }>(
643
649
  }
644
650
  return;
645
651
  case 'resume':
652
+ if (activeSocket !== ws) return;
646
653
  replay(ws, msg.lastSeenEventId);
654
+ activeSocketReadyForLiveEvents = true;
647
655
  return;
648
656
  case 'shutdown':
649
657
  currentTurnState = 'done';
@@ -721,6 +729,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
721
729
  // (the host reconnecting after a drop). The previous socket's close is a
722
730
  // no-op below because it is no longer `activeSocket`.
723
731
  activeSocket = ws;
732
+ activeSocketReadyForLiveEvents = false;
724
733
 
725
734
  // Announce liveness the instant we accept. Some sandbox runtimes complete
726
735
  // the host-side WS handshake before the connection is forwarded here; the
@@ -754,6 +763,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
754
763
  // log for replay when the host reconnects.
755
764
  if (activeSocket === ws) {
756
765
  activeSocket = undefined;
766
+ activeSocketReadyForLiveEvents = false;
757
767
  }
758
768
  });
759
769