@ai-sdk/harness-opencode 1.0.102 → 1.0.104

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.
@@ -202,7 +202,9 @@ export function createEmitStreamEvent({
202
202
  if (type === 'session.next.tool.called') {
203
203
  const callID = String(props.callID ?? event.id);
204
204
  const rawToolName = String(props.tool ?? 'unknown');
205
- if (rawToolName === 'StructuredOutput') return;
205
+ if (rawToolName === 'StructuredOutput' || rawToolName === 'question') {
206
+ return;
207
+ }
206
208
  const toolName = toWireToolName(rawToolName);
207
209
  state.toolNames.set(callID, { rawToolName, toolName });
208
210
  const hostToolName = getHostToolName(toolName, props.tool);
@@ -238,7 +240,9 @@ export function createEmitStreamEvent({
238
240
  const rawToolName =
239
241
  cachedTool?.rawToolName ??
240
242
  String((props as { tool?: unknown }).tool ?? '');
241
- if (rawToolName === 'StructuredOutput') return;
243
+ if (rawToolName === 'StructuredOutput' || rawToolName === 'question') {
244
+ return;
245
+ }
242
246
  const toolName =
243
247
  cachedTool?.toolName ?? toWireToolName(rawToolName || 'unknown');
244
248
  if (getHostToolName(toolName, rawToolName)) return;
@@ -397,7 +401,7 @@ function emitLegacyToolPart({
397
401
  }
398
402
  const callID = toolPart.callID;
399
403
  const rawToolName = toolPart.tool;
400
- if (rawToolName === 'StructuredOutput') return;
404
+ if (rawToolName === 'StructuredOutput' || rawToolName === 'question') return;
401
405
  const toolName = toWireToolName(rawToolName);
402
406
  if (toolName === 'agent') {
403
407
  const metadata = {
@@ -44,6 +44,12 @@ import {
44
44
  type OpenCodeObject,
45
45
  } from './opencode-types';
46
46
  import { startAuthorizedToolRelay, type ToolRelay } from './tool-relay';
47
+ import {
48
+ openCodeQuestionKey,
49
+ toHarnessQuestionsInput,
50
+ toOpenCodeQuestionResponse,
51
+ type OpenCodeQuestionRequest,
52
+ } from './question-tool';
47
53
 
48
54
  type Emit = (msg: Record<string, unknown>) => void;
49
55
 
@@ -65,7 +71,8 @@ type CommonBuiltinToolName =
65
71
  | 'edit'
66
72
  | 'bash'
67
73
  | 'glob'
68
- | 'grep';
74
+ | 'grep'
75
+ | 'askUserQuestions';
69
76
 
70
77
  const NATIVE_TO_COMMON: Readonly<Record<string, CommonBuiltinToolName>> = {
71
78
  view: 'read',
@@ -75,6 +82,7 @@ const NATIVE_TO_COMMON: Readonly<Record<string, CommonBuiltinToolName>> = {
75
82
  bash: 'bash',
76
83
  glob: 'glob',
77
84
  grep: 'grep',
85
+ question: 'askUserQuestions',
78
86
  };
79
87
 
80
88
  const OPENCODE_TO_WIRE: Readonly<Record<string, string>> = {
@@ -83,6 +91,7 @@ const OPENCODE_TO_WIRE: Readonly<Record<string, string>> = {
83
91
  webfetch: 'webfetch',
84
92
  task: 'agent',
85
93
  agent: 'agent',
94
+ askUserQuestions: 'question',
86
95
  subtask: 'agent',
87
96
  };
88
97
 
@@ -256,7 +265,7 @@ function buildOpenCodeConfig({
256
265
  webfetch: 'ask',
257
266
  doom_loop: 'ask',
258
267
  task: 'ask',
259
- question: 'deny',
268
+ question: 'allow',
260
269
  },
261
270
  };
262
271
  if (start.model) config.model = start.model;
@@ -333,8 +342,15 @@ function buildProviderConfig(
333
342
  apiKey: procEnv.AI_GATEWAY_API_KEY,
334
343
  baseURL: toOpenCodeGatewayBaseUrl(procEnv.AI_GATEWAY_BASE_URL),
335
344
  ...(HARNESS_CLIENT_APP
336
- ? { headers: { 'x-client-app': HARNESS_CLIENT_APP } }
337
- : {}),
345
+ ? {
346
+ headers: {
347
+ ...start.headers,
348
+ 'x-client-app': HARNESS_CLIENT_APP,
349
+ },
350
+ }
351
+ : start.headers
352
+ ? { headers: start.headers }
353
+ : {}),
338
354
  },
339
355
  ...(modelID
340
356
  ? {
@@ -360,6 +376,7 @@ function buildProviderConfig(
360
376
  ...(procEnv.OPENAI_BASE_URL
361
377
  ? { baseURL: procEnv.OPENAI_BASE_URL }
362
378
  : {}),
379
+ ...(start.headers ? { headers: start.headers } : {}),
363
380
  ...parseOpenAIQueryParams(),
364
381
  },
365
382
  ...(modelID
@@ -391,6 +408,7 @@ function buildProviderConfig(
391
408
  ...(procEnv.ANTHROPIC_BASE_URL
392
409
  ? { baseURL: procEnv.ANTHROPIC_BASE_URL }
393
410
  : {}),
411
+ ...(start.headers ? { headers: start.headers } : {}),
394
412
  },
395
413
  },
396
414
  };
@@ -413,6 +431,7 @@ function buildProviderConfig(
413
431
  ...(procEnv.OPENAI_PROJECT
414
432
  ? { project: procEnv.OPENAI_PROJECT }
415
433
  : {}),
434
+ ...(start.headers ? { headers: start.headers } : {}),
416
435
  ...parseOpenAIQueryParams(),
417
436
  },
418
437
  },
@@ -1035,7 +1054,14 @@ async function consumeEvents({
1035
1054
  : undefined;
1036
1055
  if (!scopedSessionId) continue;
1037
1056
  const isDescendant = scopedSessionId !== sessionId;
1038
- if (event.type === 'permission.v2.asked') {
1057
+ if (event.type === 'question.asked') {
1058
+ await handleQuestion({
1059
+ client,
1060
+ turn,
1061
+ emit,
1062
+ event,
1063
+ });
1064
+ } else if (event.type === 'permission.v2.asked') {
1039
1065
  await handlePermissionV2({
1040
1066
  client,
1041
1067
  sessionId: scopedSessionId,
@@ -1075,6 +1101,79 @@ function getSubagentStepId(event: OpenCodeEvent | undefined) {
1075
1101
  return stringValue(event.properties?.stepID) ?? event.id;
1076
1102
  }
1077
1103
 
1104
+ async function handleQuestion({
1105
+ client,
1106
+ turn,
1107
+ emit,
1108
+ event,
1109
+ }: {
1110
+ client: OpenCodeClient;
1111
+ turn: BridgeTurn;
1112
+ emit: Emit;
1113
+ event: OpenCodeEvent;
1114
+ }): Promise<void> {
1115
+ const nativeRequest = event.properties as OpenCodeQuestionRequest | undefined;
1116
+ if (
1117
+ nativeRequest == null ||
1118
+ typeof nativeRequest.id !== 'string' ||
1119
+ typeof nativeRequest.sessionID !== 'string' ||
1120
+ !Array.isArray(nativeRequest.questions)
1121
+ ) {
1122
+ return;
1123
+ }
1124
+ const toolCallId = nativeRequest.tool?.callID ?? nativeRequest.id;
1125
+
1126
+ emit({
1127
+ type: 'tool-call',
1128
+ toolCallId,
1129
+ toolName: 'askUserQuestions',
1130
+ nativeName: 'question',
1131
+ input: JSON.stringify(toHarnessQuestionsInput(nativeRequest)),
1132
+ providerExecuted: false,
1133
+ providerMetadata: {
1134
+ opencode: {
1135
+ nativeRequest,
1136
+ },
1137
+ },
1138
+ });
1139
+
1140
+ const questionKey = openCodeQuestionKey(nativeRequest);
1141
+ const result = await turn.requestToolResult({
1142
+ toolCallId,
1143
+ matches: candidate => {
1144
+ const continuedRequest = candidate.toolResult?.providerOptions?.opencode
1145
+ ?.nativeRequest as OpenCodeQuestionRequest | undefined;
1146
+ return (
1147
+ continuedRequest != null &&
1148
+ openCodeQuestionKey(continuedRequest) === questionKey
1149
+ );
1150
+ },
1151
+ });
1152
+ const nativeResponse = toOpenCodeQuestionResponse({
1153
+ nativeRequest,
1154
+ output: result.output as Parameters<
1155
+ typeof toOpenCodeQuestionResponse
1156
+ >[0]['output'],
1157
+ });
1158
+
1159
+ const response =
1160
+ nativeResponse.action === 'reject'
1161
+ ? await client.question.reject({
1162
+ requestID: nativeRequest.id,
1163
+ directory: workdir,
1164
+ })
1165
+ : await client.question.reply({
1166
+ requestID: nativeRequest.id,
1167
+ directory: workdir,
1168
+ answers: nativeResponse.answers,
1169
+ });
1170
+ if (response.error != null) {
1171
+ throw new Error(
1172
+ `OpenCode question response failed: ${formatError(response.error)}`,
1173
+ );
1174
+ }
1175
+ }
1176
+
1078
1177
  function sanitizeMcpToolName(value: string): string {
1079
1178
  return value.replace(/[^a-zA-Z0-9_-]/g, '_');
1080
1179
  }
@@ -0,0 +1,90 @@
1
+ import type {
2
+ HarnessV1QuestionsToolInput,
3
+ HarnessV1QuestionsToolOutput,
4
+ } from '@ai-sdk/harness';
5
+ import type { QuestionInfo } from '@opencode-ai/sdk/v2';
6
+
7
+ export type OpenCodeQuestionRequest = {
8
+ id: string;
9
+ sessionID: string;
10
+ questions: QuestionInfo[];
11
+ tool?: { messageID: string; callID: string };
12
+ };
13
+
14
+ export function openCodeQuestionKey(
15
+ nativeRequest: OpenCodeQuestionRequest,
16
+ ): string {
17
+ return JSON.stringify(nativeRequest.questions);
18
+ }
19
+
20
+ export function toHarnessQuestionsInput(
21
+ nativeRequest: OpenCodeQuestionRequest,
22
+ ): HarnessV1QuestionsToolInput {
23
+ return {
24
+ allowPartialAnswers: true,
25
+ questions: nativeRequest.questions.map((question, questionIndex) => ({
26
+ id: `question-${questionIndex + 1}`,
27
+ question: question.question,
28
+ header: question.header,
29
+ options: question.options.map((option, optionIndex) => ({
30
+ id: `option-${optionIndex + 1}`,
31
+ label: option.label,
32
+ description: option.description,
33
+ })),
34
+ ...(question.multiple !== undefined
35
+ ? { allowMultiple: question.multiple }
36
+ : {}),
37
+ ...(question.custom !== undefined
38
+ ? { allowFreeForm: question.custom }
39
+ : {}),
40
+ })),
41
+ };
42
+ }
43
+
44
+ export function toOpenCodeQuestionResponse(input: {
45
+ nativeRequest: OpenCodeQuestionRequest;
46
+ output: HarnessV1QuestionsToolOutput;
47
+ }): { action: 'reply'; answers: string[][] } | { action: 'reject' } {
48
+ if (
49
+ input.output.action === 'declined' ||
50
+ input.output.action === 'cancelled'
51
+ ) {
52
+ return { action: 'reject' };
53
+ }
54
+
55
+ const answers = input.nativeRequest.questions.map(
56
+ (nativeQuestion, questionIndex) => {
57
+ const answer =
58
+ input.output.action === 'answered' ||
59
+ input.output.action === 'partially-answered'
60
+ ? input.output.answers[`question-${questionIndex + 1}`]
61
+ : undefined;
62
+ if (answer == null) return [];
63
+ const selectedLabels = answer.optionIds.flatMap(optionId => {
64
+ const index = positionalIdIndex({
65
+ id: optionId,
66
+ prefix: 'option-',
67
+ });
68
+ const option =
69
+ index == null ? undefined : nativeQuestion.options[index];
70
+ return option == null ? [] : [option.label];
71
+ });
72
+ return answer.freeform === undefined
73
+ ? selectedLabels
74
+ : [...selectedLabels, answer.freeform];
75
+ },
76
+ );
77
+
78
+ return { action: 'reply', answers };
79
+ }
80
+
81
+ function positionalIdIndex(input: {
82
+ id: string;
83
+ prefix: string;
84
+ }): number | undefined {
85
+ if (!input.id.startsWith(input.prefix)) return undefined;
86
+ const oneBasedIndex = Number(input.id.slice(input.prefix.length));
87
+ return Number.isInteger(oneBasedIndex) && oneBasedIndex > 0
88
+ ? oneBasedIndex - 1
89
+ : undefined;
90
+ }
@@ -18,6 +18,7 @@ export const startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
18
18
  resumeSessionId: z.string().optional(),
19
19
  openCodeConfig: z.record(z.string(), z.unknown()).optional(),
20
20
  mcpServers: z.record(z.string(), z.unknown()).optional(),
21
+ headers: z.record(z.string(), z.string()).optional(),
21
22
  });
22
23
 
23
24
  export type StartMessage = z.infer<typeof startMessageSchema>;
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import {
3
3
  commonTool,
4
+ HARNESS_V1_BUILTIN_TOOLS,
4
5
  HarnessCapabilityUnsupportedError,
5
6
  harnessV1DiagnosticFromBridgeFrame,
6
7
  type HarnessV1,
@@ -13,6 +14,7 @@ import {
13
14
  type HarnessV1PermissionMode,
14
15
  type HarnessV1Prompt,
15
16
  type HarnessV1PromptControl,
17
+ type HarnessV1ResponseFormat,
16
18
  type HarnessV1PortEndpoint,
17
19
  type HarnessV1ResumeSessionState,
18
20
  type HarnessV1Session,
@@ -38,7 +40,7 @@ import {
38
40
  warnCredentialBrokeringUnavailable,
39
41
  waitForBridgeReady,
40
42
  withBridgeToken,
41
- writeSkills as writeHarnessSkills,
43
+ writeSkills,
42
44
  type WriteSkillsResult,
43
45
  } from '@ai-sdk/harness/utils';
44
46
  import {
@@ -123,6 +125,11 @@ export type OpenCodeHarnessSettings = {
123
125
  const optionalStringRecord = z.record(z.string(), z.unknown()).optional();
124
126
 
125
127
  const OPENCODE_BUILTIN_TOOLS = {
128
+ askUserQuestions: {
129
+ ...HARNESS_V1_BUILTIN_TOOLS.askUserQuestions,
130
+ nativeName: 'question',
131
+ toolUseKind: 'readonly',
132
+ },
126
133
  read: commonTool('read', {
127
134
  nativeName: 'view',
128
135
  toolUseKind: 'readonly',
@@ -406,6 +413,7 @@ export function createOpenCode(
406
413
  reasoningVariant: settings.reasoningVariant,
407
414
  openCodeConfig: settings.openCodeConfig,
408
415
  mcpServers: settings.mcpServers,
416
+ headers: startOpts.headers,
409
417
  openCodeSessionId: resumeSessionId,
410
418
  isResume: true,
411
419
  seedResumeSessionOnFirstPrompt: false,
@@ -563,6 +571,7 @@ export function createOpenCode(
563
571
  reasoningVariant: settings.reasoningVariant,
564
572
  openCodeConfig: settings.openCodeConfig,
565
573
  mcpServers: settings.mcpServers,
574
+ headers: startOpts.headers,
566
575
  openCodeSessionId: resumeSessionId,
567
576
  isResume: respawnStrategy !== undefined,
568
577
  seedResumeSessionOnFirstPrompt: respawnStrategy !== undefined,
@@ -647,30 +656,6 @@ async function resolveBridgeEndpoint({
647
656
  });
648
657
  }
649
658
 
650
- async function writeOpenCodeSkills({
651
- sandbox,
652
- skills,
653
- homeDir,
654
- abortSignal,
655
- }: {
656
- sandbox: SandboxSession;
657
- skills: ReadonlyArray<HarnessV1Skill>;
658
- homeDir: string;
659
- abortSignal?: AbortSignal;
660
- }): Promise<WriteSkillsResult> {
661
- const skillsDir = path.posix.join(homeDir, '.agents', 'skills');
662
- return writeHarnessSkills({
663
- sandbox,
664
- rootDir: skillsDir,
665
- skills,
666
- abortSignal,
667
- invalidSkillNameMessage: ({ name }) =>
668
- `Invalid OpenCode skill name: ${name}`,
669
- invalidSkillFilePathMessage: ({ skillName, filePath }) =>
670
- `Invalid OpenCode skill file path for ${skillName}: ${filePath}`,
671
- });
672
- }
673
-
674
659
  function openWebSocket({
675
660
  endpoint,
676
661
  helloTimeoutMs,
@@ -780,6 +765,7 @@ function createSession({
780
765
  reasoningVariant,
781
766
  openCodeConfig,
782
767
  mcpServers,
768
+ headers,
783
769
  openCodeSessionId,
784
770
  isResume,
785
771
  seedResumeSessionOnFirstPrompt,
@@ -803,6 +789,7 @@ function createSession({
803
789
  reasoningVariant: string | undefined;
804
790
  openCodeConfig: Record<string, unknown> | undefined;
805
791
  mcpServers: Record<string, unknown> | undefined;
792
+ headers: Readonly<Record<string, string>> | undefined;
806
793
  openCodeSessionId: string | undefined;
807
794
  isResume: boolean;
808
795
  seedResumeSessionOnFirstPrompt: boolean;
@@ -954,6 +941,9 @@ function createSession({
954
941
  toolCallId: input.toolCallId,
955
942
  output: input.output,
956
943
  isError: input.isError,
944
+ ...(input.toolResult !== undefined
945
+ ? { toolResult: input.toolResult }
946
+ : {}),
957
947
  });
958
948
  },
959
949
  submitToolApproval: async input => {
@@ -981,6 +971,7 @@ function createSession({
981
971
  ...(reasoningVariant ? { variant: reasoningVariant } : {}),
982
972
  ...(openCodeConfig == null ? {} : { openCodeConfig }),
983
973
  ...(mcpServers == null ? {} : { mcpServers }),
974
+ ...(headers == null ? {} : { headers }),
984
975
  ...(permissionMode ? { permissionMode } : {}),
985
976
  ...(builtinToolFiltering ? { builtinToolFiltering } : {}),
986
977
  ...(pendingResumeSessionId
@@ -991,30 +982,48 @@ function createSession({
991
982
  ...(debug ? { debug } : {}),
992
983
  });
993
984
 
985
+ const prepareTurn = async (opts: {
986
+ responseFormat?: HarnessV1ResponseFormat;
987
+ skills: ReadonlyArray<HarnessV1Skill>;
988
+ emit: (event: HarnessV1StreamPart) => void;
989
+ abortSignal?: AbortSignal;
990
+ }): Promise<{
991
+ control: HarnessV1PromptControl;
992
+ skillWriteResult: WriteSkillsResult;
993
+ }> => {
994
+ if (
995
+ opts.responseFormat?.type === 'json' &&
996
+ opts.responseFormat.schema == null
997
+ ) {
998
+ throw new HarnessCapabilityUnsupportedError({
999
+ message:
1000
+ "Harness 'opencode' requires a JSON schema for structured output.",
1001
+ harnessId: 'opencode',
1002
+ });
1003
+ }
1004
+ const skillWriteResult = await writeSkills({
1005
+ sandbox,
1006
+ homePath: sandboxHomeDir,
1007
+ skillsDir: '.agents/skills',
1008
+ skills: opts.skills,
1009
+ abortSignal: opts.abortSignal,
1010
+ invalidSkillNameMessage: ({ name }) =>
1011
+ `Invalid OpenCode skill name: ${name}`,
1012
+ invalidSkillFilePathMessage: ({ skillName, filePath }) =>
1013
+ `Invalid OpenCode skill file path for ${skillName}: ${filePath}`,
1014
+ });
1015
+ const control = wireTurn({
1016
+ emit: opts.emit,
1017
+ abortSignal: opts.abortSignal,
1018
+ });
1019
+ return { control, skillWriteResult };
1020
+ };
1021
+
994
1022
  return {
995
1023
  sessionId,
996
1024
  isResume,
997
1025
  doPromptTurn: async promptOpts => {
998
- if (
999
- promptOpts.responseFormat?.type === 'json' &&
1000
- promptOpts.responseFormat.schema == null
1001
- ) {
1002
- throw new HarnessCapabilityUnsupportedError({
1003
- message:
1004
- "Harness 'opencode' requires a JSON schema for structured output.",
1005
- harnessId: 'opencode',
1006
- });
1007
- }
1008
- const skillWriteResult = await writeOpenCodeSkills({
1009
- sandbox,
1010
- skills: promptOpts.skills,
1011
- homeDir: sandboxHomeDir,
1012
- abortSignal: promptOpts.abortSignal,
1013
- });
1014
- const control = wireTurn({
1015
- emit: promptOpts.emit,
1016
- abortSignal: promptOpts.abortSignal,
1017
- });
1026
+ const { control, skillWriteResult } = await prepareTurn(promptOpts);
1018
1027
  const turnModel = promptOpts.model ?? selectedModel;
1019
1028
  if (turnModel) selectedModel = turnModel;
1020
1029
  channel.send({
@@ -1039,26 +1048,7 @@ function createSession({
1039
1048
  return control;
1040
1049
  },
1041
1050
  doContinueTurn: async continueOpts => {
1042
- if (
1043
- continueOpts.responseFormat?.type === 'json' &&
1044
- continueOpts.responseFormat.schema == null
1045
- ) {
1046
- throw new HarnessCapabilityUnsupportedError({
1047
- message:
1048
- "Harness 'opencode' requires a JSON schema for structured output.",
1049
- harnessId: 'opencode',
1050
- });
1051
- }
1052
- const skillWriteResult = await writeOpenCodeSkills({
1053
- sandbox,
1054
- skills: continueOpts.skills,
1055
- homeDir: sandboxHomeDir,
1056
- abortSignal: continueOpts.abortSignal,
1057
- });
1058
- const control = wireTurn({
1059
- emit: continueOpts.emit,
1060
- abortSignal: continueOpts.abortSignal,
1061
- });
1051
+ const { control, skillWriteResult } = await prepareTurn(continueOpts);
1062
1052
  if (rerunContinue) {
1063
1053
  const turnModel = continueOpts.model ?? selectedModel;
1064
1054
  if (turnModel) selectedModel = turnModel;
@@ -1107,6 +1097,7 @@ function createSession({
1107
1097
  debug,
1108
1098
  openCodeConfig,
1109
1099
  mcpServers,
1100
+ headers,
1110
1101
  resumeSessionId: latestOpenCodeSessionId,
1111
1102
  onCompaction: part => pendingCompactionParts.push(part),
1112
1103
  });
@@ -1280,6 +1271,7 @@ async function runCompactOperation({
1280
1271
  debug,
1281
1272
  openCodeConfig,
1282
1273
  mcpServers,
1274
+ headers,
1283
1275
  resumeSessionId,
1284
1276
  onCompaction,
1285
1277
  }: {
@@ -1290,6 +1282,7 @@ async function runCompactOperation({
1290
1282
  debug: HarnessV1DebugConfig | undefined;
1291
1283
  openCodeConfig: Record<string, unknown> | undefined;
1292
1284
  mcpServers: Record<string, unknown> | undefined;
1285
+ headers: Readonly<Record<string, string>> | undefined;
1293
1286
  resumeSessionId: string | undefined;
1294
1287
  onCompaction: (part: HarnessV1StreamPart) => void;
1295
1288
  }): Promise<void> {
@@ -1319,6 +1312,7 @@ async function runCompactOperation({
1319
1312
  provider,
1320
1313
  ...(openCodeConfig == null ? {} : { openCodeConfig }),
1321
1314
  ...(mcpServers == null ? {} : { mcpServers }),
1315
+ ...(headers == null ? {} : { headers }),
1322
1316
  ...(permissionMode ? { permissionMode } : {}),
1323
1317
  ...(resumeSessionId ? { resumeSessionId } : {}),
1324
1318
  ...(debug ? { debug } : {}),