@oh-my-pi/pi-ai 16.3.0 → 16.3.3

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.
@@ -329,15 +329,26 @@ const ANTHROPIC_PROVIDER_SESSION_STATE_KEY = "anthropic-messages";
329
329
  type AnthropicProviderSessionState = ProviderSessionState & {
330
330
  strictToolsDisabled: boolean;
331
331
  fastModeDisabled: boolean;
332
+ /**
333
+ * Runtime-learned: this endpoint returned `400 Invalid signature in
334
+ * thinking block` for a replayed unsigned thinking block, so it must be
335
+ * treated as a signing proxy from now on. All subsequent requests demote
336
+ * unsigned thinking to text for this (baseUrl, modelId), same behavior as
337
+ * an explicit `compat.replayUnsignedThinking: false`. Cleared on session
338
+ * close.
339
+ */
340
+ replayUnsignedThinkingDisabled: boolean;
332
341
  };
333
342
 
334
343
  function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
335
344
  const state: AnthropicProviderSessionState = {
336
345
  strictToolsDisabled: false,
337
346
  fastModeDisabled: false,
347
+ replayUnsignedThinkingDisabled: false,
338
348
  close: () => {
339
349
  state.strictToolsDisabled = false;
340
350
  state.fastModeDisabled = false;
351
+ state.replayUnsignedThinkingDisabled = false;
341
352
  },
342
353
  };
343
354
  return state;
@@ -1644,6 +1655,32 @@ function calculateFallbackTurnCost(
1644
1655
  return true;
1645
1656
  }
1646
1657
 
1658
+ /**
1659
+ * Detects the Anthropic `400 Invalid `signature` in `thinking` block` failure
1660
+ * a signing proxy returns when a stripped/unsigned prior thinking block is
1661
+ * replayed as `signature: ""`. Exported for the compat tests.
1662
+ */
1663
+ const INVALID_THINKING_SIGNATURE_PATTERN = /invalid\s+`?signature`?\s+in\s+`?thinking`?(?:\s+block)?/i;
1664
+ export function isInvalidThinkingSignatureError(message: string): boolean {
1665
+ return INVALID_THINKING_SIGNATURE_PATTERN.test(message);
1666
+ }
1667
+
1668
+ /**
1669
+ * Prepend a pointed remediation to Anthropic's `Invalid signature in thinking
1670
+ * block` 400 when the model looks like an unmarked custom signing proxy
1671
+ * (opaque baseUrl, `spec.reasoning: true`, no explicit
1672
+ * `compat.replayUnsignedThinking` override). The default is native replay for
1673
+ * the 3p reasoning majority (#2005); this hint turns the misconfigured-proxy
1674
+ * case into a one-line fix instead of a silent retry loop (#4297).
1675
+ */
1676
+ export function maybeAddReplayUnsignedThinkingHint(model: Model<"anthropic-messages">, message: string): string {
1677
+ if (!isInvalidThinkingSignatureError(message)) return message;
1678
+ if (model.compat.officialEndpoint) return message;
1679
+ if (model.compatConfig?.replayUnsignedThinking !== undefined) return message;
1680
+ const hint = `Provider "${model.provider}" looks like an Anthropic-compatible signing proxy: it rejected a replayed unsigned thinking block. Set \`compat.replayUnsignedThinking: false\` under \`providers.${model.provider}\` in your models.yml and retry. See https://github.com/can1357/oh-my-pi/issues/4297.`;
1681
+ return `${hint}\n\n${message}`;
1682
+ }
1683
+
1647
1684
  const streamAnthropicOnce = (
1648
1685
  model: Model<"anthropic-messages">,
1649
1686
  context: Context,
@@ -1698,6 +1735,7 @@ const streamAnthropicOnce = (
1698
1735
  let disableStrictTools =
1699
1736
  (providerSessionState?.strictToolsDisabled ?? false) || (model.compat?.disableStrictTools ?? false);
1700
1737
  let dropFastMode = providerSessionState?.fastModeDisabled ?? false;
1738
+ let forceDemoteUnsignedThinking = providerSessionState?.replayUnsignedThinkingDisabled ?? false;
1701
1739
  const mergedCallerHeaders = mergeHeaders(model.headers, options?.headers);
1702
1740
  const umansGatewayWebSearchHeader = getUmansWebSearchHeader(model, mergedCallerHeaders);
1703
1741
 
@@ -1808,6 +1846,7 @@ const streamAnthropicOnce = (
1808
1846
  options,
1809
1847
  disableStrictTools,
1810
1848
  umansGatewayWebSearchHeader !== undefined,
1849
+ forceDemoteUnsignedThinking,
1811
1850
  );
1812
1851
  if (disableStrictTools) {
1813
1852
  dropAnthropicStrictTools(nextParams);
@@ -2377,6 +2416,39 @@ const streamAnthropicOnce = (
2377
2416
  firstTokenTime = undefined;
2378
2417
  continue;
2379
2418
  }
2419
+ if (
2420
+ !forceDemoteUnsignedThinking &&
2421
+ firstTokenTime === undefined &&
2422
+ !streamedReplayUnsafeContent &&
2423
+ isInvalidThinkingSignatureError(
2424
+ streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
2425
+ )
2426
+ ) {
2427
+ logger.warn(
2428
+ "anthropic: signing proxy detected (Invalid signature in thinking block), demoting unsigned thinking and retrying",
2429
+ {
2430
+ provider: model.provider,
2431
+ model: model.id,
2432
+ baseUrl,
2433
+ error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
2434
+ },
2435
+ );
2436
+ if (providerSessionState) {
2437
+ providerSessionState.replayUnsignedThinkingDisabled = true;
2438
+ }
2439
+ forceDemoteUnsignedThinking = true;
2440
+ params = await prepareParams();
2441
+ providerRetryAttempt = 0;
2442
+ output.content.length = 0;
2443
+ output.model = model.id;
2444
+ output.responseId = undefined;
2445
+ output.errorMessage = undefined;
2446
+ output.providerPayload = undefined;
2447
+ output.usage = createEmptyUsage(copilotDynamicHeaders?.premiumRequests);
2448
+ output.stopReason = "stop";
2449
+ firstTokenTime = undefined;
2450
+ continue;
2451
+ }
2380
2452
  if (
2381
2453
  !dropFastMode &&
2382
2454
  model.provider === "anthropic" &&
@@ -2453,6 +2525,9 @@ const streamAnthropicOnce = (
2453
2525
  if (dropFastMode && model.provider === "anthropic" && options?.serviceTier === "priority") {
2454
2526
  output.disabledFeatures = [...(output.disabledFeatures ?? []), "priority"];
2455
2527
  }
2528
+ if (forceDemoteUnsignedThinking && model.compat.replayUnsignedThinking) {
2529
+ output.disabledFeatures = [...(output.disabledFeatures ?? []), "unsigned-thinking-replay"];
2530
+ }
2456
2531
  stream.push({ type: "done", reason: output.stopReason, message: output });
2457
2532
  stream.end();
2458
2533
  } catch (error) {
@@ -2468,7 +2543,7 @@ const streamAnthropicOnce = (
2468
2543
  output.stopReason = result.stopReason;
2469
2544
  output.errorStatus = result.status;
2470
2545
  output.errorId = result.id;
2471
- output.errorMessage = result.message;
2546
+ output.errorMessage = maybeAddReplayUnsignedThinkingHint(model, result.message);
2472
2547
  output.duration = performance.now() - startTime;
2473
2548
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
2474
2549
  stream.push({ type: "error", reason: output.stopReason, error: output });
@@ -3025,7 +3100,16 @@ function buildParams(
3025
3100
  options?: AnthropicOptions,
3026
3101
  disableStrictTools = false,
3027
3102
  useUmansGatewayWebSearch = false,
3103
+ forceDemoteUnsignedThinking = false,
3028
3104
  ): MessageCreateParamsStreaming {
3105
+ // A session-scoped auto-demote (learned from a live signing 400) clones the
3106
+ // resolved compat with `replayUnsignedThinking: false` so every subsequent
3107
+ // downstream read (convertAnthropicMessages, transformMessages) sees the
3108
+ // demoted default without mutating the shared `model` reference.
3109
+ const effectiveModel =
3110
+ forceDemoteUnsignedThinking && model.compat.replayUnsignedThinking
3111
+ ? { ...model, compat: { ...model.compat, replayUnsignedThinking: false } }
3112
+ : model;
3029
3113
  const { cacheControl } = getCacheControl(model, options?.cacheRetention, isOAuthToken);
3030
3114
 
3031
3115
  // Pre-compute system blocks so they occupy the right slot in the serialized body.
@@ -3151,7 +3235,7 @@ function buildParams(
3151
3235
  // metadata → max_tokens → thinking → context_management → output_config → stream.
3152
3236
  const params: MessageCreateParamsStreaming = {
3153
3237
  model: options?.requestModelId ?? model.requestModelId ?? model.id,
3154
- messages: convertAnthropicMessages(context.messages, model, isOAuthToken, {
3238
+ messages: convertAnthropicMessages(context.messages, effectiveModel, isOAuthToken, {
3155
3239
  serverSideFallbackEnabled: !!options?.fallbacks?.length,
3156
3240
  }),
3157
3241
  ...(systemBlocks && { system: systemBlocks }),
@@ -376,7 +376,7 @@ function buildParams(
376
376
  }
377
377
  }
378
378
 
379
- applyResponsesReasoningParams(params, model, options, messages);
379
+ applyResponsesReasoningParams(params, model, options);
380
380
 
381
381
  return params;
382
382
  }
@@ -133,6 +133,7 @@ import type {
133
133
  import { normalizeSystemPrompts } from "../utils";
134
134
  import {
135
135
  clearStreamingPartialJson,
136
+ kCursorExecResolved,
136
137
  kStreamingBlockIndex,
137
138
  kStreamingBlockKind,
138
139
  kStreamingLastParseLen,
@@ -147,6 +148,8 @@ import { toolWireSchema } from "../utils/schema/wire";
147
148
  export const CURSOR_API_URL = "https://api2.cursor.sh";
148
149
  export const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
149
150
 
151
+ const CURSOR_PROXY_TUNNEL_TIMEOUT_MS = 30_000;
152
+
150
153
  const conversationStateCache = new Map<string, ConversationStateStructure>();
151
154
  const conversationBlobStores = new Map<string, Map<string, Uint8Array>>();
152
155
 
@@ -392,7 +395,10 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
392
395
 
393
396
  const proxyUrl = shouldBypassProxy(new URL(baseUrl)) ? undefined : getProxyForProvider(model.provider);
394
397
  if (proxyUrl) {
395
- const tlsSocket = await connectProxiedSocket(proxyUrl, baseUrl);
398
+ const tlsSocket = await connectProxiedSocket(proxyUrl, baseUrl, {
399
+ signal: options?.signal,
400
+ timeoutMs: CURSOR_PROXY_TUNNEL_TIMEOUT_MS,
401
+ });
396
402
  h2Client = http2.connect(baseUrl, {
397
403
  createConnection: () => tlsSocket,
398
404
  });
@@ -628,7 +634,8 @@ export type ToolCallState = ToolCall & {
628
634
  [kStreamingBlockIndex]: number;
629
635
  [kStreamingPartialJson]?: string;
630
636
  [kStreamingLastParseLen]?: number;
631
- [kStreamingBlockKind]: "mcp" | "todo";
637
+ [kStreamingBlockKind]: "mcp" | "todo" | "cursor-exec";
638
+ [kCursorExecResolved]?: true;
632
639
  };
633
640
 
634
641
  export interface BlockState {
@@ -674,6 +681,9 @@ async function handleServerMessage(
674
681
  execHandlers,
675
682
  onToolResult,
676
683
  requestContextTools,
684
+ output,
685
+ stream,
686
+ state,
677
687
  );
678
688
  } else if (msgCase === "conversationCheckpointUpdate") {
679
689
  handleConversationCheckpointUpdate(msg.message.value, output, usageState, onConversationCheckpoint);
@@ -1030,6 +1040,9 @@ async function handleExecServerMessage(
1030
1040
  execHandlers: CursorExecHandlers | undefined,
1031
1041
  onToolResult: CursorToolResultHandler | undefined,
1032
1042
  requestContextTools: McpToolDefinition[],
1043
+ output: AssistantMessage,
1044
+ stream: AssistantMessageEventStream,
1045
+ state: BlockState,
1033
1046
  ): Promise<void> {
1034
1047
  const execCase = execMsg.message.case;
1035
1048
  log("exec", "dispatch", { execCase, execId: execMsg.execId, hasHandlers: !!execHandlers });
@@ -1064,6 +1077,8 @@ async function handleExecServerMessage(
1064
1077
  switch (execCase) {
1065
1078
  case "readArgs": {
1066
1079
  const args = execMsg.message.value;
1080
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1081
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", { path: args.path });
1067
1082
  const { execResult } = await resolveExecHandler(
1068
1083
  args,
1069
1084
  execHandlers?.read?.bind(execHandlers),
@@ -1077,6 +1092,11 @@ async function handleExecServerMessage(
1077
1092
  }
1078
1093
  case "lsArgs": {
1079
1094
  const args = execMsg.message.value;
1095
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1096
+ // Bridge maps `ls` onto the coding-agent `read` tool (see
1097
+ // `CursorExecHandlers.ls` in `pi-coding-agent/src/cursor.ts`); mirror
1098
+ // that here so the synthesized block matches the toolResult's `toolName`.
1099
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", { path: args.path });
1080
1100
  const { execResult } = await resolveExecHandler(
1081
1101
  args,
1082
1102
  execHandlers?.ls?.bind(execHandlers),
@@ -1090,6 +1110,16 @@ async function handleExecServerMessage(
1090
1110
  }
1091
1111
  case "grepArgs": {
1092
1112
  const args = execMsg.message.value;
1113
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1114
+ // Mirror the coding-agent bridge's arg mapping so live UI (from
1115
+ // `tool_execution_start`) and rebuilt transcript (from this block)
1116
+ // display identical args.
1117
+ const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
1118
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "grep", {
1119
+ pattern: args.pattern,
1120
+ path: searchPath,
1121
+ case: args.caseInsensitive === true ? false : undefined,
1122
+ });
1093
1123
  const { execResult } = await resolveExecHandler(
1094
1124
  args,
1095
1125
  execHandlers?.grep?.bind(execHandlers),
@@ -1103,6 +1133,13 @@ async function handleExecServerMessage(
1103
1133
  }
1104
1134
  case "writeArgs": {
1105
1135
  const args = execMsg.message.value;
1136
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1137
+ // Match the bridge: prefer `fileText`, fall back to decoded `fileBytes`.
1138
+ const content = args.fileText ?? new TextDecoder().decode(args.fileBytes ?? new Uint8Array());
1139
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "write", {
1140
+ path: args.path,
1141
+ content,
1142
+ });
1106
1143
  const { execResult } = await resolveExecHandler(
1107
1144
  args,
1108
1145
  execHandlers?.write?.bind(execHandlers),
@@ -1125,6 +1162,8 @@ async function handleExecServerMessage(
1125
1162
  }
1126
1163
  case "deleteArgs": {
1127
1164
  const args = execMsg.message.value;
1165
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1166
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "delete", { path: args.path });
1128
1167
  const { execResult } = await resolveExecHandler(
1129
1168
  args,
1130
1169
  execHandlers?.delete?.bind(execHandlers),
@@ -1138,7 +1177,16 @@ async function handleExecServerMessage(
1138
1177
  }
1139
1178
  case "shellArgs": {
1140
1179
  const args = execMsg.message.value;
1180
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1141
1181
  const normalizedArgs: ShellArgs = { ...args, workingDirectory: args.workingDirectory || process.cwd() };
1182
+ // Match the bridge (`CursorExecHandlers.shell`): map `workingDirectory`
1183
+ // → `cwd`, drop non-positive timeouts.
1184
+ const shellTimeout = args.timeout && args.timeout > 0 ? args.timeout : undefined;
1185
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
1186
+ command: args.command,
1187
+ cwd: args.workingDirectory || undefined,
1188
+ timeout: shellTimeout,
1189
+ });
1142
1190
  const { execResult } = await resolveExecHandler(
1143
1191
  args,
1144
1192
  execHandlers?.shell?.bind(execHandlers),
@@ -1153,6 +1201,13 @@ async function handleExecServerMessage(
1153
1201
  }
1154
1202
  case "shellStreamArgs": {
1155
1203
  const args = execMsg.message.value;
1204
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1205
+ const shellStreamTimeout = args.timeout && args.timeout > 0 ? args.timeout : undefined;
1206
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
1207
+ command: args.command,
1208
+ cwd: args.workingDirectory || undefined,
1209
+ timeout: shellStreamTimeout,
1210
+ });
1156
1211
  await handleShellStreamArgs(args, execMsg, h2Request, execHandlers, onToolResult);
1157
1212
  return;
1158
1213
  }
@@ -1200,6 +1255,13 @@ async function handleExecServerMessage(
1200
1255
  }
1201
1256
  case "diagnosticsArgs": {
1202
1257
  const args = execMsg.message.value;
1258
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1259
+ // Bridge maps `diagnostics` onto the coding-agent `lsp` tool with
1260
+ // `action: "diagnostics"` and `file: path`.
1261
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "lsp", {
1262
+ action: "diagnostics",
1263
+ file: args.path,
1264
+ });
1203
1265
  const { execResult } = await resolveExecHandler(
1204
1266
  args,
1205
1267
  execHandlers?.diagnostics?.bind(execHandlers),
@@ -2016,6 +2078,50 @@ function endCurrentThinkingBlock(
2016
2078
  state.setThinkingBlock(null);
2017
2079
  }
2018
2080
 
2081
+ /**
2082
+ * Synthesize a completed `toolCall` content block for a Cursor exec-channel
2083
+ * native tool (`shell`, `read`, `write`, `grep`, `ls`, `delete`, `diagnostics`).
2084
+ *
2085
+ * Args arrive complete on the exec message, so the block opens and closes in
2086
+ * one step — no partial-JSON streaming path. Without this the persisted
2087
+ * assistant message carries only text/thinking blocks, and on replay the
2088
+ * following `toolResult` messages have no matching `toolCall.id` in
2089
+ * `renderSessionContext`, so they render as header-less `⎿` lines beneath the
2090
+ * last text block instead of proper tool components (issue #4348).
2091
+ *
2092
+ * The block is stamped with {@link kCursorExecResolved} so the shared
2093
+ * `agent-loop.ts` execution pass skips it — Cursor's server-driven exec
2094
+ * channel already ran the tool via the bridge and buffered the result, so
2095
+ * treating this block as runnable would re-execute the same side-effecting
2096
+ * tool a second time.
2097
+ *
2098
+ * Exported for tests to exercise ordering with adjacent text/thinking blocks.
2099
+ */
2100
+ export function synthesizeCursorExecToolCall(
2101
+ output: AssistantMessage,
2102
+ stream: AssistantMessageEventStream,
2103
+ state: BlockState,
2104
+ toolCallId: string,
2105
+ toolName: string,
2106
+ args: Record<string, unknown>,
2107
+ ): void {
2108
+ endCurrentTextBlock(output, stream, state);
2109
+ endCurrentThinkingBlock(output, stream, state);
2110
+ const block: ToolCallState = {
2111
+ type: "toolCall",
2112
+ id: toolCallId,
2113
+ name: toolName,
2114
+ arguments: args,
2115
+ [kStreamingBlockIndex]: output.content.length,
2116
+ [kStreamingBlockKind]: "cursor-exec",
2117
+ [kCursorExecResolved]: true,
2118
+ };
2119
+ output.content.push(block);
2120
+ const idx = output.content.length - 1;
2121
+ stream.push({ type: "toolcall_start", contentIndex: idx, partial: output });
2122
+ stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: output });
2123
+ }
2124
+
2019
2125
  /** Exported for tests: drives one Cursor interaction update through the streaming state machine. */
2020
2126
  export function processInteractionUpdate(
2021
2127
  update: any,
@@ -69,6 +69,15 @@ const DEVIN_DEFAULT_STOP_PATTERNS = ["<|user|>", "<|bot|>", "<|context_request|>
69
69
  /** Connect streaming framing: flag byte bit 0x01 = gzip payload, 0x02 = end-of-stream JSON trailers. */
70
70
  const CONNECT_COMPRESSED_FLAG = 0x01;
71
71
  const CONNECT_END_STREAM_FLAG = 0x02;
72
+ /**
73
+ * Hard upper bound on a single Connect frame payload. The 4-byte length prefix
74
+ * is otherwise attacker-controlled (up to `2**32 - 1`), so a malicious or buggy
75
+ * peer could force {@link streamDevin}'s reader to buffer gigabytes via
76
+ * `Buffer.concat` before the idle-timeout wrapper aborts. Well above any
77
+ * legitimate Cascade response but tight enough that a corrupt length prefix
78
+ * fails fast instead of consuming memory.
79
+ */
80
+ const MAX_CONNECT_FRAME_PAYLOAD = 16 * 1024 * 1024;
72
81
 
73
82
  export const streamDevin: StreamFunction<"devin-agent"> = (
74
83
  model: Model<"devin-agent">,
@@ -201,6 +210,12 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
201
210
  while (pending.length >= 5) {
202
211
  const flag = pending[0];
203
212
  const len = pending.readUInt32BE(1);
213
+ if (len > MAX_CONNECT_FRAME_PAYLOAD) {
214
+ throw new AIError.ProviderResponseError(
215
+ `Devin Connect frame length ${len} exceeds ${MAX_CONNECT_FRAME_PAYLOAD}-byte cap`,
216
+ { provider: model.provider, kind: "envelope" },
217
+ );
218
+ }
204
219
  if (pending.length < 5 + len) break;
205
220
  const payload = pending.subarray(5, 5 + len);
206
221
  pending = pending.subarray(5 + len);
@@ -47,6 +47,20 @@ const GITLAB_DUO_WORKFLOW_CLIENT_TYPE = "node-websocket";
47
47
  * run reconnects once on the same `workflowID` (server-side resume).
48
48
  */
49
49
  const GITLAB_DUO_WORKFLOW_IDLE_TIMEOUT_MS = 90_000;
50
+ /**
51
+ * Absolute deadline (ms) for each REST setup fetch (`ensureGitLabDuoWorkflowSettings`,
52
+ * `discoverGitLabDuoWorkflowProject`, `resolveGitLabDuoWorkflowNumericProjectId`,
53
+ * `requestGitLabDuoWorkflowDirectAccess`, `createGitLabDuoWorkflow`,
54
+ * `fetchGitLabDuoWorkflowAvailableModels`, `stopGitLabDuoWorkflow`).
55
+ *
56
+ * `streamGitLabDuoWorkflow` pushes its `start` event before these calls run and the
57
+ * `gitlab-duo-agent` bypass in `streamSimple` skips the `register-builtins`
58
+ * `iterateWithIdleTimeout` wrapper, so a stalled setup fetch would otherwise leave
59
+ * the stream with no terminal event. 30s covers healthy p99 for every REST endpoint
60
+ * the workflow touches while still surfacing a real stall as a provider error;
61
+ * matches the OAuth `TOKEN_REQUEST_TIMEOUT_MS` used by sibling GitLab flows.
62
+ */
63
+ const GITLAB_DUO_WORKFLOW_REST_TIMEOUT_MS = 30_000;
50
64
  /**
51
65
  * How many times a single stream may restart on a FRESH workflow after the server
52
66
  * reports its per-workflow step (graph-recursion) limit. Long OMP tool-call loops
@@ -913,6 +927,19 @@ export function gitLabDuoWorkflowErrorText(error: unknown): string {
913
927
  return error instanceof Error ? error.message : String(error);
914
928
  }
915
929
 
930
+ // Absolute-deadline signal for one REST setup fetch (`fetch`, `direct_access`, etc.).
931
+ // The caller's abort signal — when present — is folded in with `AbortSignal.any`, so
932
+ // either the request being cancelled OR the local timeout aborts the fetch. Called
933
+ // per-fetch so each REST call gets its OWN fresh budget; a shared timeout would race
934
+ // several fetches on the same clock and starve the later ones after the first spent
935
+ // the whole budget. The workflow's `start` event already streamed before any of
936
+ // these calls run, so an unbounded fetch would leave the assistant stream with no
937
+ // terminal event — see {@link GITLAB_DUO_WORKFLOW_REST_TIMEOUT_MS}.
938
+ function gitLabDuoWorkflowRestSignal(callerSignal?: AbortSignal): AbortSignal {
939
+ const timeoutSignal = AbortSignal.timeout(GITLAB_DUO_WORKFLOW_REST_TIMEOUT_MS);
940
+ return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
941
+ }
942
+
916
943
  async function readGitLabDuoWorkflowResponseErrorMessage(response: Response): Promise<string | undefined> {
917
944
  try {
918
945
  const payload: unknown = await response.json();
@@ -1074,7 +1101,7 @@ async function runGitLabDuoWorkflow(
1074
1101
  // success or 4xx). A transient network error / 5xx returns false so a later
1075
1102
  // turn retries instead of permanently skipping the PUT on a namespace whose
1076
1103
  // flags are still off.
1077
- if (await ensureGitLabDuoWorkflowSettings(fetchImpl, baseUrl, apiKey, restNamespaceId)) {
1104
+ if (await ensureGitLabDuoWorkflowSettings(fetchImpl, baseUrl, apiKey, restNamespaceId, options.signal)) {
1078
1105
  markGitLabDuoWorkflowSettingsEnsured(apiKey, baseUrl, options.cwd);
1079
1106
  }
1080
1107
  }
@@ -1089,7 +1116,7 @@ async function runGitLabDuoWorkflow(
1089
1116
  !configuredProjectPath && !configuredProjectId && isGitLabDuoWorkflowInlineFlow(workflowDefinition)
1090
1117
  ? namespaceSelection.projectPath
1091
1118
  ? { path: namespaceSelection.projectPath }
1092
- : await discoverGitLabDuoWorkflowProject(fetchImpl, baseUrl, apiKey, restNamespaceId)
1119
+ : await discoverGitLabDuoWorkflowProject(fetchImpl, baseUrl, apiKey, restNamespaceId, options.signal)
1093
1120
  : undefined;
1094
1121
  if (discoveredProject) {
1095
1122
  traceGitLabDuoWorkflow("project.discover", {
@@ -1111,7 +1138,7 @@ async function runGitLabDuoWorkflow(
1111
1138
  const webSocketProjectId =
1112
1139
  projectId ??
1113
1140
  (projectPath
1114
- ? await resolveGitLabDuoWorkflowNumericProjectId(fetchImpl, baseUrl, apiKey, projectPath)
1141
+ ? await resolveGitLabDuoWorkflowNumericProjectId(fetchImpl, baseUrl, apiKey, projectPath, options.signal)
1115
1142
  : undefined);
1116
1143
  const workflowConnection: GitLabDuoWorkflowDirectAccessConnection = options.workflowToken
1117
1144
  ? { token: options.workflowToken, headers: {}, serviceEndpoint: false }
@@ -1122,6 +1149,7 @@ async function runGitLabDuoWorkflow(
1122
1149
  rootNamespaceId,
1123
1150
  restProjectId,
1124
1151
  workflowDefinition,
1152
+ options.signal,
1125
1153
  );
1126
1154
  const workflowId =
1127
1155
  options.workflowId ??
@@ -1135,7 +1163,13 @@ async function runGitLabDuoWorkflow(
1135
1163
  workflowDefinition,
1136
1164
  options.signal,
1137
1165
  ));
1138
- const availableModels = await fetchGitLabDuoWorkflowAvailableModels(fetchImpl, baseUrl, apiKey, rootNamespaceId);
1166
+ const availableModels = await fetchGitLabDuoWorkflowAvailableModels(
1167
+ fetchImpl,
1168
+ baseUrl,
1169
+ apiKey,
1170
+ rootNamespaceId,
1171
+ options.signal,
1172
+ );
1139
1173
  const selectedModelIdentifier = selectGitLabDuoWorkflowModelRef(model.id, availableModels);
1140
1174
  // A `toolChoice: "none"` side-request (e.g. handoff keeps live tool definitions
1141
1175
  // in the cache prefix but disables tool use) must not advertise the tools to
@@ -1466,6 +1500,7 @@ async function fetchGitLabDuoWorkflowAvailableModels(
1466
1500
  baseUrl: string,
1467
1501
  apiKey: string,
1468
1502
  rootNamespaceId: string,
1503
+ signal?: AbortSignal,
1469
1504
  ): Promise<GitLabAvailableModelsPayload | undefined> {
1470
1505
  try {
1471
1506
  const response = await fetchImpl(gitLabApiUrl(baseUrl, "/api/graphql"), {
@@ -1478,12 +1513,16 @@ async function fetchGitLabDuoWorkflowAvailableModels(
1478
1513
  query: GITLAB_DUO_WORKFLOW_AVAILABLE_MODELS_QUERY,
1479
1514
  variables: { rootNamespaceId: toGitLabGraphQLNamespaceId(rootNamespaceId) },
1480
1515
  }),
1516
+ signal: gitLabDuoWorkflowRestSignal(signal),
1481
1517
  });
1482
1518
  if (!response.ok) return undefined;
1483
1519
  const payload: unknown = await response.json();
1484
1520
  const models = getRecord(getRecord(payload, "data"), "aiChatAvailableModels");
1485
1521
  return parseGitLabAvailableModelsPayload(models);
1486
1522
  } catch {
1523
+ // Timeout (AbortSignal.timeout) surfaces as an AbortError here; matches the pre-fix
1524
+ // transient-network behavior (undefined -> caller falls back to defaults), so a
1525
+ // stalled models fetch degrades rather than hanging the whole stream.
1487
1526
  return undefined;
1488
1527
  }
1489
1528
  }
@@ -1513,6 +1552,7 @@ async function resolveGitLabDuoWorkflowNumericProjectId(
1513
1552
  baseUrl: string,
1514
1553
  apiKey: string,
1515
1554
  projectPath: string,
1555
+ signal?: AbortSignal,
1516
1556
  ): Promise<string | undefined> {
1517
1557
  try {
1518
1558
  const response = await fetchImpl(gitLabApiUrl(baseUrl, `/api/v4/projects/${encodeURIComponent(projectPath)}`), {
@@ -1521,11 +1561,15 @@ async function resolveGitLabDuoWorkflowNumericProjectId(
1521
1561
  Authorization: `Bearer ${apiKey}`,
1522
1562
  "content-type": "application/json",
1523
1563
  },
1564
+ signal: gitLabDuoWorkflowRestSignal(signal),
1524
1565
  });
1525
1566
  if (!response.ok) return undefined;
1526
1567
  const payload: unknown = await response.json();
1527
1568
  return getRecordString(payload, "id");
1528
1569
  } catch {
1570
+ // Timeout / abort behaves like a transient network fault: undefined leaves the
1571
+ // caller to fall back to the workflow's namespace-only routing rather than block
1572
+ // the stream on a hanging project lookup.
1529
1573
  return undefined;
1530
1574
  }
1531
1575
  }
@@ -1549,6 +1593,7 @@ async function discoverGitLabDuoWorkflowProject(
1549
1593
  baseUrl: string,
1550
1594
  apiKey: string,
1551
1595
  restNamespaceId: string,
1596
+ signal?: AbortSignal,
1552
1597
  ): Promise<GitLabDuoWorkflowDiscoveredProject | undefined> {
1553
1598
  const query = "per_page=1&min_access_level=30&order_by=last_activity_at&sort=desc";
1554
1599
  const endpoints = [
@@ -1563,6 +1608,7 @@ async function discoverGitLabDuoWorkflowProject(
1563
1608
  Authorization: `Bearer ${apiKey}`,
1564
1609
  "content-type": "application/json",
1565
1610
  },
1611
+ signal: gitLabDuoWorkflowRestSignal(signal),
1566
1612
  });
1567
1613
  if (!response.ok) continue;
1568
1614
  const payload: unknown = await response.json();
@@ -1570,7 +1616,10 @@ async function discoverGitLabDuoWorkflowProject(
1570
1616
  const id = getRecordString(first, "id");
1571
1617
  const path = getRecordString(first, "path_with_namespace");
1572
1618
  if (id && path) return { id, path };
1573
- } catch {}
1619
+ } catch {
1620
+ // Timeout/abort on one endpoint: fall through to the next fallback rather than
1621
+ // aborting discovery. Each endpoint gets its own fresh REST budget.
1622
+ }
1574
1623
  }
1575
1624
  return undefined;
1576
1625
  }
@@ -1582,7 +1631,12 @@ async function requestGitLabDuoWorkflowDirectAccess(
1582
1631
  rootNamespaceId: string,
1583
1632
  projectId?: string,
1584
1633
  workflowDefinition: GitLabDuoWorkflowDefinition = GITLAB_DUO_WORKFLOW_DEFINITION,
1634
+ signal?: AbortSignal,
1585
1635
  ): Promise<GitLabDuoWorkflowDirectAccessConnection> {
1636
+ // A timeout here throws `AbortError`/`TimeoutError` (per `AbortSignal.timeout`),
1637
+ // which surfaces through the outer `streamGitLabDuoWorkflow` catch as a real
1638
+ // stream `error` event — matching the existing HTTP-error path rather than the
1639
+ // swallowed best-effort helpers (settings ensure / project discovery / models).
1586
1640
  const response = await fetchImpl(gitLabApiUrl(baseUrl, "/api/v4/ai/duo_workflows/direct_access"), {
1587
1641
  method: "POST",
1588
1642
  headers: {
@@ -1590,6 +1644,7 @@ async function requestGitLabDuoWorkflowDirectAccess(
1590
1644
  "content-type": "application/json",
1591
1645
  },
1592
1646
  body: JSON.stringify(buildGitLabDuoWorkflowDirectAccessBody(rootNamespaceId, projectId, workflowDefinition)),
1647
+ signal: gitLabDuoWorkflowRestSignal(signal),
1593
1648
  });
1594
1649
  traceGitLabDuoWorkflow("direct_access.response", {
1595
1650
  status: response.status,
@@ -1654,7 +1709,7 @@ async function createGitLabDuoWorkflow(
1654
1709
  "content-type": "application/json",
1655
1710
  },
1656
1711
  body: JSON.stringify(body),
1657
- signal,
1712
+ signal: gitLabDuoWorkflowRestSignal(signal),
1658
1713
  });
1659
1714
  traceGitLabDuoWorkflow("workflow.create.response", {
1660
1715
  status: response.status,
@@ -1686,14 +1741,30 @@ async function stopGitLabDuoWorkflow(
1686
1741
  apiKey: string,
1687
1742
  workflowId: string,
1688
1743
  ): Promise<void> {
1689
- await fetchImpl(gitLabApiUrl(baseUrl, `/api/v4/ai/duo_workflows/workflows/${encodeURIComponent(workflowId)}`), {
1690
- method: "PATCH",
1691
- headers: {
1692
- Authorization: `Bearer ${apiKey}`,
1693
- "content-type": "application/json",
1694
- },
1695
- body: JSON.stringify(buildGitLabDuoWorkflowStopBody()),
1696
- });
1744
+ // Stop rides a FRESH timeout signal, deliberately decoupled from `options.signal`
1745
+ // (see the `finally` block in `runGitLabDuoWorkflow`): a run cancelled by the
1746
+ // caller must still fire the server-side stop, but a stalled PATCH here would
1747
+ // otherwise leave the `runGitLabDuoWorkflow` promise unresolved forever — the
1748
+ // bounded budget keeps cleanup best-effort in both directions.
1749
+ try {
1750
+ await fetchImpl(gitLabApiUrl(baseUrl, `/api/v4/ai/duo_workflows/workflows/${encodeURIComponent(workflowId)}`), {
1751
+ method: "PATCH",
1752
+ headers: {
1753
+ Authorization: `Bearer ${apiKey}`,
1754
+ "content-type": "application/json",
1755
+ },
1756
+ body: JSON.stringify(buildGitLabDuoWorkflowStopBody()),
1757
+ signal: gitLabDuoWorkflowRestSignal(),
1758
+ });
1759
+ } catch (error) {
1760
+ // Server-side stop is best-effort: a timeout / network fault must not reject
1761
+ // the caller (the local stream already emitted its terminal event). Trace and
1762
+ // swallow so the enclosing `finally` never surfaces a spurious rejection.
1763
+ traceGitLabDuoWorkflow("workflow.stop_error", {
1764
+ workflowId,
1765
+ error: gitLabDuoWorkflowErrorText(error),
1766
+ });
1767
+ }
1697
1768
  }
1698
1769
 
1699
1770
  // Body the group PUT carries to turn on exactly the three flags the inline MCP-only
@@ -1721,6 +1792,7 @@ async function ensureGitLabDuoWorkflowSettings(
1721
1792
  baseUrl: string,
1722
1793
  apiKey: string,
1723
1794
  restNamespaceId: string,
1795
+ signal?: AbortSignal,
1724
1796
  ): Promise<boolean> {
1725
1797
  // Returns whether the attempt was DEFINITIVE (so the caller may stop retrying):
1726
1798
  // any HTTP response — 2xx (flags now on) or 4xx (insufficient rights / no such
@@ -1735,6 +1807,7 @@ async function ensureGitLabDuoWorkflowSettings(
1735
1807
  "content-type": "application/json",
1736
1808
  },
1737
1809
  body: JSON.stringify(buildGitLabDuoWorkflowSettingsBody()),
1810
+ signal: gitLabDuoWorkflowRestSignal(signal),
1738
1811
  });
1739
1812
  traceGitLabDuoWorkflow("settings.ensure", { status: response.status, ok: response.ok });
1740
1813
  return response.status < 500;