opencode-gitlab-duo-agentic 0.2.4 → 0.2.5

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.
Files changed (2) hide show
  1. package/dist/index.js +429 -167
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -400,7 +400,9 @@ import { randomUUID as randomUUID2 } from "crypto";
400
400
  var AsyncQueue = class {
401
401
  #values = [];
402
402
  #waiters = [];
403
+ #closed = false;
403
404
  push(value) {
405
+ if (this.#closed) return;
404
406
  const waiter = this.#waiters.shift();
405
407
  if (waiter) {
406
408
  waiter(value);
@@ -408,11 +410,20 @@ var AsyncQueue = class {
408
410
  }
409
411
  this.#values.push(value);
410
412
  }
413
+ /** Returns null when closed and no buffered values remain. */
411
414
  shift() {
412
415
  const value = this.#values.shift();
413
416
  if (value !== void 0) return Promise.resolve(value);
417
+ if (this.#closed) return Promise.resolve(null);
414
418
  return new Promise((resolve2) => this.#waiters.push(resolve2));
415
419
  }
420
+ close() {
421
+ this.#closed = true;
422
+ for (const waiter of this.#waiters) {
423
+ waiter(null);
424
+ }
425
+ this.#waiters = [];
426
+ }
416
427
  };
417
428
 
418
429
  // src/workflow/checkpoint.ts
@@ -1021,8 +1032,9 @@ var WorkflowSession = class {
1021
1032
  #checkpoint = createCheckpointState();
1022
1033
  #toolsConfig;
1023
1034
  #toolExecutor;
1024
- /** Mutex: serialises concurrent calls to runTurn so only one runs at a time. */
1025
- #turnLock = Promise.resolve();
1035
+ #socket;
1036
+ #queue;
1037
+ #startRequestSent = false;
1026
1038
  constructor(client, modelId, cwd) {
1027
1039
  this.#client = client;
1028
1040
  this.#tokenService = new WorkflowTokenService(client);
@@ -1032,7 +1044,6 @@ var WorkflowSession = class {
1032
1044
  }
1033
1045
  /**
1034
1046
  * Opt-in: override the server-side system prompt and/or register MCP tools.
1035
- * When not called, the server uses its default prompt and built-in tools.
1036
1047
  */
1037
1048
  setToolsConfig(config) {
1038
1049
  this.#toolsConfig = config;
@@ -1040,126 +1051,153 @@ var WorkflowSession = class {
1040
1051
  get workflowId() {
1041
1052
  return this.#workflowId;
1042
1053
  }
1054
+ get hasStarted() {
1055
+ return this.#startRequestSent;
1056
+ }
1043
1057
  reset() {
1044
1058
  this.#workflowId = void 0;
1045
1059
  this.#checkpoint = createCheckpointState();
1046
1060
  this.#tokenService.clear();
1047
- }
1048
- async *runTurn(goal, abortSignal) {
1049
- await this.#turnLock;
1050
- let resolve2;
1051
- this.#turnLock = new Promise((r) => {
1052
- resolve2 = r;
1053
- });
1054
- const queue = new AsyncQueue();
1061
+ this.#closeConnection();
1062
+ this.#startRequestSent = false;
1063
+ }
1064
+ // ---------------------------------------------------------------------------
1065
+ // Connection lifecycle (persistent)
1066
+ // ---------------------------------------------------------------------------
1067
+ async ensureConnected(goal) {
1068
+ if (this.#socket && this.#queue) return;
1069
+ if (!this.#workflowId) {
1070
+ this.#workflowId = await this.#createWorkflow(goal);
1071
+ }
1072
+ await this.#tokenService.get(this.#rootNamespaceId);
1073
+ this.#queue = new AsyncQueue();
1074
+ const queue = this.#queue;
1055
1075
  const socket = new WorkflowWebSocketClient({
1056
- action: (action) => queue.push({ type: "action", action }),
1057
- error: (error) => queue.push({ type: "error", error }),
1058
- close: (code, reason) => queue.push({ type: "close", code, reason })
1076
+ action: (action) => this.#handleAction(action, queue),
1077
+ error: (error) => queue.push({ type: "error", message: error.message }),
1078
+ close: (_code, _reason) => queue.close()
1059
1079
  });
1060
- const onAbort = () => {
1061
- socket.send({
1062
- stopWorkflow: {
1063
- reason: "ABORTED"
1064
- }
1065
- });
1066
- socket.close();
1067
- };
1068
- try {
1069
- if (abortSignal?.aborted) throw new Error("aborted");
1070
- if (!this.#workflowId) this.#workflowId = await this.#createWorkflow(goal);
1071
- const access = await this.#tokenService.get(this.#rootNamespaceId);
1072
- const url = buildWebSocketUrl(this.#client.instanceUrl, this.#modelId);
1073
- await socket.connect(url, {
1074
- authorization: `Bearer ${this.#client.token}`,
1075
- origin: new URL(this.#client.instanceUrl).origin,
1076
- "x-request-id": randomUUID2(),
1077
- "x-gitlab-client-type": "node-websocket"
1078
- });
1079
- abortSignal?.addEventListener("abort", onAbort, { once: true });
1080
- const mcpTools = this.#toolsConfig?.mcpTools ?? [];
1081
- const preapprovedTools = mcpTools.map((t) => t.name);
1082
- const sent = socket.send({
1083
- startRequest: {
1084
- workflowID: this.#workflowId,
1085
- clientVersion: WORKFLOW_CLIENT_VERSION,
1086
- workflowDefinition: WORKFLOW_DEFINITION,
1087
- goal,
1088
- workflowMetadata: JSON.stringify({
1089
- extended_logging: access?.workflow_metadata?.extended_logging ?? false
1090
- }),
1091
- clientCapabilities: ["shell_command"],
1092
- mcpTools,
1093
- additional_context: [],
1094
- preapproved_tools: preapprovedTools,
1095
- // Only include flowConfig when explicitly configured (opt-in prompt override)
1096
- ...this.#toolsConfig?.flowConfig ? {
1097
- flowConfig: this.#toolsConfig.flowConfig,
1098
- flowConfigSchemaVersion: this.#toolsConfig.flowConfigSchemaVersion ?? "v1"
1099
- } : {}
1100
- }
1101
- });
1102
- if (!sent) throw new Error("failed to send workflow startRequest");
1103
- for (; ; ) {
1104
- const event = await queue.shift();
1105
- if (event.type === "error") throw event.error;
1106
- if (event.type === "close") {
1107
- if (event.code === 1e3 || event.code === 1006) return;
1108
- throw new Error(`workflow websocket closed abnormally (${event.code}): ${event.reason}`);
1109
- }
1110
- if (isCheckpointAction(event.action)) {
1111
- const ckpt = event.action.newCheckpoint.checkpoint;
1112
- const deltas = extractAgentTextDeltas(ckpt, this.#checkpoint);
1113
- for (const delta of deltas) {
1114
- yield {
1115
- type: "text-delta",
1116
- value: delta
1117
- };
1118
- }
1119
- const toolRequests = extractToolRequests(ckpt, this.#checkpoint);
1120
- for (const req of toolRequests) {
1121
- console.error(`[duo-workflow] checkpoint tool request: ${req.toolName} requestId=${req.requestId} args=${JSON.stringify(req.args).slice(0, 200)}`);
1122
- const result2 = await this.#toolExecutor.executeDuoTool(req.toolName, req.args);
1123
- console.error(`[duo-workflow] checkpoint tool result: ${result2.response.length} bytes, error=${result2.error ?? "none"}`);
1124
- socket.send({
1125
- actionResponse: {
1126
- requestID: req.requestId,
1127
- plainTextResponse: {
1128
- response: result2.response,
1129
- error: result2.error ?? ""
1130
- }
1131
- }
1132
- });
1133
- }
1134
- if (isTurnComplete(event.action.newCheckpoint.status)) {
1135
- socket.close();
1136
- }
1137
- continue;
1138
- }
1139
- const actionKeys = Object.keys(event.action).filter((k) => k !== "requestID");
1140
- console.error(`[duo-workflow] tool action received: keys=${JSON.stringify(actionKeys)} requestID=${event.action.requestID ?? "MISSING"}`);
1141
- if (!event.action.requestID) {
1142
- console.error("[duo-workflow] skipping action without requestID");
1143
- continue;
1080
+ const url = buildWebSocketUrl(this.#client.instanceUrl, this.#modelId);
1081
+ await socket.connect(url, {
1082
+ authorization: `Bearer ${this.#client.token}`,
1083
+ origin: new URL(this.#client.instanceUrl).origin,
1084
+ "x-request-id": randomUUID2(),
1085
+ "x-gitlab-client-type": "node-websocket"
1086
+ });
1087
+ this.#socket = socket;
1088
+ }
1089
+ // ---------------------------------------------------------------------------
1090
+ // Messaging
1091
+ // ---------------------------------------------------------------------------
1092
+ sendStartRequest(goal) {
1093
+ if (!this.#socket || !this.#workflowId) throw new Error("Not connected");
1094
+ const mcpTools = this.#toolsConfig?.mcpTools ?? [];
1095
+ const preapprovedTools = mcpTools.map((t) => t.name);
1096
+ this.#socket.send({
1097
+ startRequest: {
1098
+ workflowID: this.#workflowId,
1099
+ clientVersion: WORKFLOW_CLIENT_VERSION,
1100
+ workflowDefinition: WORKFLOW_DEFINITION,
1101
+ goal,
1102
+ workflowMetadata: JSON.stringify({
1103
+ extended_logging: false
1104
+ }),
1105
+ clientCapabilities: ["shell_command"],
1106
+ mcpTools,
1107
+ additional_context: [],
1108
+ preapproved_tools: preapprovedTools,
1109
+ ...this.#toolsConfig?.flowConfig ? {
1110
+ flowConfig: this.#toolsConfig.flowConfig,
1111
+ flowConfigSchemaVersion: this.#toolsConfig.flowConfigSchemaVersion ?? "v1"
1112
+ } : {}
1113
+ }
1114
+ });
1115
+ this.#startRequestSent = true;
1116
+ }
1117
+ /**
1118
+ * Send a tool result back to DWS on the existing connection.
1119
+ */
1120
+ sendToolResult(requestId, output, error) {
1121
+ if (!this.#socket) throw new Error("Not connected");
1122
+ console.error(`[duo-workflow] sendToolResult: requestId=${requestId} output=${output.length} bytes, error=${error ?? "none"}`);
1123
+ this.#socket.send({
1124
+ actionResponse: {
1125
+ requestID: requestId,
1126
+ plainTextResponse: {
1127
+ response: output,
1128
+ error: error ?? ""
1144
1129
  }
1145
- const result = await this.#toolExecutor.executeAction(event.action);
1146
- console.error(`[duo-workflow] tool result: response=${result.response.length} bytes, error=${result.error ?? "none"}`);
1147
- socket.send({
1148
- actionResponse: {
1149
- requestID: event.action.requestID,
1150
- plainTextResponse: {
1151
- response: result.response,
1152
- error: result.error ?? ""
1153
- }
1154
- }
1130
+ }
1131
+ });
1132
+ }
1133
+ /**
1134
+ * Wait for the next event from the session.
1135
+ * Returns null when the stream is closed (turn complete or connection lost).
1136
+ */
1137
+ async waitForEvent() {
1138
+ if (!this.#queue) return null;
1139
+ return this.#queue.shift();
1140
+ }
1141
+ /**
1142
+ * Send an abort signal to DWS and close the connection.
1143
+ */
1144
+ abort() {
1145
+ this.#socket?.send({ stopWorkflow: { reason: "ABORTED" } });
1146
+ this.#closeConnection();
1147
+ }
1148
+ // ---------------------------------------------------------------------------
1149
+ // Private: action handling
1150
+ // ---------------------------------------------------------------------------
1151
+ #handleAction(action, queue) {
1152
+ if (isCheckpointAction(action)) {
1153
+ const ckpt = action.newCheckpoint.checkpoint;
1154
+ const deltas = extractAgentTextDeltas(ckpt, this.#checkpoint);
1155
+ for (const delta of deltas) {
1156
+ queue.push({ type: "text-delta", value: delta });
1157
+ }
1158
+ const toolRequests = extractToolRequests(ckpt, this.#checkpoint);
1159
+ for (const req of toolRequests) {
1160
+ console.error(`[duo-workflow] checkpoint tool request: ${req.toolName} requestId=${req.requestId}`);
1161
+ queue.push({
1162
+ type: "tool-request",
1163
+ requestId: req.requestId,
1164
+ toolName: req.toolName,
1165
+ args: req.args
1155
1166
  });
1156
1167
  }
1157
- } finally {
1158
- abortSignal?.removeEventListener("abort", onAbort);
1159
- socket.close();
1160
- resolve2();
1168
+ if (isTurnComplete(action.newCheckpoint.status)) {
1169
+ queue.close();
1170
+ this.#closeConnection();
1171
+ }
1172
+ return;
1173
+ }
1174
+ if (action.requestID) {
1175
+ console.error(`[duo-workflow] ws tool action: keys=${JSON.stringify(Object.keys(action).filter((k) => k !== "requestID"))}`);
1176
+ this.#executeStandaloneAction(action);
1161
1177
  }
1162
1178
  }
1179
+ async #executeStandaloneAction(action) {
1180
+ if (!action.requestID || !this.#socket) return;
1181
+ const result = await this.#toolExecutor.executeAction(action);
1182
+ this.#socket.send({
1183
+ actionResponse: {
1184
+ requestID: action.requestID,
1185
+ plainTextResponse: {
1186
+ response: result.response,
1187
+ error: result.error ?? ""
1188
+ }
1189
+ }
1190
+ });
1191
+ }
1192
+ // ---------------------------------------------------------------------------
1193
+ // Private: connection management
1194
+ // ---------------------------------------------------------------------------
1195
+ #closeConnection() {
1196
+ this.#socket?.close();
1197
+ this.#socket = void 0;
1198
+ this.#queue = void 0;
1199
+ this.#startRequestSent = false;
1200
+ }
1163
1201
  async #createWorkflow(goal) {
1164
1202
  await this.#loadProjectContext();
1165
1203
  const body = {
@@ -1167,9 +1205,7 @@ var WorkflowSession = class {
1167
1205
  workflow_definition: WORKFLOW_DEFINITION,
1168
1206
  environment: WORKFLOW_ENVIRONMENT,
1169
1207
  allow_agent_to_request_user: true,
1170
- ...this.#projectPath ? {
1171
- project_id: this.#projectPath
1172
- } : {}
1208
+ ...this.#projectPath ? { project_id: this.#projectPath } : {}
1173
1209
  };
1174
1210
  const created = await post(this.#client, "ai/duo_workflows/workflows", body);
1175
1211
  if (created.id === void 0 || created.id === null) {
@@ -1220,6 +1256,151 @@ function stripSystemReminders(value) {
1220
1256
  }).trim();
1221
1257
  }
1222
1258
 
1259
+ // src/provider/prompt-utils.ts
1260
+ function extractToolResults(prompt) {
1261
+ if (!Array.isArray(prompt)) return [];
1262
+ const results = [];
1263
+ for (const message of prompt) {
1264
+ const content = message.content;
1265
+ if (!Array.isArray(content)) continue;
1266
+ for (const part of content) {
1267
+ const p = part;
1268
+ if (p.type !== "tool-result") continue;
1269
+ const toolCallId = String(p.toolCallId ?? "");
1270
+ const toolName = String(p.toolName ?? "");
1271
+ if (!toolCallId) continue;
1272
+ const { output, error } = parseToolResultOutput(p);
1273
+ results.push({ toolCallId, toolName, output, error });
1274
+ }
1275
+ }
1276
+ return results;
1277
+ }
1278
+ function parseToolResultOutput(part) {
1279
+ const outputField = part.output;
1280
+ const resultField = part.result;
1281
+ if (isPlainObject(outputField) && "type" in outputField) {
1282
+ const outputType = String(outputField.type);
1283
+ const outputValue = outputField.value;
1284
+ if (outputType === "text" || outputType === "json") {
1285
+ return { output: typeof outputValue === "string" ? outputValue : JSON.stringify(outputValue ?? "") };
1286
+ }
1287
+ if (outputType === "error-text" || outputType === "error-json") {
1288
+ return { output: "", error: typeof outputValue === "string" ? outputValue : JSON.stringify(outputValue ?? "") };
1289
+ }
1290
+ if (outputType === "content" && Array.isArray(outputValue)) {
1291
+ const text2 = outputValue.filter((v) => v.type === "text").map((v) => String(v.text ?? "")).join("\n");
1292
+ return { output: text2 };
1293
+ }
1294
+ }
1295
+ if (outputField !== void 0) {
1296
+ return { output: typeof outputField === "string" ? outputField : JSON.stringify(outputField) };
1297
+ }
1298
+ if (resultField !== void 0) {
1299
+ return { output: typeof resultField === "string" ? resultField : JSON.stringify(resultField) };
1300
+ }
1301
+ return { output: "" };
1302
+ }
1303
+ function isPlainObject(value) {
1304
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1305
+ }
1306
+
1307
+ // src/provider/tool-mapping.ts
1308
+ function mapDuoToolRequest(toolName, args) {
1309
+ switch (toolName) {
1310
+ case "list_dir": {
1311
+ const directory = asString2(args.directory) ?? ".";
1312
+ return {
1313
+ toolName: "bash",
1314
+ args: { command: `ls -la ${shellQuote(directory)}`, description: "List directory contents", workdir: "." }
1315
+ };
1316
+ }
1317
+ case "read_file": {
1318
+ const filePath = asString2(args.file_path) ?? asString2(args.filepath) ?? asString2(args.filePath) ?? asString2(args.path);
1319
+ if (!filePath) return { toolName, args };
1320
+ const mapped = { filePath };
1321
+ if (typeof args.offset === "number") mapped.offset = args.offset;
1322
+ if (typeof args.limit === "number") mapped.limit = args.limit;
1323
+ return { toolName: "read", args: mapped };
1324
+ }
1325
+ case "read_files": {
1326
+ const filePaths = asStringArray2(args.file_paths);
1327
+ if (filePaths.length === 0) return { toolName, args };
1328
+ return filePaths.map((fp) => ({ toolName: "read", args: { filePath: fp } }));
1329
+ }
1330
+ case "create_file_with_contents": {
1331
+ const filePath = asString2(args.file_path);
1332
+ const content = asString2(args.contents);
1333
+ if (!filePath || content === void 0) return { toolName, args };
1334
+ return { toolName: "write", args: { filePath, content } };
1335
+ }
1336
+ case "edit_file": {
1337
+ const filePath = asString2(args.file_path);
1338
+ const oldString = asString2(args.old_str);
1339
+ const newString = asString2(args.new_str);
1340
+ if (!filePath || oldString === void 0 || newString === void 0) return { toolName, args };
1341
+ return { toolName: "edit", args: { filePath, oldString, newString } };
1342
+ }
1343
+ case "find_files": {
1344
+ const pattern = asString2(args.name_pattern);
1345
+ if (!pattern) return { toolName, args };
1346
+ return { toolName: "glob", args: { pattern } };
1347
+ }
1348
+ case "grep": {
1349
+ const pattern = asString2(args.pattern);
1350
+ if (!pattern) return { toolName, args };
1351
+ const searchDir = asString2(args.search_directory);
1352
+ const caseInsensitive = Boolean(args.case_insensitive);
1353
+ const normalizedPattern = caseInsensitive && !pattern.startsWith("(?i)") ? `(?i)${pattern}` : pattern;
1354
+ const mapped = { pattern: normalizedPattern };
1355
+ if (searchDir) mapped.path = searchDir;
1356
+ return { toolName: "grep", args: mapped };
1357
+ }
1358
+ case "mkdir": {
1359
+ const directory = asString2(args.directory_path);
1360
+ if (!directory) return { toolName, args };
1361
+ return { toolName: "bash", args: { command: `mkdir -p ${shellQuote(directory)}`, description: "Create directory", workdir: "." } };
1362
+ }
1363
+ case "shell_command": {
1364
+ const command = asString2(args.command);
1365
+ if (!command) return { toolName, args };
1366
+ return { toolName: "bash", args: { command, description: "Run shell command", workdir: "." } };
1367
+ }
1368
+ case "run_command": {
1369
+ const program = asString2(args.program);
1370
+ if (program) {
1371
+ const parts = [shellQuote(program)];
1372
+ if (Array.isArray(args.flags)) parts.push(...args.flags.map((f) => shellQuote(String(f))));
1373
+ if (Array.isArray(args.arguments)) parts.push(...args.arguments.map((a) => shellQuote(String(a))));
1374
+ return { toolName: "bash", args: { command: parts.join(" "), description: "Run command", workdir: "." } };
1375
+ }
1376
+ const command = asString2(args.command);
1377
+ if (!command) return { toolName, args };
1378
+ return { toolName: "bash", args: { command, description: "Run command", workdir: "." } };
1379
+ }
1380
+ case "run_git_command": {
1381
+ const command = asString2(args.command);
1382
+ if (!command) return { toolName, args };
1383
+ const rawArgs = args.args;
1384
+ const extraArgs = Array.isArray(rawArgs) ? rawArgs.map((v) => shellQuote(String(v))).join(" ") : asString2(rawArgs);
1385
+ const gitCmd = extraArgs ? `git ${shellQuote(command)} ${extraArgs}` : `git ${shellQuote(command)}`;
1386
+ return { toolName: "bash", args: { command: gitCmd, description: "Run git command", workdir: "." } };
1387
+ }
1388
+ default:
1389
+ return { toolName, args };
1390
+ }
1391
+ }
1392
+ function asString2(value) {
1393
+ return typeof value === "string" ? value : void 0;
1394
+ }
1395
+ function asStringArray2(value) {
1396
+ if (!Array.isArray(value)) return [];
1397
+ return value.filter((v) => typeof v === "string");
1398
+ }
1399
+ function shellQuote(s) {
1400
+ if (/^[a-zA-Z0-9_\-./=:@]+$/.test(s)) return s;
1401
+ return `'${s.replace(/'/g, "'\\''")}'`;
1402
+ }
1403
+
1223
1404
  // src/provider/session-context.ts
1224
1405
  function readSessionID(options) {
1225
1406
  const providerBlock = readProviderBlock(options);
@@ -1255,6 +1436,12 @@ var DuoWorkflowModel = class {
1255
1436
  #client;
1256
1437
  #cwd;
1257
1438
  #toolsConfig;
1439
+ // Tool tracking state (per model instance, reset on session change)
1440
+ #pendingToolRequests = /* @__PURE__ */ new Map();
1441
+ #multiCallGroups = /* @__PURE__ */ new Map();
1442
+ #sentToolCallIds = /* @__PURE__ */ new Set();
1443
+ #lastSentGoal = null;
1444
+ #stateSessionId;
1258
1445
  constructor(modelId, client, cwd) {
1259
1446
  this.modelId = modelId;
1260
1447
  this.#client = client;
@@ -1262,8 +1449,6 @@ var DuoWorkflowModel = class {
1262
1449
  }
1263
1450
  /**
1264
1451
  * Opt-in: override the server-side system prompt and/or register MCP tools.
1265
- * When not called, the server uses its default prompt and built-in tools.
1266
- * Tool execution is always bridged locally regardless of this setting.
1267
1452
  */
1268
1453
  setToolsConfig(config) {
1269
1454
  this.#toolsConfig = config;
@@ -1272,22 +1457,13 @@ var DuoWorkflowModel = class {
1272
1457
  }
1273
1458
  }
1274
1459
  async doGenerate(options) {
1275
- const sessionID = readSessionID(options);
1276
- if (!sessionID) throw new Error("missing workflow session ID");
1277
- const goal = extractGoal(options.prompt);
1278
- if (!goal) throw new Error("missing user message content");
1279
- const session = this.#resolveSession(sessionID);
1280
- const chunks = [];
1281
- for await (const item of session.runTurn(goal, options.abortSignal)) {
1282
- if (item.type === "text-delta") chunks.push(item.value);
1460
+ let text2 = "";
1461
+ const { stream } = await this.doStream(options);
1462
+ for await (const part of stream) {
1463
+ if (part.type === "text-delta") text2 += part.delta;
1283
1464
  }
1284
1465
  return {
1285
- content: [
1286
- {
1287
- type: "text",
1288
- text: chunks.join("")
1289
- }
1290
- ],
1466
+ content: [{ type: "text", text: text2 }],
1291
1467
  finishReason: "stop",
1292
1468
  usage: UNKNOWN_USAGE,
1293
1469
  warnings: []
@@ -1297,57 +1473,143 @@ var DuoWorkflowModel = class {
1297
1473
  const sessionID = readSessionID(options);
1298
1474
  if (!sessionID) throw new Error("missing workflow session ID");
1299
1475
  const goal = extractGoal(options.prompt);
1300
- if (!goal) throw new Error("missing user message content");
1476
+ const toolResults = extractToolResults(options.prompt);
1301
1477
  const session = this.#resolveSession(sessionID);
1302
1478
  const textId = randomUUID3();
1479
+ if (sessionID !== this.#stateSessionId) {
1480
+ this.#pendingToolRequests.clear();
1481
+ this.#multiCallGroups.clear();
1482
+ this.#sentToolCallIds.clear();
1483
+ this.#lastSentGoal = null;
1484
+ this.#stateSessionId = sessionID;
1485
+ }
1486
+ const model = this;
1303
1487
  return {
1304
1488
  stream: new ReadableStream({
1305
1489
  start: async (controller) => {
1306
- controller.enqueue({
1307
- type: "stream-start",
1308
- warnings: []
1309
- });
1310
- let hasText = false;
1490
+ controller.enqueue({ type: "stream-start", warnings: [] });
1491
+ const onAbort = () => session.abort();
1492
+ options.abortSignal?.addEventListener("abort", onAbort, { once: true });
1311
1493
  try {
1312
- for await (const item of session.runTurn(goal, options.abortSignal)) {
1313
- if (item.type !== "text-delta") continue;
1314
- if (!item.value) continue;
1315
- if (!hasText) {
1316
- hasText = true;
1494
+ const freshResults = toolResults.filter(
1495
+ (r) => !model.#sentToolCallIds.has(r.toolCallId)
1496
+ );
1497
+ let sentToolResults = false;
1498
+ for (const result of freshResults) {
1499
+ const hashIdx = result.toolCallId.indexOf("#");
1500
+ if (hashIdx !== -1) {
1501
+ const originalId = result.toolCallId.substring(0, hashIdx);
1502
+ const group = model.#multiCallGroups.get(originalId);
1503
+ if (!group) {
1504
+ model.#sentToolCallIds.add(result.toolCallId);
1505
+ continue;
1506
+ }
1507
+ group.collected.set(result.toolCallId, result.error ?? result.output);
1508
+ model.#sentToolCallIds.add(result.toolCallId);
1509
+ model.#pendingToolRequests.delete(result.toolCallId);
1510
+ if (group.collected.size === group.subIds.length) {
1511
+ const aggregated = group.subIds.map((id) => group.collected.get(id) ?? "").join("\n");
1512
+ session.sendToolResult(originalId, aggregated);
1513
+ model.#multiCallGroups.delete(originalId);
1514
+ model.#pendingToolRequests.delete(originalId);
1515
+ sentToolResults = true;
1516
+ }
1517
+ continue;
1518
+ }
1519
+ const pending = model.#pendingToolRequests.get(result.toolCallId);
1520
+ if (!pending) {
1521
+ model.#sentToolCallIds.add(result.toolCallId);
1522
+ continue;
1523
+ }
1524
+ session.sendToolResult(result.toolCallId, result.output, result.error);
1525
+ sentToolResults = true;
1526
+ model.#sentToolCallIds.add(result.toolCallId);
1527
+ model.#pendingToolRequests.delete(result.toolCallId);
1528
+ }
1529
+ const isNewGoal = goal && goal !== model.#lastSentGoal;
1530
+ if (!sentToolResults && isNewGoal) {
1531
+ await session.ensureConnected(goal);
1532
+ if (!session.hasStarted) {
1533
+ session.sendStartRequest(goal);
1534
+ }
1535
+ model.#lastSentGoal = goal;
1536
+ }
1537
+ let hasText = false;
1538
+ while (true) {
1539
+ const event = await session.waitForEvent();
1540
+ if (!event) break;
1541
+ if (event.type === "text-delta") {
1542
+ if (!event.value) continue;
1543
+ if (!hasText) {
1544
+ hasText = true;
1545
+ controller.enqueue({ type: "text-start", id: textId });
1546
+ }
1547
+ controller.enqueue({ type: "text-delta", id: textId, delta: event.value });
1548
+ continue;
1549
+ }
1550
+ if (event.type === "tool-request") {
1551
+ let mapped;
1552
+ try {
1553
+ mapped = mapDuoToolRequest(event.toolName, event.args);
1554
+ } catch {
1555
+ continue;
1556
+ }
1557
+ if (hasText) {
1558
+ controller.enqueue({ type: "text-end", id: textId });
1559
+ }
1560
+ if (Array.isArray(mapped)) {
1561
+ const subIds = mapped.map((_, i) => `${event.requestId}#${i}`);
1562
+ model.#multiCallGroups.set(event.requestId, {
1563
+ subIds,
1564
+ collected: /* @__PURE__ */ new Map()
1565
+ });
1566
+ model.#pendingToolRequests.set(event.requestId, {});
1567
+ for (const subId of subIds) {
1568
+ model.#pendingToolRequests.set(subId, {});
1569
+ }
1570
+ for (let i = 0; i < mapped.length; i++) {
1571
+ controller.enqueue({
1572
+ type: "tool-call",
1573
+ toolCallId: subIds[i],
1574
+ toolName: mapped[i].toolName,
1575
+ input: JSON.stringify(mapped[i].args)
1576
+ });
1577
+ }
1578
+ } else {
1579
+ model.#pendingToolRequests.set(event.requestId, {});
1580
+ controller.enqueue({
1581
+ type: "tool-call",
1582
+ toolCallId: event.requestId,
1583
+ toolName: mapped.toolName,
1584
+ input: JSON.stringify(mapped.args)
1585
+ });
1586
+ }
1317
1587
  controller.enqueue({
1318
- type: "text-start",
1319
- id: textId
1588
+ type: "finish",
1589
+ finishReason: "tool-calls",
1590
+ usage: UNKNOWN_USAGE
1320
1591
  });
1592
+ controller.close();
1593
+ return;
1594
+ }
1595
+ if (event.type === "error") {
1596
+ controller.enqueue({ type: "error", error: new Error(event.message) });
1597
+ controller.enqueue({ type: "finish", finishReason: "error", usage: UNKNOWN_USAGE });
1598
+ controller.close();
1599
+ return;
1321
1600
  }
1322
- controller.enqueue({
1323
- type: "text-delta",
1324
- id: textId,
1325
- delta: item.value
1326
- });
1327
1601
  }
1328
1602
  if (hasText) {
1329
- controller.enqueue({
1330
- type: "text-end",
1331
- id: textId
1332
- });
1603
+ controller.enqueue({ type: "text-end", id: textId });
1333
1604
  }
1334
- controller.enqueue({
1335
- type: "finish",
1336
- finishReason: "stop",
1337
- usage: UNKNOWN_USAGE
1338
- });
1605
+ controller.enqueue({ type: "finish", finishReason: "stop", usage: UNKNOWN_USAGE });
1339
1606
  controller.close();
1340
1607
  } catch (error) {
1341
- controller.enqueue({
1342
- type: "error",
1343
- error
1344
- });
1345
- controller.enqueue({
1346
- type: "finish",
1347
- finishReason: "error",
1348
- usage: UNKNOWN_USAGE
1349
- });
1608
+ controller.enqueue({ type: "error", error });
1609
+ controller.enqueue({ type: "finish", finishReason: "error", usage: UNKNOWN_USAGE });
1350
1610
  controller.close();
1611
+ } finally {
1612
+ options.abortSignal?.removeEventListener("abort", onAbort);
1351
1613
  }
1352
1614
  }
1353
1615
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-gitlab-duo-agentic",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "OpenCode plugin and provider for GitLab Duo Agentic workflows",
5
5
  "license": "MIT",
6
6
  "type": "module",