@ai-sdk/harness-opencode 1.0.22 → 1.0.24

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.
@@ -21,6 +21,10 @@ import {
21
21
  type OpenCodeEvent,
22
22
  unwrapOpenCodeEvent,
23
23
  } from './opencode-events';
24
+ import {
25
+ legacyStepFinishPartToFinishStep,
26
+ mapOpenCodeFinishReason,
27
+ } from './opencode-finish-step';
24
28
  import { prependOpenCodeBinToPath } from './opencode-path';
25
29
  import {
26
30
  addUsage,
@@ -144,7 +148,7 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
144
148
  totalUsage = await runPrompt({ client, sessionId, start, turn, emit });
145
149
  }
146
150
  } catch (err) {
147
- emit({ type: 'error', error: serialiseError(err) });
151
+ turn.emitError({ error: err, message: 'OpenCode turn failed' });
148
152
  } finally {
149
153
  emit({
150
154
  type: 'finish',
@@ -469,11 +473,45 @@ function legacyStatusType(event: OpenCodeEvent): string | undefined {
469
473
  : undefined;
470
474
  }
471
475
 
472
- function legacyStatusMessage(event: OpenCodeEvent): string | undefined {
476
+ function legacyRetryStatusMessage(event: OpenCodeEvent): string {
473
477
  const status = event.properties?.status;
474
- if (!status || typeof status !== 'object') return undefined;
475
- const message = (status as { message?: unknown }).message;
476
- return typeof message === 'string' ? message : undefined;
478
+ const details: string[] = [];
479
+ if (status && typeof status === 'object') {
480
+ const retryStatus = status as { attempt?: unknown; message?: unknown };
481
+ if (typeof retryStatus.attempt === 'number') {
482
+ details.push(`attempt ${retryStatus.attempt}`);
483
+ }
484
+ if (typeof retryStatus.message === 'string' && retryStatus.message.trim()) {
485
+ details.push(retryStatus.message.trim());
486
+ }
487
+ }
488
+ return details.length > 0
489
+ ? `OpenCode session retry: ${details.join('; ')}`
490
+ : 'OpenCode session retry';
491
+ }
492
+
493
+ function nextRetryEventMessage(event: OpenCodeEvent): string {
494
+ const props = event.properties ?? {};
495
+ const details: string[] = [];
496
+ if (typeof props.attempt === 'number') {
497
+ details.push(`attempt ${props.attempt}`);
498
+ }
499
+ const error = props.error;
500
+ if (isRecord(error)) {
501
+ const message =
502
+ stringValue(error.message) ??
503
+ (isRecord(error.data) ? stringValue(error.data.message) : undefined);
504
+ const statusCode = error.statusCode;
505
+ if (typeof statusCode === 'number') {
506
+ details.push(`HTTP ${statusCode}`);
507
+ }
508
+ if (message) details.push(message);
509
+ } else if (error != null) {
510
+ details.push(formatError(error));
511
+ }
512
+ return details.length > 0
513
+ ? `OpenCode session retry: ${details.join('; ')}`
514
+ : 'OpenCode session retry';
477
515
  }
478
516
 
479
517
  async function ensureSession({
@@ -569,9 +607,7 @@ async function runPrompt({
569
607
  sawBusy = true;
570
608
  } else if (status === 'retry') {
571
609
  sawBusy = true;
572
- terminalError = legacyStatusMessage(event) ?? 'Session retry';
573
- turnSettled.resolve();
574
- return true;
610
+ turn.emitWarning({ message: legacyRetryStatusMessage(event) });
575
611
  } else if (sawBusy && status === 'idle') {
576
612
  turnSettled.resolve();
577
613
  return true;
@@ -682,9 +718,7 @@ async function runCompaction({
682
718
  sawBusy = true;
683
719
  } else if (status === 'retry') {
684
720
  sawBusy = true;
685
- terminalError = legacyStatusMessage(event) ?? 'Session retry';
686
- compactionSettled.resolve();
687
- return true;
721
+ turn.emitWarning({ message: legacyRetryStatusMessage(event) });
688
722
  } else if (sawBusy && status === 'idle') {
689
723
  compactionSettled.resolve();
690
724
  return true;
@@ -777,6 +811,7 @@ type TranslationState = {
777
811
  turnUsage: Record<string, unknown> | undefined;
778
812
  legacyTextPartIds: Set<string>;
779
813
  legacyReasoningPartIds: Set<string>;
814
+ legacyStepFinishPartIds: Set<string>;
780
815
  };
781
816
 
782
817
  function createTranslationState(): TranslationState {
@@ -793,6 +828,7 @@ function createTranslationState(): TranslationState {
793
828
  turnUsage: undefined,
794
829
  legacyTextPartIds: new Set(),
795
830
  legacyReasoningPartIds: new Set(),
831
+ legacyStepFinishPartIds: new Set(),
796
832
  };
797
833
  }
798
834
 
@@ -860,6 +896,7 @@ async function translateAndEmit({
860
896
 
861
897
  if (type === 'message.part.updated') {
862
898
  if (emitLegacyTextPartUpdate({ part: props.part, state, emit })) return;
899
+ if (emitLegacyStepFinishPart({ part: props.part, state, emit })) return;
863
900
  emitLegacyToolPart({ part: props.part, state, emit });
864
901
  return;
865
902
  }
@@ -1020,13 +1057,25 @@ async function translateAndEmit({
1020
1057
  });
1021
1058
  return;
1022
1059
  }
1060
+ if (type === 'session.next.retried') {
1061
+ const error = props.error ?? event;
1062
+ if (isRecord(error) && error.isRetryable === false) {
1063
+ turn.emitError({
1064
+ error,
1065
+ message: 'OpenCode session retry failed',
1066
+ });
1067
+ } else {
1068
+ turn.emitWarning({ message: nextRetryEventMessage(event) });
1069
+ }
1070
+ return;
1071
+ }
1023
1072
  if (type === 'session.next.step.ended') {
1024
1073
  closeLegacyOpenParts({ state, emit });
1025
1074
  state.turnUsage = mapUsage(props.tokens);
1026
1075
  emit({
1027
1076
  type: 'finish-step',
1028
1077
  finishReason: {
1029
- unified: mapFinishReason(String(props.finish ?? 'stop')),
1078
+ unified: mapOpenCodeFinishReason(String(props.finish ?? 'stop')),
1030
1079
  raw: String(props.finish ?? 'stop'),
1031
1080
  },
1032
1081
  usage: state.turnUsage,
@@ -1058,7 +1107,14 @@ async function translateAndEmit({
1058
1107
  return;
1059
1108
  }
1060
1109
  if (type === 'session.error' || type === 'session.next.step.failed') {
1061
- emit({ type: 'error', error: formatError(props.error ?? event) });
1110
+ const error = props.error ?? event;
1111
+ turn.emitError({
1112
+ error,
1113
+ message:
1114
+ type === 'session.error'
1115
+ ? 'OpenCode session error'
1116
+ : 'OpenCode step failed',
1117
+ });
1062
1118
  return;
1063
1119
  }
1064
1120
  if (type === 'permission.v2.asked') {
@@ -1187,6 +1243,28 @@ function closeLegacyOpenParts({
1187
1243
  state.legacyTextPartIds.clear();
1188
1244
  }
1189
1245
 
1246
+ function emitLegacyStepFinishPart({
1247
+ part,
1248
+ state,
1249
+ emit,
1250
+ }: {
1251
+ part: unknown;
1252
+ state: TranslationState;
1253
+ emit: Emit;
1254
+ }): boolean {
1255
+ const event = legacyStepFinishPartToFinishStep(part);
1256
+ if (!event) return false;
1257
+ const id = isRecord(part) ? stringValue(part.id) : undefined;
1258
+ if (id) {
1259
+ if (state.legacyStepFinishPartIds.has(id)) return true;
1260
+ state.legacyStepFinishPartIds.add(id);
1261
+ }
1262
+ closeLegacyOpenParts({ state, emit });
1263
+ state.turnUsage = event.usage as Record<string, unknown>;
1264
+ emit(event);
1265
+ return true;
1266
+ }
1267
+
1190
1268
  function emitLegacyToolPart({
1191
1269
  part,
1192
1270
  state,
@@ -1592,7 +1670,7 @@ async function emitContextFallback({
1592
1670
  emit({
1593
1671
  type: 'finish-step',
1594
1672
  finishReason: {
1595
- unified: mapFinishReason(rawFinish),
1673
+ unified: mapOpenCodeFinishReason(rawFinish),
1596
1674
  raw: rawFinish,
1597
1675
  },
1598
1676
  usage: mapUsage(assistant.tokens),
@@ -1724,19 +1802,6 @@ function emitAssistantContentPart(part: unknown, emit: Emit): void {
1724
1802
  emit({ type: 'reasoning-end', id });
1725
1803
  }
1726
1804
 
1727
- function mapFinishReason(
1728
- reason: string,
1729
- ): 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' {
1730
- const normalized = reason.toLowerCase();
1731
- if (normalized.includes('length')) return 'length';
1732
- if (normalized.includes('filter')) return 'content-filter';
1733
- if (normalized.includes('tool')) return 'tool-calls';
1734
- if (normalized.includes('error') || normalized.includes('fail'))
1735
- return 'error';
1736
- if (normalized === 'stop' || normalized === 'end') return 'stop';
1737
- return 'other';
1738
- }
1739
-
1740
1805
  async function startToolRelay({
1741
1806
  allowedScriptPaths,
1742
1807
  tools,
@@ -1980,14 +2045,7 @@ function formatError(error: unknown): string {
1980
2045
  }
1981
2046
  }
1982
2047
 
1983
- function serialiseError(err: unknown): unknown {
1984
- if (err instanceof Error) {
1985
- return { name: err.name, message: err.message, stack: err.stack };
1986
- }
1987
- return err;
1988
- }
1989
-
1990
2048
  function emitFatal(message: string): never {
1991
- process.stderr.write(`[opencode bridge] ${message}\n`);
2049
+ process.stderr.write(`[OpenCode bridge] ${message}\n`);
1992
2050
  process.exit(1);
1993
2051
  }
@@ -0,0 +1,39 @@
1
+ import type { HarnessV1StreamPart } from '@ai-sdk/harness';
2
+ import { mapUsage } from './opencode-usage';
3
+
4
+ type FinishStepEvent = Extract<HarnessV1StreamPart, { type: 'finish-step' }>;
5
+
6
+ export function mapOpenCodeFinishReason(
7
+ reason: string,
8
+ ): 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' {
9
+ const normalized = reason.toLowerCase();
10
+ if (normalized.includes('length')) return 'length';
11
+ if (normalized.includes('filter')) return 'content-filter';
12
+ if (normalized.includes('tool')) return 'tool-calls';
13
+ if (normalized.includes('error') || normalized.includes('fail'))
14
+ return 'error';
15
+ if (normalized === 'stop' || normalized === 'end') return 'stop';
16
+ return 'other';
17
+ }
18
+
19
+ export function legacyStepFinishPartToFinishStep(
20
+ part: unknown,
21
+ ): FinishStepEvent | undefined {
22
+ if (!isRecord(part) || part.type !== 'step-finish') return undefined;
23
+ const rawFinish = typeof part.reason === 'string' ? part.reason : 'stop';
24
+ return {
25
+ type: 'finish-step',
26
+ finishReason: {
27
+ unified: mapOpenCodeFinishReason(rawFinish),
28
+ raw: rawFinish,
29
+ },
30
+ usage: mapUsage(part.tokens),
31
+ ...(typeof part.cost === 'number'
32
+ ? { harnessMetadata: { opencode: { cost: part.cost } } }
33
+ : {}),
34
+ };
35
+ }
36
+
37
+ function isRecord(value: unknown): value is Record<string, unknown> {
38
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
39
+ }
@@ -1,3 +1,7 @@
1
+ import type { HarnessV1StreamPart } from '@ai-sdk/harness';
2
+
3
+ type FinishStepEvent = Extract<HarnessV1StreamPart, { type: 'finish-step' }>;
4
+
1
5
  export type OpenCodeTokenUsage = {
2
6
  readonly input: number;
3
7
  readonly output: number;
@@ -8,7 +12,7 @@ export type OpenCodeTokenUsage = {
8
12
  };
9
13
  };
10
14
 
11
- export type HarnessUsage = Record<string, unknown>;
15
+ export type HarnessUsage = FinishStepEvent['usage'];
12
16
 
13
17
  export function mapUsage(tokens: unknown): HarnessUsage {
14
18
  const value = extractOpenCodeTokens(tokens) ?? zeroOpenCodeTokens();
@@ -23,6 +23,10 @@ import {
23
23
  } from '@ai-sdk/harness';
24
24
  import {
25
25
  classifyDiskLog,
26
+ createBridgeErrorHandler,
27
+ createBridgeStartupError,
28
+ drainBridgeProcessStream,
29
+ forwardBridgeProcessStream,
26
30
  markBridgeStarting,
27
31
  resolveSandboxHomeDir,
28
32
  SandboxChannel,
@@ -274,6 +278,10 @@ export function createOpenCode(
274
278
  }),
275
279
  )
276
280
  : undefined;
281
+ const onBridgeError = createBridgeErrorHandler({
282
+ harnessId: 'opencode',
283
+ sessionId: startOpts.sessionId,
284
+ });
277
285
 
278
286
  if (coords) {
279
287
  try {
@@ -287,6 +295,7 @@ export function createOpenCode(
287
295
  outboundSchema: outboundMessageSchema,
288
296
  initialLastSeenEventId: coords.lastSeenEventId,
289
297
  onDiagnostic,
298
+ onBridgeError,
290
299
  });
291
300
  await attachChannel.open(isContinue ? { resume: true } : undefined);
292
301
  return createSession({
@@ -383,6 +392,13 @@ export function createOpenCode(
383
392
  env,
384
393
  abortSignal: startOpts.abortSignal,
385
394
  });
395
+ const stderrTail: string[] = [];
396
+ const bridgeStderrDone = forwardBridgeProcessStream({
397
+ stream: proc.stderr,
398
+ streamName: 'stderr',
399
+ source: 'opencode',
400
+ collectTail: stderrTail,
401
+ });
386
402
 
387
403
  const { port: boundPort } = await waitForBridgeReady({
388
404
  proc,
@@ -391,13 +407,24 @@ export function createOpenCode(
391
407
  bridgeType: 'opencode',
392
408
  timeoutMs,
393
409
  abortSignal: startOpts.abortSignal,
394
- createTimeoutError: () =>
395
- new Error('opencode bridge did not become ready in time.'),
396
- createExitError: () =>
397
- new Error('opencode bridge exited before becoming ready.'),
410
+ createTimeoutError: ({ proc, stdoutTail }) =>
411
+ createBridgeStartupError({
412
+ message: 'OpenCode bridge did not become ready in time.',
413
+ proc,
414
+ stdoutTail,
415
+ stderrTail,
416
+ stderrDone: bridgeStderrDone,
417
+ }),
418
+ createExitError: ({ proc, stdoutTail }) =>
419
+ createBridgeStartupError({
420
+ message: 'OpenCode bridge exited before becoming ready.',
421
+ proc,
422
+ stdoutTail,
423
+ stderrTail,
424
+ stderrDone: bridgeStderrDone,
425
+ }),
398
426
  });
399
- void drainRest(proc.stdout);
400
- void forwardBridgeStderr(proc.stderr);
427
+ void drainBridgeProcessStream(proc.stdout);
401
428
 
402
429
  const wsUrl =
403
430
  (await sandboxSession.getPortUrl({
@@ -409,6 +436,7 @@ export function createOpenCode(
409
436
  connect: () => openWebSocket(wsUrl),
410
437
  outboundSchema: outboundMessageSchema,
411
438
  onDiagnostic,
439
+ onBridgeError,
412
440
  ...(respawnStrategy === 'replay'
413
441
  ? { initialLastSeenEventId: coords?.lastSeenEventId ?? 0 }
414
442
  : {}),
@@ -448,7 +476,7 @@ function resolveBridgePort(
448
476
  throw new HarnessCapabilityUnsupportedError({
449
477
  harnessId: 'opencode',
450
478
  message:
451
- 'The opencode harness needs a TCP port exposed by the sandbox. ' +
479
+ 'The OpenCode harness needs a TCP port exposed by the sandbox. ' +
452
480
  'Create the sandbox with `ports: [<port>]` or pass `createOpenCode({ port })`.',
453
481
  });
454
482
  }
@@ -497,34 +525,6 @@ async function writeOpenCodeSkills({
497
525
  return { skillsDir };
498
526
  }
499
527
 
500
- async function forwardBridgeStderr(
501
- stream: ReadableStream<Uint8Array>,
502
- ): Promise<void> {
503
- try {
504
- const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
505
- while (true) {
506
- const { value, done } = await reader.read();
507
- if (done) return;
508
- if (value) {
509
- const trimmed = value.endsWith('\n') ? value.slice(0, -1) : value;
510
- if (trimmed.length > 0) {
511
- console.log(`[bridge stderr] ${trimmed}`);
512
- }
513
- }
514
- }
515
- } catch {}
516
- }
517
-
518
- async function drainRest(stream: ReadableStream<Uint8Array>): Promise<void> {
519
- try {
520
- const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
521
- while (true) {
522
- const { done } = await reader.read();
523
- if (done) return;
524
- }
525
- } catch {}
526
- }
527
-
528
528
  function openWebSocket(url: string): Promise<WebSocket> {
529
529
  return new Promise((resolve, reject) => {
530
530
  const ws = new WebSocket(url);
@@ -668,7 +668,7 @@ function createSession({
668
668
  return;
669
669
  }
670
670
  settleError(
671
- new Error('opencode bridge closed before the turn finished.'),
671
+ new Error('OpenCode bridge closed before the turn finished.'),
672
672
  );
673
673
  };
674
674
  channel.onClose(onClose);
@@ -811,7 +811,7 @@ function createSession({
811
811
  doDetach: async () => {
812
812
  if (stopped) {
813
813
  throw new Error(
814
- `opencode session ${sessionId} is already stopped; cannot detach.`,
814
+ `OpenCode session ${sessionId} is already stopped; cannot detach.`,
815
815
  );
816
816
  }
817
817
  stopped = true;
@@ -867,7 +867,7 @@ function createSession({
867
867
  doStop: async () => {
868
868
  if (stopped) {
869
869
  throw new Error(
870
- `opencode session ${sessionId} is already stopped; cannot stop.`,
870
+ `OpenCode session ${sessionId} is already stopped; cannot stop.`,
871
871
  );
872
872
  }
873
873
  stopped = true;
@@ -879,7 +879,7 @@ function createSession({
879
879
  unsub();
880
880
  reject(
881
881
  new Error(
882
- `opencode session ${sessionId} did not reply to detach within 5s.`,
882
+ `OpenCode session ${sessionId} did not reply to detach within 5s.`,
883
883
  ),
884
884
  );
885
885
  }, 5000);
@@ -928,10 +928,11 @@ function createSession({
928
928
  doSuspendTurn: async () => {
929
929
  if (stopped) {
930
930
  throw new Error(
931
- `opencode session ${sessionId} is stopped; cannot suspend.`,
931
+ `OpenCode session ${sessionId} is stopped; cannot suspend.`,
932
932
  );
933
933
  }
934
934
  stopped = true;
935
+ await channel.interrupt();
935
936
  const lastSeenEventId = await channel.suspend();
936
937
  const payload: HarnessV1ContinueTurnState = {
937
938
  type: 'continue-turn',
@@ -1011,7 +1012,7 @@ function extractUserText(prompt: HarnessV1Prompt): string {
1011
1012
  if (part.type !== 'text') {
1012
1013
  throw new HarnessCapabilityUnsupportedError({
1013
1014
  harnessId: 'opencode',
1014
- message: `The opencode harness does not yet support user message parts of type '${part.type}'. Pass a string or a user message whose content contains only text parts.`,
1015
+ message: `The OpenCode harness does not yet support user message parts of type '${part.type}'. Pass a string or a user message whose content contains only text parts.`,
1015
1016
  });
1016
1017
  }
1017
1018
  parts.push(part.text);