| 1 | import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; |
| 2 | import * as Cause from "effect/Cause"; |
| 3 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Fiber from "effect/Fiber"; |
| 6 | import * as Ref from "effect/Ref"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 9 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 10 | |
| 11 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 12 | import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; |
| 13 | |
| 14 | const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; |
| 15 | export const xAiRateLimitedErrorCode = -32003; |
| 16 | const completedXAiPromptIdLimit = 128; |
| 17 | |
| 18 | const XAiPromptCompleteNotification = Schema.Struct({ |
| 19 | sessionId: Schema.String, |
| 20 | promptId: Schema.optional(Schema.String), |
| 21 | stopReason: Schema.optional(Schema.String), |
| 22 | agentResult: Schema.optional(Schema.NullOr(Schema.Unknown)), |
| 23 | }); |
| 24 | |
| 25 | type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; |
| 26 | |
| 27 | /** |
| 28 | * Grok persists root turn completion as an extension session update: |
| 29 | * `{ params: { sessionId, update: { |
| 30 | * sessionUpdate: "turn_completed", prompt_id, stop_reason } } }`. |
| 31 | * The open-source runtime forwards it as `x.ai/session_notification` and |
| 32 | * stores/replays it as `_x.ai/session/update`; released 0.2.106 forwards the |
| 33 | * underscore alias `_x.ai/session_notification`. Current source also emits |
| 34 | * the fire-and-forget canonical `x.ai/session/prompt_complete`, while released |
| 35 | * builds use the underscore alias. |
| 36 | */ |
| 37 | const XAiSessionUpdateNotification = Schema.Struct({ |
| 38 | sessionId: Schema.String, |
| 39 | update: Schema.Struct({ |
| 40 | sessionUpdate: Schema.String, |
| 41 | prompt_id: Schema.optional(Schema.String), |
| 42 | promptId: Schema.optional(Schema.String), |
| 43 | stop_reason: Schema.optional(Schema.String), |
| 44 | stopReason: Schema.optional(Schema.String), |
| 45 | agent_result: Schema.optional(Schema.NullOr(Schema.Unknown)), |
| 46 | // subagent_finished |
| 47 | child_session_id: Schema.optional(Schema.String), |
| 48 | status: Schema.optional(Schema.String), |
| 49 | output: Schema.optional(Schema.NullOr(Schema.String)), |
| 50 | error: Schema.optional(Schema.NullOr(Schema.String)), |
| 51 | }), |
| 52 | _meta: Schema.optional(Schema.Unknown), |
| 53 | }); |
| 54 | |
| 55 | type XAiSessionUpdateNotification = typeof XAiSessionUpdateNotification.Type; |
| 56 | |
| 57 | const decodeXAiSessionUpdateNotification = Schema.decodeUnknownEffect(XAiSessionUpdateNotification); |
| 58 | |
| 59 | const xAiSessionNotificationMethods = [ |
| 60 | "x.ai/session_notification", |
| 61 | "_x.ai/session_notification", |
| 62 | "_x.ai/session/update", |
| 63 | ] as const; |
| 64 | |
| 65 | const XAI_TASK_COMPLETED_PROMPT_ID_PREFIX = "task-completed-"; |
| 66 | |
| 67 | /** |
| 68 | * Map a Grok `_x.ai/session/update` payload to a prompt-complete shape when it |
| 69 | * is a root `turn_completed` for a real prompt id. Returns null for hooks, |
| 70 | * background task completions, and other update kinds. |
| 71 | */ |
| 72 | export function xAiPromptCompleteFromSessionUpdate( |
| 73 | notification: XAiSessionUpdateNotification, |
| 74 | ): XAiPromptCompleteNotification | null { |
| 75 | const update = notification.update; |
| 76 | if (update.sessionUpdate !== "turn_completed") { |
| 77 | return null; |
| 78 | } |
| 79 | const promptId = nonEmptyString(update.prompt_id) ?? nonEmptyString(update.promptId); |
| 80 | // Require prompt id so we never session-fallback match a background task |
| 81 | // completion (`task-completed-*`) or an unrelated CLI turn. |
| 82 | if (promptId === undefined || promptId.startsWith(XAI_TASK_COMPLETED_PROMPT_ID_PREFIX)) { |
| 83 | return null; |
| 84 | } |
| 85 | const stopReason = nonEmptyString(update.stop_reason) ?? nonEmptyString(update.stopReason); |
| 86 | return { |
| 87 | sessionId: notification.sessionId, |
| 88 | promptId, |
| 89 | ...(stopReason === undefined ? {} : { stopReason }), |
| 90 | ...(update.agent_result === undefined ? {} : { agentResult: update.agent_result }), |
| 91 | }; |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Grok answers a finished background command in its own turn, tagging every |
| 96 | * frame with a `task-completed-*` prompt id instead of the one T3 sent. |
| 97 | */ |
| 98 | export function isXAiTaskCompletedWakeNotification( |
| 99 | notification: EffectAcpSchema.SessionNotification, |
| 100 | ): boolean { |
| 101 | const promptId = notification._meta?.promptId; |
| 102 | return typeof promptId === "string" && promptId.startsWith(XAI_TASK_COMPLETED_PROMPT_ID_PREFIX); |
| 103 | } |
| 104 | |
| 105 | interface PendingXAiPromptCompletion { |
| 106 | readonly sessionId: string; |
| 107 | readonly promptId: string; |
| 108 | readonly deferred: Deferred.Deferred< |
| 109 | EffectAcpSchema.PromptResponse, |
| 110 | EffectAcpErrors.AcpRequestError |
| 111 | >; |
| 112 | } |
| 113 | |
| 114 | export interface XAiAcpSubagentUpdate { |
| 115 | readonly nativeTaskId: string; |
| 116 | readonly prompt: string; |
| 117 | readonly title: string | null; |
| 118 | readonly model: string | null; |
| 119 | readonly status: "running" | "completed" | "failed"; |
| 120 | readonly childSessionId: string | null; |
| 121 | readonly result: string | null; |
| 122 | /** |
| 123 | * When false, the ACP adapter also projects a normal tool turn item for this |
| 124 | * tool call (used for get_command_or_subagent_output hydration). Default true. |
| 125 | */ |
| 126 | readonly suppressNormalTool?: boolean; |
| 127 | } |
| 128 | |
| 129 | export interface XAiAcpBackgroundToolMutation { |
| 130 | readonly taskId: string; |
| 131 | readonly status: "running" | "completed" | "failed"; |
| 132 | readonly appendOutput: string; |
| 133 | } |
| 134 | |
| 135 | export interface XAiAcpSubagentEndNotice { |
| 136 | readonly childSessionId: string; |
| 137 | readonly status: "completed" | "failed"; |
| 138 | } |
| 139 | |
| 140 | const XAI_UUID_RE = "[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}"; |
| 141 | |
| 142 | function unknownRecord(value: unknown): Record<string, unknown> | undefined { |
| 143 | return typeof value === "object" && value !== null && !Array.isArray(value) |
| 144 | ? (value as Record<string, unknown>) |
| 145 | : undefined; |
| 146 | } |
| 147 | |
| 148 | function nonEmptyString(value: unknown): string | undefined { |
| 149 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; |
| 150 | } |
| 151 | |
| 152 | function titleKey(toolCall: AcpToolCallState): string { |
| 153 | return (toolCall.title ?? "").trim().toLowerCase(); |
| 154 | } |
| 155 | |
| 156 | function decodeByteText(value: unknown): string | undefined { |
| 157 | if (typeof value === "string") return nonEmptyString(value); |
| 158 | if (!Array.isArray(value)) return undefined; |
| 159 | if (value.length === 0) return undefined; |
| 160 | if (!value.every((entry) => typeof entry === "number")) return undefined; |
| 161 | try { |
| 162 | return nonEmptyString(new TextDecoder().decode(Uint8Array.from(value as number[]))); |
| 163 | } catch { |
| 164 | return undefined; |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | function xAiToolOutputText(toolCall: AcpToolCallState): string | undefined { |
| 169 | const rawOutput = unknownRecord(toolCall.data.rawOutput); |
| 170 | if (rawOutput !== undefined) { |
| 171 | const direct = |
| 172 | nonEmptyString(rawOutput.text) ?? |
| 173 | nonEmptyString(rawOutput.output_for_prompt) ?? |
| 174 | decodeByteText(rawOutput.output); |
| 175 | if (direct !== undefined) return direct; |
| 176 | const result = unknownRecord(rawOutput.Result) ?? unknownRecord(rawOutput.result); |
| 177 | if (result !== undefined) { |
| 178 | const nested = |
| 179 | nonEmptyString(result.output) ?? |
| 180 | nonEmptyString(result.text) ?? |
| 181 | decodeByteText(result.output); |
| 182 | if (nested !== undefined) return nested; |
| 183 | } |
| 184 | } |
| 185 | if (typeof toolCall.data.rawOutput === "string") { |
| 186 | return nonEmptyString(toolCall.data.rawOutput); |
| 187 | } |
| 188 | const content = toolCall.data.content; |
| 189 | if (!Array.isArray(content)) return undefined; |
| 190 | const text = content |
| 191 | .flatMap((entry) => { |
| 192 | const record = unknownRecord(entry); |
| 193 | const nested = unknownRecord(record?.content); |
| 194 | return nonEmptyString(nested?.text) ?? nonEmptyString(record?.text) ?? []; |
| 195 | }) |
| 196 | .join("\n") |
| 197 | .trim(); |
| 198 | return text.length > 0 ? text : undefined; |
| 199 | } |
| 200 | |
| 201 | function firstUuidMatch(text: string, pattern: RegExp): string | undefined { |
| 202 | const match = pattern.exec(text); |
| 203 | return match?.[1]; |
| 204 | } |
| 205 | |
| 206 | function extractXAiChildSessionId(output: string | undefined): string | null { |
| 207 | if (output === undefined) return null; |
| 208 | return ( |
| 209 | firstUuidMatch(output, new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*(${XAI_UUID_RE})\\b`, "i")) ?? |
| 210 | firstUuidMatch(output, new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*(${XAI_UUID_RE})\\b`, "i")) ?? |
| 211 | firstUuidMatch(output, new RegExp(`===\\s*Task\\s+(${XAI_UUID_RE})\\s*===`, "i")) ?? |
| 212 | null |
| 213 | ); |
| 214 | } |
| 215 | |
| 216 | function isXAiAsyncSpawnAck(output: string | undefined): boolean { |
| 217 | if (output === undefined) return false; |
| 218 | if (/subagent started in background/i.test(output)) return true; |
| 219 | return ( |
| 220 | new RegExp(`subagent_id:\\s*${XAI_UUID_RE}`, "i").test(output) && |
| 221 | /get_command_or_subagent_output/i.test(output) |
| 222 | ); |
| 223 | } |
| 224 | |
| 225 | function isXAiMonitorStartAck(output: string | undefined): boolean { |
| 226 | if (output === undefined) return false; |
| 227 | return /monitor started\s*\(\s*task\s+/i.test(output); |
| 228 | } |
| 229 | |
| 230 | function isXAiSpawnOrTaskTool( |
| 231 | toolCall: AcpToolCallState, |
| 232 | rawInput: Record<string, unknown> | undefined, |
| 233 | ): boolean { |
| 234 | const title = titleKey(toolCall); |
| 235 | if (title === "task" || title === "spawn_subagent" || title.includes("spawn subagent")) { |
| 236 | return true; |
| 237 | } |
| 238 | const variant = nonEmptyString(rawInput?.variant)?.toLowerCase(); |
| 239 | if (variant === "cursortask" || variant === "task" || variant === "spawn_subagent") { |
| 240 | return true; |
| 241 | } |
| 242 | if (nonEmptyString(rawInput?.subagent_type) !== undefined) return true; |
| 243 | if (nonEmptyString(rawInput?.subagentType) !== undefined) return true; |
| 244 | return false; |
| 245 | } |
| 246 | |
| 247 | function isXAiGetSubagentOutputTool( |
| 248 | toolCall: AcpToolCallState, |
| 249 | rawInput: Record<string, unknown> | undefined, |
| 250 | ): boolean { |
| 251 | const title = titleKey(toolCall); |
| 252 | if ( |
| 253 | title === "get_command_or_subagent_output" || |
| 254 | title.includes("get_command_or_subagent_output") || |
| 255 | title === "wait_commands_or_subagents" |
| 256 | ) { |
| 257 | return true; |
| 258 | } |
| 259 | const variant = nonEmptyString(rawInput?.variant)?.toLowerCase(); |
| 260 | if (variant === "taskoutput" || variant === "task_output") return true; |
| 261 | const rawOutput = unknownRecord(toolCall.data.rawOutput); |
| 262 | const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase(); |
| 263 | if (outputType === "taskoutput" || outputType === "task_output") return true; |
| 264 | // ACP often titles this tool with the finished subagent label. |
| 265 | if (title.startsWith("[subagent:") && Array.isArray(rawInput?.task_ids)) return true; |
| 266 | return false; |
| 267 | } |
| 268 | |
| 269 | export function isXAiMonitorTool(toolCall: AcpToolCallState): boolean { |
| 270 | const title = titleKey(toolCall); |
| 271 | if (title === "monitor" || title.startsWith("monitor ")) return true; |
| 272 | const rawInput = unknownRecord(toolCall.data.rawInput); |
| 273 | const variant = nonEmptyString(rawInput?.variant)?.toLowerCase(); |
| 274 | if (variant === "monitor") return true; |
| 275 | // Live start ACKs often keep a generic title ("Tool") and only mark the |
| 276 | // structured envelope as Monitor; title rewrite to description must not |
| 277 | // prevent monitor registration / inProgress forcing. |
| 278 | const rawOutput = unknownRecord(toolCall.data.rawOutput); |
| 279 | const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase(); |
| 280 | if (outputType === "monitor") return true; |
| 281 | // Released clients can wrap the start ACK as plain Text and omit the Monitor |
| 282 | // variant. The ACK is still unambiguous and contains the task id required for |
| 283 | // background tracking, even after a generic title is rewritten from |
| 284 | // rawInput.description. |
| 285 | return isXAiMonitorStartAck(xAiToolOutputText(toolCall)); |
| 286 | } |
| 287 | |
| 288 | /** |
| 289 | * A shell command Grok runs in the background (asked for, or moved there after |
| 290 | * its auto-background timeout) acknowledges with |
| 291 | * `{ type: "BackgroundTaskStarted", task_id }` and completes its tool call |
| 292 | * while the process still runs. Like a monitor, it ends only with |
| 293 | * `x.ai/task_completed` for that task id. |
| 294 | */ |
| 295 | function xAiBackgroundShellTaskId(toolCall: AcpToolCallState): string | undefined { |
| 296 | const rawOutput = unknownRecord(toolCall.data.rawOutput); |
| 297 | return nonEmptyString(rawOutput?.type) === "BackgroundTaskStarted" |
| 298 | ? nonEmptyString(rawOutput?.task_id) |
| 299 | : undefined; |
| 300 | } |
| 301 | |
| 302 | export function extractXAiMonitorTaskId(toolCall: AcpToolCallState): string | undefined { |
| 303 | const backgroundShellTaskId = xAiBackgroundShellTaskId(toolCall); |
| 304 | if (backgroundShellTaskId !== undefined) return backgroundShellTaskId; |
| 305 | if (!isXAiMonitorTool(toolCall)) return undefined; |
| 306 | const rawOutput = unknownRecord(toolCall.data.rawOutput); |
| 307 | // Live ACP start ACK: { type: "Monitor", taskId, timeoutMs, persistent } |
| 308 | if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") { |
| 309 | const structured = nonEmptyString(rawOutput.taskId) ?? nonEmptyString(rawOutput.task_id); |
| 310 | if (structured !== undefined) return structured; |
| 311 | } |
| 312 | const output = xAiToolOutputText(toolCall); |
| 313 | if (output !== undefined) { |
| 314 | const fromAck = firstUuidMatch( |
| 315 | output, |
| 316 | new RegExp(`monitor started\\s*\\(\\s*task\\s+(${XAI_UUID_RE})\\b`, "i"), |
| 317 | ); |
| 318 | if (fromAck !== undefined) return fromAck; |
| 319 | } |
| 320 | const rawInput = unknownRecord(toolCall.data.rawInput); |
| 321 | return ( |
| 322 | nonEmptyString(rawInput?.task_id) ?? |
| 323 | nonEmptyString(rawInput?.taskId) ?? |
| 324 | (Array.isArray(rawInput?.task_ids) ? nonEmptyString(rawInput.task_ids[0]) : undefined) |
| 325 | ); |
| 326 | } |
| 327 | |
| 328 | /** |
| 329 | * Live Monitor start ACK `{ type: "Monitor", persistent: true }` (or matching |
| 330 | * rawInput). Persistent monitors should not hold root-turn deferred finalize. |
| 331 | */ |
| 332 | export function isXAiPersistentMonitor(toolCall: AcpToolCallState): boolean { |
| 333 | if (!isXAiMonitorTool(toolCall)) return false; |
| 334 | const rawOutput = unknownRecord(toolCall.data.rawOutput); |
| 335 | if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") { |
| 336 | return rawOutput.persistent === true; |
| 337 | } |
| 338 | const rawInput = unknownRecord(toolCall.data.rawInput); |
| 339 | return rawInput?.persistent === true; |
| 340 | } |
| 341 | |
| 342 | /** |
| 343 | * When get_command_or_subagent_output / TaskOutput completes registered monitor |
| 344 | * task(s), return hydration for each completed background tool id. |
| 345 | */ |
| 346 | export function extractXAiBackgroundTaskCompletion(toolCall: AcpToolCallState): ReadonlyArray<{ |
| 347 | readonly taskId: string; |
| 348 | readonly status: "running" | "completed" | "failed"; |
| 349 | readonly appendOutput: string; |
| 350 | }> { |
| 351 | const rawInput = unknownRecord(toolCall.data.rawInput); |
| 352 | if (!isXAiGetSubagentOutputTool(toolCall, rawInput)) return []; |
| 353 | // Caller matches taskId against registered background tools (monitors). |
| 354 | // Subagent hydration stays on extractXAiAcpSubagentUpdate. |
| 355 | const text = xAiToolOutputText(toolCall); |
| 356 | const status = statusFromGetOutputTool(toolCall, text); |
| 357 | const appendOutput = resultFromGetOutputTool(toolCall, text) ?? ""; |
| 358 | // Prefer the completed identity from the result/header over the request list |
| 359 | // (one call can poll multiple task_ids). |
| 360 | const resultTaskId = resultTaskIdFromGetOutputTool(toolCall, text); |
| 361 | if (resultTaskId !== undefined) { |
| 362 | return [{ taskId: resultTaskId, status, appendOutput }]; |
| 363 | } |
| 364 | const requestIds: string[] = []; |
| 365 | const push = (value: unknown) => { |
| 366 | const id = nonEmptyString(value); |
| 367 | if (id !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(id)) { |
| 368 | requestIds.push(id); |
| 369 | } |
| 370 | }; |
| 371 | if (Array.isArray(rawInput?.task_ids)) { |
| 372 | for (const entry of rawInput.task_ids) push(entry); |
| 373 | } |
| 374 | push(rawInput?.task_id); |
| 375 | push(rawInput?.taskId); |
| 376 | const unique = [...new Set(requestIds)]; |
| 377 | return unique.map((taskId) => ({ taskId, status, appendOutput })); |
| 378 | } |
| 379 | |
| 380 | /** |
| 381 | * A finished kill_command_or_subagent call is the genuine end signal for every |
| 382 | * task id it names: the Grok CLI emits no `x.ai/task_completed` for a killed |
| 383 | * task. A failed kill is not proof the target stopped, so leave the existing |
| 384 | * task state unchanged until a genuine terminal signal arrives. |
| 385 | */ |
| 386 | export function extractXAiKilledBackgroundTasks(toolCall: AcpToolCallState): ReadonlyArray<{ |
| 387 | readonly taskId: string; |
| 388 | readonly status: "running" | "completed" | "failed"; |
| 389 | readonly appendOutput: string; |
| 390 | }> { |
| 391 | const title = titleKey(toolCall); |
| 392 | const rawInput = unknownRecord(toolCall.data.rawInput); |
| 393 | const variant = nonEmptyString(rawInput?.variant)?.toLowerCase(); |
| 394 | if (!title.includes("kill_command_or_subagent") && variant !== "kill") return []; |
| 395 | if (toolCall.status !== "completed") return []; |
| 396 | const taskIds: string[] = []; |
| 397 | const push = (value: unknown) => { |
| 398 | const id = nonEmptyString(value); |
| 399 | if (id !== undefined) taskIds.push(id); |
| 400 | }; |
| 401 | if (Array.isArray(rawInput?.task_ids)) { |
| 402 | for (const entry of rawInput.task_ids) push(entry); |
| 403 | } |
| 404 | push(rawInput?.task_id); |
| 405 | push(rawInput?.taskId); |
| 406 | return [...new Set(taskIds)].map((taskId) => ({ |
| 407 | taskId, |
| 408 | status: "completed" as const, |
| 409 | appendOutput: "", |
| 410 | })); |
| 411 | } |
| 412 | |
| 413 | const XAiTaskLifecycleNotification = Schema.Struct({ |
| 414 | sessionId: Schema.String, |
| 415 | update: Schema.Struct({ |
| 416 | sessionUpdate: Schema.String, |
| 417 | task_id: Schema.optional(Schema.String), |
| 418 | tool_call_id: Schema.optional(Schema.String), |
| 419 | command: Schema.optional(Schema.String), |
| 420 | description: Schema.optional(Schema.NullOr(Schema.String)), |
| 421 | monitor_description: Schema.optional(Schema.NullOr(Schema.String)), |
| 422 | task_snapshot: Schema.optional( |
| 423 | Schema.Struct({ |
| 424 | task_id: Schema.optional(Schema.String), |
| 425 | output: Schema.optional(Schema.String), |
| 426 | exit_code: Schema.optional(Schema.NullOr(Schema.Number)), |
| 427 | command: Schema.optional(Schema.String), |
| 428 | display_command: Schema.optional(Schema.NullOr(Schema.String)), |
| 429 | description: Schema.optional(Schema.NullOr(Schema.String)), |
| 430 | // `TaskKind` in grok-build crates/common/xai-tool-runtime/src/notification.rs. |
| 431 | kind: Schema.optional(Schema.String), |
| 432 | }), |
| 433 | ), |
| 434 | }), |
| 435 | _meta: Schema.optional(Schema.Unknown), |
| 436 | }); |
| 437 | |
| 438 | type XAiTaskLifecycleNotification = typeof XAiTaskLifecycleNotification.Type; |
| 439 | |
| 440 | export interface XAiBackgroundTaskLifecycleMutation { |
| 441 | readonly sessionId: string; |
| 442 | readonly taskId: string; |
| 443 | readonly status: "running" | "completed" | "failed"; |
| 444 | /** Final output from `task_completed.task_snapshot`, when Grok sent one. */ |
| 445 | readonly output?: string; |
| 446 | /** What the task is and how to name it, when Grok said. */ |
| 447 | readonly report?: |
| 448 | | { readonly kind: "command"; readonly label?: string; readonly exitCode?: number } |
| 449 | | { readonly kind: "monitor"; readonly label?: string }; |
| 450 | } |
| 451 | |
| 452 | function xAiBackgroundTaskReport( |
| 453 | update: XAiTaskLifecycleNotification["update"], |
| 454 | ): XAiBackgroundTaskLifecycleMutation["report"] { |
| 455 | const snapshot = update.task_snapshot; |
| 456 | const monitorDescription = nonEmptyString(update.monitor_description ?? undefined); |
| 457 | const displayCommand = nonEmptyString(snapshot?.display_command ?? undefined); |
| 458 | const isMonitor = |
| 459 | snapshot?.kind === "monitor" || |
| 460 | monitorDescription !== undefined || |
| 461 | displayCommand?.startsWith("[monitor]") === true; |
| 462 | // Grok's snapshot defaults `kind` to bash; anything that carries a command is one. |
| 463 | const isCommand = |
| 464 | snapshot?.kind === "bash" || snapshot?.command !== undefined || update.command !== undefined; |
| 465 | if (!isMonitor && !isCommand) return undefined; |
| 466 | const label = |
| 467 | monitorDescription ?? |
| 468 | nonEmptyString(snapshot?.description ?? undefined) ?? |
| 469 | nonEmptyString(update.description ?? undefined) ?? |
| 470 | displayCommand?.replace(/^\[monitor\]\s*/, "") ?? |
| 471 | nonEmptyString(snapshot?.command) ?? |
| 472 | nonEmptyString(update.command); |
| 473 | const named = label === undefined ? {} : { label }; |
| 474 | if (isMonitor) return { kind: "monitor", ...named }; |
| 475 | const exitCode = snapshot?.exit_code; |
| 476 | return { kind: "command", ...named, ...(typeof exitCode === "number" ? { exitCode } : {}) }; |
| 477 | } |
| 478 | |
| 479 | export function xAiBackgroundTaskLifecycleMutation( |
| 480 | notification: XAiTaskLifecycleNotification, |
| 481 | status: "running" | "completed", |
| 482 | ): XAiBackgroundTaskLifecycleMutation | null { |
| 483 | const update = notification.update; |
| 484 | const taskId = |
| 485 | nonEmptyString(update.task_snapshot?.task_id) ?? |
| 486 | nonEmptyString(update.task_id) ?? |
| 487 | nonEmptyString(update.tool_call_id); |
| 488 | if (taskId === undefined) return null; |
| 489 | const exitCode = update.task_snapshot?.exit_code; |
| 490 | const output = update.task_snapshot?.output; |
| 491 | const report = xAiBackgroundTaskReport(update); |
| 492 | return { |
| 493 | sessionId: notification.sessionId, |
| 494 | taskId, |
| 495 | status: |
| 496 | status === "completed" && typeof exitCode === "number" && exitCode !== 0 ? "failed" : status, |
| 497 | ...(output === undefined ? {} : { output }), |
| 498 | ...(report === undefined ? {} : { report }), |
| 499 | }; |
| 500 | } |
| 501 | |
| 502 | /** |
| 503 | * Grok task lifecycle notifications use canonical `x.ai/task_*` methods in |
| 504 | * current builds; older 0.2.x builds used `_x.ai/task_*`. Interactive cancel |
| 505 | * preserves tasks that were already backgrounded (and some older builds could |
| 506 | * background the cancelled foreground command), so both spellings must be |
| 507 | * tracked. Residual frames then buffer instead of waking synthetic |
| 508 | * continuations, and pending-background-work stays truthful until the task |
| 509 | * ends (or the model kills it via kill_command_or_subagent). |
| 510 | */ |
| 511 | export const registerXAiBackgroundTaskTracking = ( |
| 512 | runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">, |
| 513 | apply: (mutation: XAiBackgroundTaskLifecycleMutation) => Effect.Effect<void>, |
| 514 | ): Effect.Effect<void, EffectAcpErrors.AcpError> => |
| 515 | Effect.forEach( |
| 516 | [ |
| 517 | ["x.ai/task_backgrounded", "running"], |
| 518 | ["_x.ai/task_backgrounded", "running"], |
| 519 | ["x.ai/task_completed", "completed"], |
| 520 | ["_x.ai/task_completed", "completed"], |
| 521 | ] as const, |
| 522 | ([method, status]) => |
| 523 | runtime.handleExtNotification(method, XAiTaskLifecycleNotification, (notification) => { |
| 524 | const mutation = xAiBackgroundTaskLifecycleMutation(notification, status); |
| 525 | return mutation === null ? Effect.void : apply(mutation); |
| 526 | }), |
| 527 | { discard: true }, |
| 528 | ); |
| 529 | |
| 530 | /** |
| 531 | * A background subagent's end, sent on its PARENT session as |
| 532 | * `{ sessionUpdate: "subagent_finished", child_session_id, status, output, error }` |
| 533 | * (grok-build `crates/codegen/xai-grok-shell/src/extensions/notification.rs` |
| 534 | * `SessionUpdate::SubagentFinished`). `status` is exactly "completed", |
| 535 | * "failed" or "cancelled" (`SubagentResult::status()` in |
| 536 | * `crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs`); |
| 537 | * `output` is the final text of a completed subagent and `error` the message |
| 538 | * of a failed one. Grok follows it with its own `subagent-completed-<id>` wake |
| 539 | * turn when `will_wake` is true. |
| 540 | */ |
| 541 | export interface XAiSubagentFinishedNotice { |
| 542 | readonly sessionId: string; |
| 543 | readonly childSessionId: string; |
| 544 | readonly status: "completed" | "failed" | "cancelled"; |
| 545 | readonly result: string | null; |
| 546 | } |
| 547 | |
| 548 | /** Null for other session updates, and for a status Grok does not define. */ |
| 549 | export function xAiSubagentFinishedNotice( |
| 550 | notification: XAiSessionUpdateNotification, |
| 551 | ): XAiSubagentFinishedNotice | null { |
| 552 | const update = notification.update; |
| 553 | const childSessionId = nonEmptyString(update.child_session_id); |
| 554 | if (update.sessionUpdate !== "subagent_finished" || childSessionId === undefined) return null; |
| 555 | const status = update.status; |
| 556 | if (status !== "completed" && status !== "failed" && status !== "cancelled") return null; |
| 557 | const text = status === "completed" ? update.output : update.error; |
| 558 | return { |
| 559 | sessionId: notification.sessionId, |
| 560 | childSessionId, |
| 561 | status, |
| 562 | result: nonEmptyString(text ?? undefined) ?? null, |
| 563 | }; |
| 564 | } |
| 565 | |
| 566 | /** |
| 567 | * Finishes background subagents from Grok's `subagent_finished`. These arrive |
| 568 | * on the session notification methods that also carry turn completion; a |
| 569 | * runtime from {@link makeXAiPromptCompletionRuntime} settles its prompt from |
| 570 | * each notification before passing it to this handler. |
| 571 | */ |
| 572 | export const registerXAiSubagentFinished = ( |
| 573 | runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">, |
| 574 | finish: (notice: XAiSubagentFinishedNotice) => Effect.Effect<void>, |
| 575 | ): Effect.Effect<void> => |
| 576 | Effect.forEach( |
| 577 | xAiSessionNotificationMethods, |
| 578 | (method) => |
| 579 | runtime.handleExtNotification(method, XAiSessionUpdateNotification, (notification) => { |
| 580 | const notice = xAiSubagentFinishedNotice(notification); |
| 581 | return notice === null ? Effect.void : finish(notice); |
| 582 | }), |
| 583 | { discard: true }, |
| 584 | ); |
| 585 | |
| 586 | /** |
| 587 | * Grok ACP often sends `title: "Tool"` even when rawInput has a useful |
| 588 | * description or variant (especially Monitor). Prefer description/variant so |
| 589 | * the timeline matches Claude-style tool names rather than a generic label. |
| 590 | */ |
| 591 | export function isGenericAcpToolTitle(title: string | undefined): boolean { |
| 592 | const key = (title ?? "").trim().toLowerCase(); |
| 593 | return key.length === 0 || key === "tool" || key === "tool call" || key === "terminal"; |
| 594 | } |
| 595 | |
| 596 | export function resolveXAiAcpToolTitle(toolCall: AcpToolCallState): string | undefined { |
| 597 | if (!isGenericAcpToolTitle(toolCall.title)) { |
| 598 | return nonEmptyString(toolCall.title); |
| 599 | } |
| 600 | const rawInput = unknownRecord(toolCall.data.rawInput); |
| 601 | const description = nonEmptyString(rawInput?.description); |
| 602 | const variant = nonEmptyString(rawInput?.variant)?.toLowerCase(); |
| 603 | if (description !== undefined) { |
| 604 | if (variant === "monitor" && !description.toLowerCase().startsWith("monitor")) { |
| 605 | return `Monitor: ${description}`; |
| 606 | } |
| 607 | return description; |
| 608 | } |
| 609 | if (variant === "monitor") return "Monitor"; |
| 610 | if (variant === "task" || variant === "cursortask" || variant === "spawn_subagent") { |
| 611 | return "Task"; |
| 612 | } |
| 613 | if (variant === "taskoutput" || variant === "task_output") return "Task output"; |
| 614 | if (variant !== undefined) { |
| 615 | return variant; |
| 616 | } |
| 617 | const command = |
| 618 | nonEmptyString(rawInput?.command) ?? |
| 619 | nonEmptyString(unknownRecord(toolCall.data.rawOutput)?.command); |
| 620 | if (command !== undefined) { |
| 621 | return command.length > 80 ? `${command.slice(0, 77)}...` : command; |
| 622 | } |
| 623 | return nonEmptyString(toolCall.title); |
| 624 | } |
| 625 | |
| 626 | /** |
| 627 | * Normalize Grok ACP tool presentation and monitor lifecycle: |
| 628 | * - replace generic titles ("Tool") with description / variant labels |
| 629 | * - structured Monitor start ACK stays running |
| 630 | * - text start ACK stays running |
| 631 | * - structured Bash results with exit_code are terminal, unless Grok itself |
| 632 | * still reports the tool running. Grok 1.0.41 streams every monitor tick as |
| 633 | * `status: "in_progress"` with a Bash-shaped rawOutput whose exit_code is |
| 634 | * already 0, and never sends a completed status for the monitor; its end |
| 635 | * arrives as `_x.ai/task_completed`. |
| 636 | */ |
| 637 | export function normalizeXAiAcpToolCallState(toolCall: AcpToolCallState): AcpToolCallState { |
| 638 | const resolvedTitle = resolveXAiAcpToolTitle(toolCall); |
| 639 | const withTitle = |
| 640 | resolvedTitle !== undefined && resolvedTitle !== toolCall.title |
| 641 | ? { ...toolCall, title: resolvedTitle } |
| 642 | : toolCall; |
| 643 | |
| 644 | const rawOutput = unknownRecord(withTitle.data.rawOutput); |
| 645 | const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase(); |
| 646 | const reportedRunning = withTitle.status === "inProgress" || withTitle.status === "pending"; |
| 647 | if (outputType === "bash" && !reportedRunning) { |
| 648 | // Same key set as the adapter's command projection (commandExitCode). |
| 649 | const exitCode = ["exit_code", "exitCode", "code"] |
| 650 | .map((key) => rawOutput?.[key]) |
| 651 | .find((value) => typeof value === "number" && Number.isInteger(value)); |
| 652 | if (typeof exitCode === "number") { |
| 653 | if (exitCode === 0 && (xAiToolOutputText(withTitle)?.trim().length ?? 0) === 0) { |
| 654 | return { ...withTitle, status: "inProgress" }; |
| 655 | } |
| 656 | return { |
| 657 | ...withTitle, |
| 658 | status: exitCode === 0 ? "completed" : "failed", |
| 659 | }; |
| 660 | } |
| 661 | } |
| 662 | if (xAiBackgroundShellTaskId(withTitle) !== undefined) { |
| 663 | // Start acknowledgement only; the command still runs in the background. |
| 664 | return { ...withTitle, status: "inProgress" }; |
| 665 | } |
| 666 | if (!isXAiMonitorTool(withTitle)) { |
| 667 | return withTitle; |
| 668 | } |
| 669 | if (outputType === "monitor") { |
| 670 | // Start registration only; process still running in the background. |
| 671 | return { ...withTitle, status: "inProgress" }; |
| 672 | } |
| 673 | const output = xAiToolOutputText(withTitle); |
| 674 | if (withTitle.status === "completed" && isXAiMonitorStartAck(output)) { |
| 675 | return { ...withTitle, status: "inProgress" }; |
| 676 | } |
| 677 | return withTitle; |
| 678 | } |
| 679 | |
| 680 | /** |
| 681 | * Parse Grok synthetic monitor traffic that arrives as root text chunks |
| 682 | * (`<monitor-event>` lines, batched `<monitor>` event blocks, and |
| 683 | * "Monitor ... ended" reminders). Coalesced chunks may contain several events; |
| 684 | * return every match so later progress / end notices are not dropped. |
| 685 | */ |
| 686 | export function extractXAiAcpBackgroundToolMutation( |
| 687 | text: string, |
| 688 | ): ReadonlyArray<XAiAcpBackgroundToolMutation> { |
| 689 | const mutations: XAiAcpBackgroundToolMutation[] = []; |
| 690 | |
| 691 | const eventRe = new RegExp( |
| 692 | `<monitor-event\\s+task_id=["']?(${XAI_UUID_RE})["']?\\s*>\\s*([\\s\\S]*?)\\s*</monitor-event>`, |
| 693 | "gi", |
| 694 | ); |
| 695 | for (const eventMatch of text.matchAll(eventRe)) { |
| 696 | if (eventMatch[1] === undefined) continue; |
| 697 | const line = (eventMatch[2] ?? "").trim(); |
| 698 | mutations.push({ |
| 699 | taskId: eventMatch[1], |
| 700 | status: "running", |
| 701 | appendOutput: line.length > 0 ? `${line}\n` : "", |
| 702 | }); |
| 703 | } |
| 704 | |
| 705 | // Batched form: "N monitor events from 1 monitor ...:\n<monitor |
| 706 | // description="..." task_id="...">\n[1] line\n</monitor>". Still a running |
| 707 | // monitor; must track and filter like single-event chatter. |
| 708 | const batchedRe = new RegExp( |
| 709 | `<monitor\\s[^>]*task_id=["']?(${XAI_UUID_RE})["']?[^>]*>\\s*([\\s\\S]*?)\\s*</monitor>`, |
| 710 | "gi", |
| 711 | ); |
| 712 | for (const batchedMatch of text.matchAll(batchedRe)) { |
| 713 | if (batchedMatch[1] === undefined) continue; |
| 714 | const lines = (batchedMatch[2] ?? "").trim(); |
| 715 | mutations.push({ |
| 716 | taskId: batchedMatch[1], |
| 717 | status: "running", |
| 718 | appendOutput: lines.length > 0 ? `${lines}\n` : "", |
| 719 | }); |
| 720 | } |
| 721 | |
| 722 | const endedRe = new RegExp(`Monitor\\s+["']?(${XAI_UUID_RE})["']?\\s+ended`, "gi"); |
| 723 | for (const endedMatch of text.matchAll(endedRe)) { |
| 724 | if (endedMatch[1] === undefined) continue; |
| 725 | // Only the "Monitor … ended …" clause is outcome text. Description/output |
| 726 | // after the header can contain words like "error" without meaning failure. |
| 727 | const outcomeClause = (endedMatch[0] ?? "").trim(); |
| 728 | const afterHeader = text.slice((endedMatch.index ?? 0) + endedMatch[0].length); |
| 729 | const outcomeTail = afterHeader.split(/\n/)[0] ?? ""; |
| 730 | const outcome = `${outcomeClause}${outcomeTail}`; |
| 731 | const summary = text.replace(/<\/?system-reminder>/gi, "").trim(); |
| 732 | mutations.push({ |
| 733 | taskId: endedMatch[1], |
| 734 | status: |
| 735 | /exited\s*\(\s*code\s*0\s*\)/i.test(outcome) || /ended cleanly/i.test(outcome) |
| 736 | ? "completed" |
| 737 | : /exited|failed|error|signal/i.test(outcome) |
| 738 | ? "failed" |
| 739 | : "completed", |
| 740 | appendOutput: summary.length > 0 ? `${summary}\n` : "Monitor ended.\n", |
| 741 | }); |
| 742 | } |
| 743 | |
| 744 | return mutations; |
| 745 | } |
| 746 | |
| 747 | /** |
| 748 | * Parse Grok's root-session subagent end reminder ("Background subagent |
| 749 | * "<uuid>" (general-purpose: "...") completed successfully."). The agent is |
| 750 | * free to skip get_command_or_subagent_output hydration, so this reminder can |
| 751 | * be the only terminal signal for the subagent row; a row stuck on running |
| 752 | * holds deferred finalize (and the whole run) open until harness timeout. |
| 753 | */ |
| 754 | function stripLeadingBalancedParenGroup(text: string): string { |
| 755 | const trimmed = text.replace(/^\s+/, ""); |
| 756 | if (!trimmed.startsWith("(")) return trimmed; |
| 757 | let depth = 0; |
| 758 | for (let index = 0; index < trimmed.length; index += 1) { |
| 759 | const char = trimmed[index]; |
| 760 | if (char === "(") depth += 1; |
| 761 | else if (char === ")") { |
| 762 | depth -= 1; |
| 763 | if (depth === 0) return trimmed.slice(index + 1).replace(/^\s+/, ""); |
| 764 | } |
| 765 | } |
| 766 | return trimmed; |
| 767 | } |
| 768 | |
| 769 | export function extractXAiAcpSubagentEndNotice(text: string): XAiAcpSubagentEndNotice | undefined { |
| 770 | const match = text.match(new RegExp(`Background subagent\\s+["']?(${XAI_UUID_RE})["']?`, "i")); |
| 771 | if (match?.[1] === undefined) return undefined; |
| 772 | // Skip the parenthesized descriptor (subagent type and title) so its words |
| 773 | // cannot masquerade as the outcome verb. Balance parens so nested titles |
| 774 | // like `(general-purpose: "Check (backend) failed")` do not leak. |
| 775 | const tail = stripLeadingBalancedParenGroup(text.slice((match.index ?? 0) + match[0].length)); |
| 776 | if (/completed successfully/i.test(tail)) { |
| 777 | return { childSessionId: match[1], status: "completed" }; |
| 778 | } |
| 779 | if (/\b(failed|errored|error|crashed|cancelled|canceled|interrupted|timed out)\b/i.test(tail)) { |
| 780 | return { childSessionId: match[1], status: "failed" }; |
| 781 | } |
| 782 | if (/\b(completed|finished|ended)\b/i.test(tail)) { |
| 783 | return { childSessionId: match[1], status: "completed" }; |
| 784 | } |
| 785 | return undefined; |
| 786 | } |
| 787 | |
| 788 | function taskIdsFromGetOutputTool( |
| 789 | toolCall: AcpToolCallState, |
| 790 | rawInput: Record<string, unknown> | undefined, |
| 791 | ): ReadonlyArray<string> { |
| 792 | const ids: string[] = []; |
| 793 | const push = (value: unknown) => { |
| 794 | const text = nonEmptyString(value); |
| 795 | if (text !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(text)) { |
| 796 | ids.push(text); |
| 797 | } |
| 798 | }; |
| 799 | if (Array.isArray(rawInput?.task_ids)) { |
| 800 | for (const entry of rawInput.task_ids) push(entry); |
| 801 | } |
| 802 | push(rawInput?.task_id); |
| 803 | push(rawInput?.taskId); |
| 804 | const resultId = resultTaskIdFromGetOutputTool(toolCall, xAiToolOutputText(toolCall)); |
| 805 | if (resultId !== undefined) ids.push(resultId); |
| 806 | return [...new Set(ids)]; |
| 807 | } |
| 808 | |
| 809 | /** Identity of the task the get_output envelope actually reports (not the request list). */ |
| 810 | function resultTaskIdFromGetOutputTool( |
| 811 | toolCall: AcpToolCallState, |
| 812 | output: string | undefined, |
| 813 | ): string | undefined { |
| 814 | const rawOutput = unknownRecord(toolCall.data.rawOutput); |
| 815 | const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result); |
| 816 | const structured = |
| 817 | nonEmptyString(result?.task_id) ?? |
| 818 | nonEmptyString(result?.taskId) ?? |
| 819 | nonEmptyString(rawOutput?.task_id) ?? |
| 820 | nonEmptyString(rawOutput?.taskId); |
| 821 | if (structured !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(structured)) { |
| 822 | return structured; |
| 823 | } |
| 824 | if (output === undefined) return undefined; |
| 825 | const taskHeader = firstUuidMatch( |
| 826 | output, |
| 827 | new RegExp(`===\\s*Task\\s+(${XAI_UUID_RE})\\s*===`, "i"), |
| 828 | ); |
| 829 | return taskHeader; |
| 830 | } |
| 831 | |
| 832 | function resultFromGetOutputTool( |
| 833 | toolCall: AcpToolCallState, |
| 834 | output: string | undefined, |
| 835 | ): string | null { |
| 836 | const rawOutput = unknownRecord(toolCall.data.rawOutput); |
| 837 | const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result); |
| 838 | const structured = |
| 839 | nonEmptyString(result?.output) ?? |
| 840 | nonEmptyString(result?.text) ?? |
| 841 | decodeByteText(result?.output); |
| 842 | if (structured !== undefined) { |
| 843 | // Drop trailing machine meta blocks when present. |
| 844 | const cleaned = structured |
| 845 | .replace(/<subagent_meta>[\s\S]*?<\/subagent_meta>/gi, "") |
| 846 | .replace(/<subagent_result>[\s\S]*?<\/subagent_result>/gi, "") |
| 847 | .trim(); |
| 848 | if (cleaned.length > 0) return cleaned; |
| 849 | return null; |
| 850 | } |
| 851 | if (output === undefined) return null; |
| 852 | const marker = output.match(/=== Output ===\s*([\s\S]*)$/i); |
| 853 | const body = (marker?.[1] ?? output).trim(); |
| 854 | return body.length > 0 ? body : null; |
| 855 | } |
| 856 | |
| 857 | function statusFromGetOutputTool( |
| 858 | toolCall: AcpToolCallState, |
| 859 | output: string | undefined, |
| 860 | ): "running" | "completed" | "failed" { |
| 861 | if (toolCall.status === "failed") return "failed"; |
| 862 | const rawOutput = unknownRecord(toolCall.data.rawOutput); |
| 863 | const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result); |
| 864 | const structuredStatus = nonEmptyString(result?.status)?.toLowerCase(); |
| 865 | if (structuredStatus === "completed" || structuredStatus === "success") return "completed"; |
| 866 | if (structuredStatus === "failed" || structuredStatus === "error") return "failed"; |
| 867 | if (structuredStatus === "running" || structuredStatus === "pending") return "running"; |
| 868 | if (typeof result?.exit_code === "number") { |
| 869 | return result.exit_code === 0 ? "completed" : "failed"; |
| 870 | } |
| 871 | if (output !== undefined) { |
| 872 | if (/Status:\s*failed/i.test(output) || /Exit Code:\s*-?(?!0\b)\d+/i.test(output)) { |
| 873 | return "failed"; |
| 874 | } |
| 875 | if (/Status:\s*completed/i.test(output) || /Status:\s*success/i.test(output)) { |
| 876 | return "completed"; |
| 877 | } |
| 878 | if (/Status:\s*running/i.test(output) || /Status:\s*pending/i.test(output)) { |
| 879 | return "running"; |
| 880 | } |
| 881 | } |
| 882 | // A completed get_output tool call only means the poll RPC returned. Without |
| 883 | // a task-level terminal status or exit code, the requested task is still |
| 884 | // running and must remain registered for subsequent polling. |
| 885 | return "running"; |
| 886 | } |
| 887 | |
| 888 | /** |
| 889 | * Recognizes Grok Task / spawn_subagent envelopes and get_command hydration |
| 890 | * without teaching the generic ACP adapter about xAI tool names. |
| 891 | * |
| 892 | * Current Grok CLI returns spawn_subagent immediately with a background ACK |
| 893 | * (`subagent_id: ...`). That must stay `running` until child work finishes or |
| 894 | * get_command_or_subagent_output reports a terminal status. |
| 895 | */ |
| 896 | export function extractXAiAcpSubagentUpdate( |
| 897 | toolCall: AcpToolCallState, |
| 898 | ): XAiAcpSubagentUpdate | undefined { |
| 899 | const rawInput = unknownRecord(toolCall.data.rawInput); |
| 900 | const output = xAiToolOutputText(toolCall); |
| 901 | |
| 902 | if (isXAiGetSubagentOutputTool(toolCall, rawInput)) { |
| 903 | const taskIds = taskIdsFromGetOutputTool(toolCall, rawInput); |
| 904 | // Prefer the completed task identity from the result/header over the first |
| 905 | // requested task_ids entry (one call can poll multiple task_ids). |
| 906 | const childSessionId = |
| 907 | resultTaskIdFromGetOutputTool(toolCall, output) ?? |
| 908 | taskIds[0] ?? |
| 909 | extractXAiChildSessionId(output); |
| 910 | if (childSessionId === null) return undefined; |
| 911 | // Prefer the durable subagent row as the completion surface (Claude/Codex |
| 912 | // style). Suppress the noisy TaskOutput tool card; monitor hydration is |
| 913 | // handled separately via extractBackgroundTaskCompletion. |
| 914 | return { |
| 915 | nativeTaskId: toolCall.toolCallId, |
| 916 | prompt: "", |
| 917 | title: null, |
| 918 | model: null, |
| 919 | status: statusFromGetOutputTool(toolCall, output), |
| 920 | childSessionId, |
| 921 | result: resultFromGetOutputTool(toolCall, output), |
| 922 | suppressNormalTool: true, |
| 923 | }; |
| 924 | } |
| 925 | |
| 926 | if (!isXAiSpawnOrTaskTool(toolCall, rawInput)) return undefined; |
| 927 | |
| 928 | const childSessionId = extractXAiChildSessionId(output); |
| 929 | const asyncSpawnAck = isXAiAsyncSpawnAck(output); |
| 930 | const legacyResult = |
| 931 | output |
| 932 | ?.replace(new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"), "\n") |
| 933 | .replace( |
| 934 | new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"), |
| 935 | "\n", |
| 936 | ) |
| 937 | .trim() || null; |
| 938 | |
| 939 | let status: "running" | "completed" | "failed"; |
| 940 | if (toolCall.status === "failed") { |
| 941 | status = "failed"; |
| 942 | } else if (asyncSpawnAck) { |
| 943 | // Spawn RPC completed, but the child is still running. |
| 944 | status = "running"; |
| 945 | } else if (toolCall.status === "completed") { |
| 946 | status = "completed"; |
| 947 | } else { |
| 948 | status = "running"; |
| 949 | } |
| 950 | |
| 951 | return { |
| 952 | nativeTaskId: toolCall.toolCallId, |
| 953 | prompt: nonEmptyString(rawInput?.prompt) ?? "", |
| 954 | title: nonEmptyString(rawInput?.description) ?? null, |
| 955 | model: nonEmptyString(rawInput?.model) ?? null, |
| 956 | status, |
| 957 | childSessionId, |
| 958 | result: asyncSpawnAck ? null : legacyResult, |
| 959 | suppressNormalTool: true, |
| 960 | }; |
| 961 | } |
| 962 | |
| 963 | const XAiAskUserQuestionOption = Schema.Struct({ |
| 964 | label: Schema.String, |
| 965 | description: Schema.optional(Schema.String), |
| 966 | preview: Schema.optional(Schema.String), |
| 967 | id: Schema.optional(Schema.String), |
| 968 | }); |
| 969 | |
| 970 | const XAiAskUserQuestion = Schema.Struct({ |
| 971 | id: Schema.optional(Schema.String), |
| 972 | question: Schema.String, |
| 973 | options: Schema.Array(XAiAskUserQuestionOption), |
| 974 | multiSelect: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 975 | }); |
| 976 | |
| 977 | const XAiAskUserQuestionParams = Schema.Struct({ |
| 978 | sessionId: Schema.String, |
| 979 | toolCallId: Schema.String, |
| 980 | questions: Schema.Array(XAiAskUserQuestion), |
| 981 | mode: Schema.Literals(["default", "plan"]), |
| 982 | }); |
| 983 | |
| 984 | const XAiWrappedAskUserQuestionParams = Schema.Struct({ |
| 985 | method: Schema.Literals(["x.ai/ask_user_question", "_x.ai/ask_user_question"]), |
| 986 | params: XAiAskUserQuestionParams, |
| 987 | }); |
| 988 | |
| 989 | export const XAiAskUserQuestionRequest = Schema.Union([ |
| 990 | XAiAskUserQuestionParams, |
| 991 | XAiWrappedAskUserQuestionParams, |
| 992 | ]); |
| 993 | |
| 994 | type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type; |
| 995 | type XAiAskUserQuestionRequest = typeof XAiAskUserQuestionRequest.Type; |
| 996 | |
| 997 | // --------------------------------------------------------------------------- |
| 998 | // x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window) |
| 999 | // --------------------------------------------------------------------------- |
| 1000 | |
| 1001 | const XAiExitPlanModeParams = Schema.Struct({ |
| 1002 | sessionId: Schema.String, |
| 1003 | toolCallId: Schema.String, |
| 1004 | planContent: Schema.optional(Schema.NullOr(Schema.String)), |
| 1005 | }); |
| 1006 | |
| 1007 | const XAiWrappedExitPlanModeParams = Schema.Struct({ |
| 1008 | method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]), |
| 1009 | params: XAiExitPlanModeParams, |
| 1010 | }); |
| 1011 | |
| 1012 | export const XAiExitPlanModeRequest = Schema.Union([ |
| 1013 | XAiExitPlanModeParams, |
| 1014 | XAiWrappedExitPlanModeParams, |
| 1015 | ]); |
| 1016 | |
| 1017 | type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type; |
| 1018 | type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type; |
| 1019 | |
| 1020 | function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanModeRequestParams { |
| 1021 | return "params" in params ? params.params : params; |
| 1022 | } |
| 1023 | |
| 1024 | /** Empty-state copy when Grok exits plan mode without a plan file. */ |
| 1025 | export const XAI_EMPTY_PLAN_MARKDOWN = |
| 1026 | "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)"; |
| 1027 | |
| 1028 | export function extractXAiExitPlanMarkdown( |
| 1029 | params: XAiExitPlanModeRequest, |
| 1030 | fallback?: string | null, |
| 1031 | ): string { |
| 1032 | const content = unwrapExitPlanModeParams(params).planContent; |
| 1033 | const fromRequest = typeof content === "string" ? trimmed(content) : undefined; |
| 1034 | if (fromRequest) { |
| 1035 | return fromRequest; |
| 1036 | } |
| 1037 | const fromFallback = fallback?.trim(); |
| 1038 | if (fromFallback && fromFallback.length > 0) { |
| 1039 | return fromFallback; |
| 1040 | } |
| 1041 | return XAI_EMPTY_PLAN_MARKDOWN; |
| 1042 | } |
| 1043 | |
| 1044 | export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes"; |
| 1045 | |
| 1046 | export interface XAiExitPlanModeResponse { |
| 1047 | readonly outcome: XAiExitPlanModeOutcome; |
| 1048 | readonly feedback?: string; |
| 1049 | } |
| 1050 | |
| 1051 | /** |
| 1052 | * Client captured the plan for T3's proposed-plan card. Abandon the native |
| 1053 | * Grok plan-approval gate so the turn unblocks; the user implements via T3 UI. |
| 1054 | */ |
| 1055 | export function makeXAiExitPlanModeCapturedResponse(feedback?: string): XAiExitPlanModeResponse { |
| 1056 | return { |
| 1057 | outcome: "abandoned", |
| 1058 | feedback: |
| 1059 | feedback ?? |
| 1060 | "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", |
| 1061 | }; |
| 1062 | } |
| 1063 | |
| 1064 | function normalizeFsPath(value: string): string { |
| 1065 | return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); |
| 1066 | } |
| 1067 | |
| 1068 | function pathHasTraversalSegment(normalized: string): boolean { |
| 1069 | return normalized.split("/").includes(".."); |
| 1070 | } |
| 1071 | |
| 1072 | function addGrokSessionPrefix( |
| 1073 | prefixes: Set<string>, |
| 1074 | homeOrRoot: string, |
| 1075 | nestedGrokDir: boolean, |
| 1076 | ): void { |
| 1077 | const root = normalizeFsPath(homeOrRoot); |
| 1078 | if (!root) { |
| 1079 | return; |
| 1080 | } |
| 1081 | prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`); |
| 1082 | } |
| 1083 | |
| 1084 | /** Injected host bits so these helpers stay off `process.platform` / `process.env`. */ |
| 1085 | export interface GrokPlanPathHost { |
| 1086 | readonly platform: NodeJS.Platform; |
| 1087 | readonly environment: NodeJS.ProcessEnv; |
| 1088 | readonly homeDirectory: string; |
| 1089 | } |
| 1090 | |
| 1091 | function grokPlanSessionPrefixes(host: GrokPlanPathHost): ReadonlySet<string> { |
| 1092 | const { environment } = host; |
| 1093 | const prefixes = new Set<string>(); |
| 1094 | addGrokSessionPrefix(prefixes, host.homeDirectory, true); |
| 1095 | addGrokSessionPrefix(prefixes, "~", true); |
| 1096 | addGrokSessionPrefix(prefixes, environment.HOME ?? "", true); |
| 1097 | addGrokSessionPrefix(prefixes, environment.USERPROFILE ?? "", true); |
| 1098 | // ACP mock and isolated Grok spawns use a HOME that is not the server process home. |
| 1099 | addGrokSessionPrefix(prefixes, "/tmp/mock-home", true); |
| 1100 | const grokHome = environment.GROK_HOME ?? ""; |
| 1101 | addGrokSessionPrefix(prefixes, grokHome, false); |
| 1102 | addGrokSessionPrefix(prefixes, grokHome, true); |
| 1103 | return prefixes; |
| 1104 | } |
| 1105 | |
| 1106 | const CANONICAL_HOME_GROK_SESSION_PATH = |
| 1107 | /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/; |
| 1108 | const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp( |
| 1109 | CANONICAL_HOME_GROK_SESSION_PATH.source, |
| 1110 | "i", |
| 1111 | ); |
| 1112 | |
| 1113 | /** |
| 1114 | * True when a path is Grok's session plan file under a Grok home |
| 1115 | * (`~/.grok/sessions/.../plan.md`, `$HOME/.grok/sessions/...`, or `$GROK_HOME/sessions/...`). |
| 1116 | * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md |
| 1117 | * or a repo-local `.grok/sessions/.../plan.md`). |
| 1118 | */ |
| 1119 | export function isGrokPlanMarkdownPath( |
| 1120 | path: string | undefined | null, |
| 1121 | host: GrokPlanPathHost, |
| 1122 | ): boolean { |
| 1123 | if (typeof path !== "string") { |
| 1124 | return false; |
| 1125 | } |
| 1126 | const normalized = path.trim().replace(/\\/g, "/"); |
| 1127 | const win32 = host.platform === "win32"; |
| 1128 | const haystack = win32 ? normalized.toLowerCase() : normalized; |
| 1129 | if ( |
| 1130 | normalized.length === 0 || |
| 1131 | !haystack.endsWith("/plan.md") || |
| 1132 | pathHasTraversalSegment(normalized) |
| 1133 | ) { |
| 1134 | return false; |
| 1135 | } |
| 1136 | for (const prefix of grokPlanSessionPrefixes(host)) { |
| 1137 | const needle = win32 ? prefix.toLowerCase() : prefix; |
| 1138 | if (!haystack.startsWith(needle)) { |
| 1139 | continue; |
| 1140 | } |
| 1141 | const rest = haystack.slice(needle.length); |
| 1142 | // Session layout: <home>/.grok/sessions/<encoded-cwd>/<session-id>/plan.md |
| 1143 | if (rest !== "plan.md" && rest.endsWith("plan.md")) { |
| 1144 | return true; |
| 1145 | } |
| 1146 | } |
| 1147 | return ( |
| 1148 | win32 ? CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH : CANONICAL_HOME_GROK_SESSION_PATH |
| 1149 | ).test(haystack); |
| 1150 | } |
| 1151 | |
| 1152 | /** |
| 1153 | * Extract plan markdown from a Grok write/edit tool call targeting plan.md. |
| 1154 | * Used so T3 can show the plan while plan mode is still active (before exit). |
| 1155 | */ |
| 1156 | export function extractGrokPlanMarkdownFromToolCallData( |
| 1157 | data: Record<string, unknown> | undefined, |
| 1158 | host: GrokPlanPathHost, |
| 1159 | ): string | undefined { |
| 1160 | if (!data) { |
| 1161 | return undefined; |
| 1162 | } |
| 1163 | |
| 1164 | let sawPlanWrite = false; |
| 1165 | const takePlanText = ( |
| 1166 | value: string | undefined, |
| 1167 | filePath: string | undefined, |
| 1168 | ): string | undefined => { |
| 1169 | if (!isGrokPlanMarkdownPath(filePath, host) || value === undefined) { |
| 1170 | return undefined; |
| 1171 | } |
| 1172 | sawPlanWrite = true; |
| 1173 | const trimmed = value.trim(); |
| 1174 | return trimmed.length > 0 ? trimmed : undefined; |
| 1175 | }; |
| 1176 | |
| 1177 | const rawInput = data.rawInput; |
| 1178 | if (isRecord(rawInput)) { |
| 1179 | const filePath = |
| 1180 | (typeof rawInput.file_path === "string" ? rawInput.file_path : undefined) ?? |
| 1181 | (typeof rawInput.path === "string" ? rawInput.path : undefined); |
| 1182 | const content = typeof rawInput.content === "string" ? rawInput.content : undefined; |
| 1183 | const fromRaw = takePlanText(content, filePath); |
| 1184 | if (fromRaw !== undefined) { |
| 1185 | return fromRaw; |
| 1186 | } |
| 1187 | } |
| 1188 | |
| 1189 | const content = data.content; |
| 1190 | if (Array.isArray(content)) { |
| 1191 | for (const block of content) { |
| 1192 | if (!isRecord(block) || block.type !== "diff") { |
| 1193 | continue; |
| 1194 | } |
| 1195 | const path = typeof block.path === "string" ? block.path : undefined; |
| 1196 | const newText = typeof block.newText === "string" ? block.newText : undefined; |
| 1197 | const fromDiff = takePlanText(newText, path); |
| 1198 | if (fromDiff !== undefined) { |
| 1199 | return fromDiff; |
| 1200 | } |
| 1201 | } |
| 1202 | } |
| 1203 | |
| 1204 | return sawPlanWrite ? "" : undefined; |
| 1205 | } |
| 1206 | |
| 1207 | function isRecord(value: unknown): value is Record<string, unknown> { |
| 1208 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 1209 | } |
| 1210 | |
| 1211 | function trimmed(value: string | undefined): string | undefined { |
| 1212 | const text = value?.trim(); |
| 1213 | return text && text.length > 0 ? text : undefined; |
| 1214 | } |
| 1215 | |
| 1216 | function unwrapAskUserQuestionParams( |
| 1217 | params: XAiAskUserQuestionRequest, |
| 1218 | ): XAiAskUserQuestionRequestParams { |
| 1219 | return "params" in params ? params.params : params; |
| 1220 | } |
| 1221 | |
| 1222 | export function extractXAiAskUserQuestions( |
| 1223 | params: XAiAskUserQuestionRequest, |
| 1224 | ): ReadonlyArray<UserInputQuestion> { |
| 1225 | return unwrapAskUserQuestionParams(params).questions.map((question) => ({ |
| 1226 | id: question.id ?? question.question, |
| 1227 | header: "Question", |
| 1228 | question: question.question, |
| 1229 | multiSelect: question.multiSelect === true, |
| 1230 | options: |
| 1231 | question.options.length > 0 |
| 1232 | ? question.options.map((option) => ({ |
| 1233 | label: option.label, |
| 1234 | description: option.description ?? option.label, |
| 1235 | })) |
| 1236 | : [{ label: "OK", description: "Continue" }], |
| 1237 | })); |
| 1238 | } |
| 1239 | |
| 1240 | export function extractXAiAskUserQuestionIdentity(params: XAiAskUserQuestionRequest): { |
| 1241 | readonly sessionId: string; |
| 1242 | readonly toolCallId: string; |
| 1243 | } { |
| 1244 | const unwrapped = unwrapAskUserQuestionParams(params); |
| 1245 | return { |
| 1246 | sessionId: unwrapped.sessionId, |
| 1247 | toolCallId: unwrapped.toolCallId, |
| 1248 | }; |
| 1249 | } |
| 1250 | |
| 1251 | interface XAiAskUserQuestionAnnotation { |
| 1252 | readonly preview?: string; |
| 1253 | readonly notes?: string; |
| 1254 | } |
| 1255 | |
| 1256 | interface XAiAskUserQuestionAcceptedResponse { |
| 1257 | readonly outcome: "accepted"; |
| 1258 | readonly answers: Record<string, ReadonlyArray<string>>; |
| 1259 | readonly annotations?: Record<string, XAiAskUserQuestionAnnotation>; |
| 1260 | } |
| 1261 | |
| 1262 | interface XAiAskUserQuestionCancelledResponse { |
| 1263 | readonly outcome: "cancelled"; |
| 1264 | } |
| 1265 | |
| 1266 | interface NormalizedXAiAnswer { |
| 1267 | readonly questionText: string; |
| 1268 | readonly selectedLabels: ReadonlyArray<string>; |
| 1269 | readonly annotation?: XAiAskUserQuestionAnnotation; |
| 1270 | } |
| 1271 | |
| 1272 | function answerValues(answer: unknown): ReadonlyArray<string> { |
| 1273 | if (Array.isArray(answer)) { |
| 1274 | return answer.flatMap((entry) => { |
| 1275 | const text = typeof entry === "string" ? trimmed(entry) : undefined; |
| 1276 | return text ? [text] : []; |
| 1277 | }); |
| 1278 | } |
| 1279 | const text = typeof answer === "string" ? trimmed(answer) : undefined; |
| 1280 | return text ? [text] : []; |
| 1281 | } |
| 1282 | |
| 1283 | function normalizeAnswerForXAi( |
| 1284 | question: XAiAskUserQuestionRequestParams["questions"][number], |
| 1285 | answer: unknown, |
| 1286 | ): NormalizedXAiAnswer | undefined { |
| 1287 | const values = answerValues(answer); |
| 1288 | if (values.length === 0) { |
| 1289 | return undefined; |
| 1290 | } |
| 1291 | |
| 1292 | const optionByLabel = new Map(question.options.map((option) => [option.label, option])); |
| 1293 | const resolvedValues = values.map((value) => ({ |
| 1294 | value, |
| 1295 | option: optionByLabel.get(value), |
| 1296 | })); |
| 1297 | const selectedLabels = resolvedValues.flatMap(({ option }) => (option ? [option.label] : [])); |
| 1298 | const notes = resolvedValues.flatMap(({ option, value }) => (option ? [] : [value])); |
| 1299 | const preview = |
| 1300 | question.multiSelect === true |
| 1301 | ? undefined |
| 1302 | : resolvedValues.map(({ option }) => trimmed(option?.preview)).find((value) => value); |
| 1303 | |
| 1304 | const annotation = |
| 1305 | preview || notes.length > 0 |
| 1306 | ? { |
| 1307 | ...(preview ? { preview } : {}), |
| 1308 | ...(notes.length > 0 ? { notes: notes.join("\n") } : {}), |
| 1309 | } |
| 1310 | : undefined; |
| 1311 | |
| 1312 | return { |
| 1313 | questionText: question.question, |
| 1314 | selectedLabels: selectedLabels.length > 0 ? selectedLabels : ["Other"], |
| 1315 | ...(annotation ? { annotation } : {}), |
| 1316 | }; |
| 1317 | } |
| 1318 | |
| 1319 | function findQuestionAnswer( |
| 1320 | answers: ProviderUserInputAnswers, |
| 1321 | question: XAiAskUserQuestionRequestParams["questions"][number], |
| 1322 | ): unknown { |
| 1323 | const key = question.id ?? question.question; |
| 1324 | return answers[key] ?? answers[question.question]; |
| 1325 | } |
| 1326 | |
| 1327 | export function makeXAiAskUserQuestionResponse( |
| 1328 | params: XAiAskUserQuestionRequest, |
| 1329 | answers: ProviderUserInputAnswers, |
| 1330 | ): XAiAskUserQuestionAcceptedResponse { |
| 1331 | const questions = unwrapAskUserQuestionParams(params).questions; |
| 1332 | const normalized = questions.flatMap((question) => { |
| 1333 | const entry = normalizeAnswerForXAi(question, findQuestionAnswer(answers, question)); |
| 1334 | return entry ? [entry] : []; |
| 1335 | }); |
| 1336 | const annotations = Object.fromEntries( |
| 1337 | normalized.flatMap((entry) => |
| 1338 | entry.annotation ? [[entry.questionText, entry.annotation] as const] : [], |
| 1339 | ), |
| 1340 | ); |
| 1341 | |
| 1342 | return { |
| 1343 | outcome: "accepted", |
| 1344 | answers: Object.fromEntries( |
| 1345 | normalized.map((entry) => [entry.questionText, entry.selectedLabels]), |
| 1346 | ), |
| 1347 | ...(Object.keys(annotations).length > 0 ? { annotations } : {}), |
| 1348 | }; |
| 1349 | } |
| 1350 | |
| 1351 | export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse { |
| 1352 | return { outcome: "cancelled" }; |
| 1353 | } |
| 1354 | |
| 1355 | function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined { |
| 1356 | const meta = response._meta; |
| 1357 | if (meta === null || typeof meta !== "object") { |
| 1358 | return undefined; |
| 1359 | } |
| 1360 | const promptId = meta.promptId ?? meta.requestId; |
| 1361 | return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; |
| 1362 | } |
| 1363 | |
| 1364 | function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason { |
| 1365 | switch (value) { |
| 1366 | case "cancelled": |
| 1367 | case "end_turn": |
| 1368 | case "max_tokens": |
| 1369 | case "max_turn_requests": |
| 1370 | case "refusal": |
| 1371 | return value; |
| 1372 | default: |
| 1373 | return "end_turn"; |
| 1374 | } |
| 1375 | } |
| 1376 | |
| 1377 | function promptResponseFromXAi( |
| 1378 | notification: XAiPromptCompleteNotification, |
| 1379 | ): EffectAcpSchema.PromptResponse { |
| 1380 | const stopReason = normalizeXAiStopReason(notification.stopReason); |
| 1381 | // Values originate from a decoded JSON-RPC payload, so they are JSON even |
| 1382 | // though the x.ai schema types agentResult as unknown. |
| 1383 | const meta: Record<string, Schema.Json> = { |
| 1384 | sessionId: notification.sessionId, |
| 1385 | }; |
| 1386 | if (notification.stopReason === undefined) { |
| 1387 | meta[xAiStopReasonMissingMetaKey] = true; |
| 1388 | } |
| 1389 | if (notification.promptId !== undefined) { |
| 1390 | meta.promptId = notification.promptId; |
| 1391 | meta.requestId = notification.promptId; |
| 1392 | } |
| 1393 | if (notification.agentResult !== undefined) { |
| 1394 | meta.agentResult = notification.agentResult as Schema.Json; |
| 1395 | } |
| 1396 | return { |
| 1397 | stopReason, |
| 1398 | _meta: meta, |
| 1399 | }; |
| 1400 | } |
| 1401 | |
| 1402 | /** |
| 1403 | * Grok settles a failed prompt with `stopReason: "error"` and the provider's |
| 1404 | * message in `agentResult` before its `session/prompt` RPC error arrives. The |
| 1405 | * completion wins the race, so it must carry the failure itself. |
| 1406 | */ |
| 1407 | function xAiPromptFailure( |
| 1408 | notification: XAiPromptCompleteNotification, |
| 1409 | ): EffectAcpErrors.AcpRequestError | null { |
| 1410 | if (notification.stopReason === "rate_limit") { |
| 1411 | return new EffectAcpErrors.AcpRequestError({ |
| 1412 | code: xAiRateLimitedErrorCode, |
| 1413 | errorMessage: "Grok usage limit reached. Try again later.", |
| 1414 | }); |
| 1415 | } |
| 1416 | if (notification.stopReason === "error") { |
| 1417 | // Grok's raw result is unbounded provider text: keep it only as the cause. |
| 1418 | const agentResult = nonEmptyString(notification.agentResult); |
| 1419 | return new EffectAcpErrors.AcpRequestError({ |
| 1420 | code: -32603, |
| 1421 | errorMessage: "Grok ended the turn with an error.", |
| 1422 | operation: "receive-response", |
| 1423 | ...(agentResult === undefined ? {} : { cause: new XAiPromptFailureText(agentResult) }), |
| 1424 | }); |
| 1425 | } |
| 1426 | return null; |
| 1427 | } |
| 1428 | |
| 1429 | /** |
| 1430 | * Grok's own text for a failed prompt. A plain Error rather than a schema |
| 1431 | * error so the unbounded provider text never becomes a structured attribute; |
| 1432 | * it is read back only at the presentation boundary. |
| 1433 | */ |
| 1434 | export class XAiPromptFailureText extends Error {} |
| 1435 | |
| 1436 | const registerXAiPromptCompletionFallback = ( |
| 1437 | pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>, |
| 1438 | sessionId: string, |
| 1439 | promptId: string, |
| 1440 | ) => |
| 1441 | Deferred.make<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>().pipe( |
| 1442 | Effect.tap((deferred) => |
| 1443 | Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]), |
| 1444 | ), |
| 1445 | Effect.map((deferred) => ({ deferred, promptId })), |
| 1446 | ); |
| 1447 | |
| 1448 | const unregisterXAiPromptCompletionFallback = ( |
| 1449 | pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>, |
| 1450 | deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>, |
| 1451 | ) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred)); |
| 1452 | |
| 1453 | const abortPendingPromptCompletions = ( |
| 1454 | pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>, |
| 1455 | sessionId?: string, |
| 1456 | ) => |
| 1457 | Ref.modify(pendingRef, (pending) => { |
| 1458 | const [toAbort, remaining] = pending.reduce< |
| 1459 | [ReadonlyArray<PendingXAiPromptCompletion>, ReadonlyArray<PendingXAiPromptCompletion>] |
| 1460 | >( |
| 1461 | ([aborting, kept], entry) => |
| 1462 | sessionId === undefined || entry.sessionId === sessionId |
| 1463 | ? [[...aborting, entry], kept] |
| 1464 | : [aborting, [...kept, entry]], |
| 1465 | [[], []], |
| 1466 | ); |
| 1467 | if (toAbort.length === 0) { |
| 1468 | return [Effect.void, pending] as const; |
| 1469 | } |
| 1470 | return [ |
| 1471 | Effect.forEach( |
| 1472 | toAbort, |
| 1473 | (entry) => |
| 1474 | Deferred.succeed( |
| 1475 | entry.deferred, |
| 1476 | promptResponseFromXAi({ |
| 1477 | sessionId: entry.sessionId, |
| 1478 | promptId: entry.promptId, |
| 1479 | stopReason: "cancelled", |
| 1480 | agentResult: null, |
| 1481 | }), |
| 1482 | ), |
| 1483 | { concurrency: "unbounded" }, |
| 1484 | ).pipe(Effect.asVoid), |
| 1485 | remaining, |
| 1486 | ] as const; |
| 1487 | }).pipe(Effect.flatten); |
| 1488 | |
| 1489 | const resolveXAiPromptCompletionFallback = ({ |
| 1490 | pendingRef, |
| 1491 | completedPromptIdsRef, |
| 1492 | notification, |
| 1493 | }: { |
| 1494 | readonly pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>; |
| 1495 | readonly completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>; |
| 1496 | readonly notification: XAiPromptCompleteNotification; |
| 1497 | }) => |
| 1498 | Ref.get(completedPromptIdsRef).pipe( |
| 1499 | Effect.flatMap((completedPromptIds) => { |
| 1500 | if ( |
| 1501 | notification.promptId !== undefined && |
| 1502 | completedPromptIds.includes(notification.promptId) |
| 1503 | ) { |
| 1504 | return Effect.void; |
| 1505 | } |
| 1506 | return Ref.modify(pendingRef, (pending) => { |
| 1507 | const index = |
| 1508 | notification.promptId !== undefined |
| 1509 | ? pending.findIndex( |
| 1510 | (entry) => |
| 1511 | entry.sessionId === notification.sessionId && |
| 1512 | entry.promptId === notification.promptId, |
| 1513 | ) |
| 1514 | : (() => { |
| 1515 | const sessionPendingIndexes = pending.flatMap((entry, entryIndex) => |
| 1516 | entry.sessionId === notification.sessionId ? [entryIndex] : [], |
| 1517 | ); |
| 1518 | if (sessionPendingIndexes.length !== 1) { |
| 1519 | return -1; |
| 1520 | } |
| 1521 | return sessionPendingIndexes[0] ?? -1; |
| 1522 | })(); |
| 1523 | if (index < 0) { |
| 1524 | return [Effect.void, pending] as const; |
| 1525 | } |
| 1526 | const entry = pending[index]; |
| 1527 | if (!entry) { |
| 1528 | return [Effect.void, pending] as const; |
| 1529 | } |
| 1530 | const failure = xAiPromptFailure(notification); |
| 1531 | const settle = |
| 1532 | failure !== null |
| 1533 | ? Deferred.fail(entry.deferred, failure).pipe(Effect.asVoid) |
| 1534 | : Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe( |
| 1535 | Effect.asVoid, |
| 1536 | ); |
| 1537 | return [settle, [...pending.slice(0, index), ...pending.slice(index + 1)]] as const; |
| 1538 | }).pipe(Effect.flatten); |
| 1539 | }), |
| 1540 | ); |
| 1541 | |
| 1542 | const rememberCompletedXAiPromptId = ( |
| 1543 | completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>, |
| 1544 | response: EffectAcpSchema.PromptResponse, |
| 1545 | fallbackPromptId: string, |
| 1546 | ) => { |
| 1547 | const promptId = promptIdFromResponse(response) ?? fallbackPromptId; |
| 1548 | if (promptId.length === 0) { |
| 1549 | return Effect.void; |
| 1550 | } |
| 1551 | return Ref.update(completedPromptIdsRef, (completedPromptIds) => { |
| 1552 | if (completedPromptIds.includes(promptId)) { |
| 1553 | return completedPromptIds; |
| 1554 | } |
| 1555 | return [...completedPromptIds, promptId].slice(-completedXAiPromptIdLimit); |
| 1556 | }); |
| 1557 | }; |
| 1558 | |
| 1559 | /** |
| 1560 | * Grok-specific ACP runtime wrapper. Races `session/prompt` against root-matched |
| 1561 | * terminal notifications: |
| 1562 | * - session notification/update + `turn_completed` + matching `prompt_id` |
| 1563 | * - `x.ai/session/prompt_complete` (open-source fire-and-forget signal) |
| 1564 | * - `_x.ai/session/prompt_complete` (released-build alias) |
| 1565 | * |
| 1566 | * Pending entries are keyed by root sessionId + T3-injected promptId, so |
| 1567 | * foreign/child sessions and `task-completed-*` ids do not settle the root turn. |
| 1568 | */ |
| 1569 | export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")( |
| 1570 | function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) { |
| 1571 | // Grok sends turn completion and other session updates (subagent_finished) |
| 1572 | // on the same extension methods, and the client keeps one handler per |
| 1573 | // method. This wrapper owns those methods: it settles prompts itself, then |
| 1574 | // passes each notification to the handler registered on the wrapped runtime. |
| 1575 | const sessionNotificationHandlers = new Map< |
| 1576 | string, |
| 1577 | (params: unknown) => Effect.Effect<void, EffectAcpErrors.AcpError> |
| 1578 | >(); |
| 1579 | let nextPromptFallbackId = 0; |
| 1580 | const allocatePromptFallbackId = Effect.sync(() => { |
| 1581 | nextPromptFallbackId += 1; |
| 1582 | return `t3-xai-prompt-${nextPromptFallbackId}`; |
| 1583 | }); |
| 1584 | const pendingXAiPromptCompletionsRef = yield* Ref.make< |
| 1585 | ReadonlyArray<PendingXAiPromptCompletion> |
| 1586 | >([]); |
| 1587 | const completedXAiPromptIdsRef = yield* Ref.make<ReadonlyArray<string>>([]); |
| 1588 | |
| 1589 | const settleFromPromptComplete = (notification: XAiPromptCompleteNotification) => |
| 1590 | resolveXAiPromptCompletionFallback({ |
| 1591 | pendingRef: pendingXAiPromptCompletionsRef, |
| 1592 | completedPromptIdsRef: completedXAiPromptIdsRef, |
| 1593 | notification, |
| 1594 | }).pipe(Effect.catch(() => Effect.void)); |
| 1595 | |
| 1596 | yield* Effect.forEach( |
| 1597 | ["x.ai/session/prompt_complete", "_x.ai/session/prompt_complete"] as const, |
| 1598 | (method) => |
| 1599 | runtime.handleExtNotification( |
| 1600 | method, |
| 1601 | XAiPromptCompleteNotification, |
| 1602 | settleFromPromptComplete, |
| 1603 | ), |
| 1604 | { discard: true }, |
| 1605 | ); |
| 1606 | |
| 1607 | yield* Effect.forEach( |
| 1608 | xAiSessionNotificationMethods, |
| 1609 | (method) => |
| 1610 | runtime.handleExtNotification(method, Schema.Unknown, (params) => |
| 1611 | decodeXAiSessionUpdateNotification(params).pipe( |
| 1612 | Effect.flatMap((notification) => { |
| 1613 | const complete = xAiPromptCompleteFromSessionUpdate(notification); |
| 1614 | return complete === null ? Effect.void : settleFromPromptComplete(complete); |
| 1615 | }), |
| 1616 | Effect.ignore, |
| 1617 | Effect.andThen( |
| 1618 | Effect.suspend( |
| 1619 | () => sessionNotificationHandlers.get(method)?.(params) ?? Effect.void, |
| 1620 | ), |
| 1621 | ), |
| 1622 | ), |
| 1623 | ), |
| 1624 | { discard: true }, |
| 1625 | ); |
| 1626 | |
| 1627 | return { |
| 1628 | ...runtime, |
| 1629 | handleExtNotification: (method, payload, handler) => |
| 1630 | xAiSessionNotificationMethods.some((owned) => owned === method) |
| 1631 | ? Effect.sync(() => { |
| 1632 | sessionNotificationHandlers.set(method, (params) => |
| 1633 | Schema.decodeUnknownEffect(payload)(params).pipe( |
| 1634 | Effect.mapError((error) => |
| 1635 | EffectAcpErrors.AcpProtocolParseError.fromSchemaError( |
| 1636 | "decode-notification-payload", |
| 1637 | method, |
| 1638 | error, |
| 1639 | ), |
| 1640 | ), |
| 1641 | Effect.flatMap(handler), |
| 1642 | ), |
| 1643 | ); |
| 1644 | }) |
| 1645 | : runtime.handleExtNotification(method, payload, handler), |
| 1646 | prompt: (payload, promptOptions?) => |
| 1647 | Effect.gen(function* () { |
| 1648 | const started = yield* runtime.start(); |
| 1649 | const promptId = yield* allocatePromptFallbackId; |
| 1650 | const fallback = yield* registerXAiPromptCompletionFallback( |
| 1651 | pendingXAiPromptCompletionsRef, |
| 1652 | started.sessionId, |
| 1653 | promptId, |
| 1654 | ); |
| 1655 | const cancelledResponse = promptResponseFromXAi({ |
| 1656 | sessionId: started.sessionId, |
| 1657 | promptId: fallback.promptId, |
| 1658 | stopReason: "cancelled", |
| 1659 | agentResult: null, |
| 1660 | }); |
| 1661 | const promptRpcFiber = yield* runtime |
| 1662 | .prompt( |
| 1663 | { |
| 1664 | ...payload, |
| 1665 | _meta: { |
| 1666 | ...payload._meta, |
| 1667 | promptId: fallback.promptId, |
| 1668 | requestId: fallback.promptId, |
| 1669 | }, |
| 1670 | }, |
| 1671 | promptOptions, |
| 1672 | ) |
| 1673 | .pipe(Effect.forkChild); |
| 1674 | return yield* Effect.raceFirst( |
| 1675 | Fiber.join(promptRpcFiber).pipe( |
| 1676 | Effect.catchCause((cause) => |
| 1677 | Cause.hasInterruptsOnly(cause) |
| 1678 | ? Effect.succeed(cancelledResponse) |
| 1679 | : Effect.failCause(cause), |
| 1680 | ), |
| 1681 | ), |
| 1682 | Deferred.await(fallback.deferred), |
| 1683 | ).pipe( |
| 1684 | Effect.tap((response) => |
| 1685 | rememberCompletedXAiPromptId(completedXAiPromptIdsRef, response, fallback.promptId), |
| 1686 | ), |
| 1687 | Effect.ensuring( |
| 1688 | Effect.gen(function* () { |
| 1689 | yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); |
| 1690 | yield* unregisterXAiPromptCompletionFallback( |
| 1691 | pendingXAiPromptCompletionsRef, |
| 1692 | fallback.deferred, |
| 1693 | ); |
| 1694 | }), |
| 1695 | ), |
| 1696 | ); |
| 1697 | }), |
| 1698 | cancel: Effect.gen(function* () { |
| 1699 | // Do not call start here: cancellation must be able to reach the |
| 1700 | // underlying runtime when startup is stalled. Pending entries already |
| 1701 | // carry their session ids, and this runtime owns all of them. |
| 1702 | yield* abortPendingPromptCompletions(pendingXAiPromptCompletionsRef); |
| 1703 | yield* runtime.cancel; |
| 1704 | }), |
| 1705 | } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 1706 | }, |
| 1707 | ); |
| 1708 | |
| 1709 | function promptResponseHasMissingXAiStopReason(response: EffectAcpSchema.PromptResponse): boolean { |
| 1710 | const meta = response._meta; |
| 1711 | return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true; |
| 1712 | } |