| 1 | import * as Clock from "effect/Clock"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Ref from "effect/Ref"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | import * as EffectAcpSchema from "effect-acp/compat"; |
| 8 | import * as EffectAcpSchemaV1 from "effect-acp/schema-v1"; |
| 9 | import { |
| 10 | deriveToolActivityPresentation, |
| 11 | mergeToolActivityData, |
| 12 | } from "@t3tools/shared/toolActivity"; |
| 13 | import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; |
| 14 | import type { |
| 15 | OrchestrationV2ProviderThreadNativeMetadata, |
| 16 | ThreadTokenUsageSnapshot, |
| 17 | ToolLifecycleItemType, |
| 18 | } from "@t3tools/contracts"; |
| 19 | |
| 20 | function isRecord(value: unknown): value is Record<string, unknown> { |
| 21 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 22 | } |
| 23 | |
| 24 | // Guards for the untyped `initialize._meta` states some agents (Grok) advertise. |
| 25 | // Modes were removed from ACP v2, so the v1 wire schema is the source of truth. |
| 26 | const isSessionModelState = Schema.is(EffectAcpSchema.SessionModelState); |
| 27 | const isSessionModeState = Schema.is(EffectAcpSchemaV1.SessionModeState); |
| 28 | |
| 29 | export interface AcpSessionMode { |
| 30 | readonly id: string; |
| 31 | readonly name: string; |
| 32 | readonly description?: string; |
| 33 | } |
| 34 | |
| 35 | export interface AcpSessionModeState { |
| 36 | readonly currentModeId: string; |
| 37 | readonly availableModes: ReadonlyArray<AcpSessionMode>; |
| 38 | } |
| 39 | |
| 40 | export interface AcpToolCallState { |
| 41 | readonly toolCallId: string; |
| 42 | readonly kind?: string; |
| 43 | readonly title?: string; |
| 44 | readonly status?: "pending" | "inProgress" | "requiresAction" | "completed" | "failed"; |
| 45 | readonly command?: string; |
| 46 | readonly detail?: string; |
| 47 | readonly data: Record<string, unknown>; |
| 48 | } |
| 49 | |
| 50 | export interface AcpAgentTerminalState { |
| 51 | readonly command?: string; |
| 52 | readonly cwd?: string; |
| 53 | readonly output: string; |
| 54 | readonly exitStatus?: EffectAcpSchema.TerminalExitStatus; |
| 55 | /** Incomplete trailing UTF-8 bytes retained until the next output update. */ |
| 56 | readonly pendingOutputBytes?: Uint8Array; |
| 57 | } |
| 58 | |
| 59 | type AcpAgentTerminalUpdate = Extract< |
| 60 | EffectAcpSchema.SessionUpdate, |
| 61 | { readonly sessionUpdate: "terminal_update" | "terminal_output_chunk" } |
| 62 | >; |
| 63 | |
| 64 | const MAX_ACP_TERMINAL_OUTPUT_BYTES = 16 * 1024 * 1024; |
| 65 | const MAX_ACP_TERMINAL_FRAME_BYTES = MAX_ACP_TERMINAL_OUTPUT_BYTES; |
| 66 | |
| 67 | function isBase64DataCode(code: number): boolean { |
| 68 | return ( |
| 69 | (code >= 65 && code <= 90) || |
| 70 | (code >= 97 && code <= 122) || |
| 71 | (code >= 48 && code <= 57) || |
| 72 | code === 43 || |
| 73 | code === 47 |
| 74 | ); |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * Decodes one bounded base64 terminal frame, or `undefined` when the frame is |
| 79 | * malformed or oversized. Invalid frames are dropped rather than failed so one |
| 80 | * misbehaving agent frame cannot take down session-update handling. |
| 81 | */ |
| 82 | function decodeBase64TerminalFrame(encoded: string): Uint8Array | undefined { |
| 83 | if (encoded.length > Math.ceil(MAX_ACP_TERMINAL_FRAME_BYTES / 3) * 4) { |
| 84 | return undefined; |
| 85 | } |
| 86 | const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; |
| 87 | const dataLength = encoded.length - padding; |
| 88 | if ( |
| 89 | encoded.length % 4 === 1 || |
| 90 | (padding > 0 && encoded.length % 4 !== 0) || |
| 91 | (padding === 1 && dataLength % 4 !== 3) || |
| 92 | (padding === 2 && dataLength % 4 !== 2) |
| 93 | ) { |
| 94 | return undefined; |
| 95 | } |
| 96 | for (let index = 0; index < dataLength; index += 1) { |
| 97 | if (!isBase64DataCode(encoded.charCodeAt(index))) return undefined; |
| 98 | } |
| 99 | const decoded = Buffer.from(encoded, "base64"); |
| 100 | return decoded.byteLength <= MAX_ACP_TERMINAL_FRAME_BYTES ? decoded : undefined; |
| 101 | } |
| 102 | |
| 103 | function trailingIncompleteUtf8Start(bytes: Uint8Array): number { |
| 104 | if (bytes.length === 0) return bytes.length; |
| 105 | let leadIndex = bytes.length - 1; |
| 106 | while (leadIndex >= 0 && (bytes[leadIndex]! & 0xc0) === 0x80) { |
| 107 | leadIndex -= 1; |
| 108 | } |
| 109 | if (leadIndex < 0) return bytes.length; |
| 110 | const lead = bytes[leadIndex]!; |
| 111 | const expectedLength = |
| 112 | lead >= 0xc2 && lead <= 0xdf |
| 113 | ? 2 |
| 114 | : lead >= 0xe0 && lead <= 0xef |
| 115 | ? 3 |
| 116 | : lead >= 0xf0 && lead <= 0xf4 |
| 117 | ? 4 |
| 118 | : 1; |
| 119 | return bytes.length - leadIndex < expectedLength ? leadIndex : bytes.length; |
| 120 | } |
| 121 | |
| 122 | function decodeTerminalOutputChunk( |
| 123 | pending: Uint8Array | undefined, |
| 124 | encoded: string, |
| 125 | ): { readonly text: string; readonly pendingOutputBytes?: Uint8Array } | undefined { |
| 126 | const frame = decodeBase64TerminalFrame(encoded); |
| 127 | if (frame === undefined) return undefined; |
| 128 | const bytes = Buffer.concat([...(pending === undefined ? [] : [Buffer.from(pending)]), frame]); |
| 129 | const completeEnd = trailingIncompleteUtf8Start(bytes); |
| 130 | const text = bytes.subarray(0, completeEnd).toString("utf8"); |
| 131 | return completeEnd === bytes.length |
| 132 | ? { text } |
| 133 | : { text, pendingOutputBytes: Uint8Array.from(bytes.subarray(completeEnd)) }; |
| 134 | } |
| 135 | |
| 136 | function truncateTerminalOutput(output: string): string { |
| 137 | const bytes = Buffer.from(output, "utf8"); |
| 138 | if (bytes.length <= MAX_ACP_TERMINAL_OUTPUT_BYTES) return output; |
| 139 | let start = bytes.length - MAX_ACP_TERMINAL_OUTPUT_BYTES; |
| 140 | while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1; |
| 141 | return bytes.subarray(start).toString("utf8"); |
| 142 | } |
| 143 | |
| 144 | /** Applies an ACP v2 agent-owned terminal snapshot or chunk to display state. */ |
| 145 | export function applyAcpAgentTerminalUpdate( |
| 146 | previous: AcpAgentTerminalState | undefined, |
| 147 | update: AcpAgentTerminalUpdate, |
| 148 | ): AcpAgentTerminalState { |
| 149 | const current = previous ?? { output: "" }; |
| 150 | if (update.sessionUpdate === "terminal_output_chunk") { |
| 151 | const decoded = decodeTerminalOutputChunk(current.pendingOutputBytes, update.data); |
| 152 | if (decoded === undefined) return current; |
| 153 | const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = current; |
| 154 | return { |
| 155 | ...withoutPendingOutputBytes, |
| 156 | output: truncateTerminalOutput(`${current.output}${decoded.text}`), |
| 157 | ...(decoded.pendingOutputBytes === undefined |
| 158 | ? {} |
| 159 | : { pendingOutputBytes: decoded.pendingOutputBytes }), |
| 160 | }; |
| 161 | } |
| 162 | let next: AcpAgentTerminalState = current; |
| 163 | if (update.command === null) { |
| 164 | const { command: _command, ...withoutCommand } = next; |
| 165 | next = withoutCommand; |
| 166 | } else if (update.command !== undefined) { |
| 167 | next = { ...next, command: update.command }; |
| 168 | } |
| 169 | if (update.cwd === null) { |
| 170 | const { cwd: _cwd, ...withoutCwd } = next; |
| 171 | next = withoutCwd; |
| 172 | } else if (update.cwd !== undefined) { |
| 173 | next = { ...next, cwd: update.cwd }; |
| 174 | } |
| 175 | if (update.output !== undefined) { |
| 176 | const decoded = |
| 177 | update.output === null |
| 178 | ? { text: "" } |
| 179 | : decodeTerminalOutputChunk(undefined, update.output.data); |
| 180 | if (decoded !== undefined) { |
| 181 | const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = next; |
| 182 | next = { |
| 183 | ...withoutPendingOutputBytes, |
| 184 | output: truncateTerminalOutput(decoded.text), |
| 185 | ...(decoded.pendingOutputBytes === undefined |
| 186 | ? {} |
| 187 | : { pendingOutputBytes: decoded.pendingOutputBytes }), |
| 188 | }; |
| 189 | } |
| 190 | } |
| 191 | if (update.exitStatus === null) { |
| 192 | const { exitStatus: _exitStatus, ...withoutExitStatus } = next; |
| 193 | next = withoutExitStatus; |
| 194 | } else if (update.exitStatus !== undefined) { |
| 195 | const pendingText = |
| 196 | next.pendingOutputBytes === undefined |
| 197 | ? "" |
| 198 | : Buffer.from(next.pendingOutputBytes).toString("utf8"); |
| 199 | const { pendingOutputBytes: _pendingOutputBytes, ...withoutPendingOutputBytes } = next; |
| 200 | next = { |
| 201 | ...withoutPendingOutputBytes, |
| 202 | output: truncateTerminalOutput(`${next.output}${pendingText}`), |
| 203 | exitStatus: update.exitStatus, |
| 204 | }; |
| 205 | } |
| 206 | return next; |
| 207 | } |
| 208 | |
| 209 | export type AcpPlanUpdate = |
| 210 | | { |
| 211 | readonly nativePlanId: string; |
| 212 | readonly kind: "items"; |
| 213 | readonly explanation?: string | null; |
| 214 | readonly plan: ReadonlyArray<{ |
| 215 | readonly step: string; |
| 216 | readonly status: "pending" | "inProgress" | "completed"; |
| 217 | }>; |
| 218 | } |
| 219 | | { readonly nativePlanId: string; readonly kind: "markdown"; readonly markdown: string } |
| 220 | | { readonly nativePlanId: string; readonly kind: "file"; readonly uri: string } |
| 221 | | { readonly nativePlanId: string; readonly kind: "unknown"; readonly contentType: string } |
| 222 | | { readonly nativePlanId: string; readonly kind: "removed" }; |
| 223 | |
| 224 | export interface AcpPermissionRequest { |
| 225 | readonly kind: string | "unknown"; |
| 226 | readonly detail?: string; |
| 227 | readonly toolCall?: AcpToolCallState; |
| 228 | } |
| 229 | |
| 230 | export type AcpParsedSessionEvent = |
| 231 | | { |
| 232 | readonly _tag: "ModeChanged"; |
| 233 | readonly modeId: string; |
| 234 | } |
| 235 | | { |
| 236 | readonly _tag: "AvailableCommandsUpdated"; |
| 237 | readonly availableCommands: ReadonlyArray<EffectAcpSchema.AvailableCommand>; |
| 238 | readonly rawPayload: unknown; |
| 239 | } |
| 240 | | { |
| 241 | readonly _tag: "ConfigOptionsUpdated"; |
| 242 | readonly configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>; |
| 243 | readonly rawPayload: unknown; |
| 244 | } |
| 245 | | { |
| 246 | readonly _tag: "AssistantItemStarted"; |
| 247 | readonly itemId: string; |
| 248 | } |
| 249 | | { |
| 250 | readonly _tag: "AssistantItemCompleted"; |
| 251 | readonly itemId: string; |
| 252 | } |
| 253 | | { |
| 254 | readonly _tag: "PlanUpdated"; |
| 255 | readonly payload: AcpPlanUpdate; |
| 256 | readonly rawPayload: unknown; |
| 257 | } |
| 258 | | { |
| 259 | readonly _tag: "ToolCallUpdated"; |
| 260 | readonly toolCall: AcpToolCallState; |
| 261 | readonly rawPayload: unknown; |
| 262 | } |
| 263 | | { |
| 264 | readonly _tag: "ContentDelta"; |
| 265 | readonly itemId?: string; |
| 266 | readonly messageId?: string; |
| 267 | readonly text: string; |
| 268 | readonly rawPayload: unknown; |
| 269 | } |
| 270 | | { |
| 271 | readonly _tag: "ThoughtDelta"; |
| 272 | readonly text: string; |
| 273 | readonly rawPayload: unknown; |
| 274 | } |
| 275 | | { |
| 276 | readonly _tag: "UsageUpdated"; |
| 277 | readonly usage: ThreadTokenUsageSnapshot; |
| 278 | readonly rawPayload: unknown; |
| 279 | } |
| 280 | | { |
| 281 | readonly _tag: "SessionInfoUpdated"; |
| 282 | readonly metadata: OrchestrationV2ProviderThreadNativeMetadata; |
| 283 | readonly rawPayload: unknown; |
| 284 | } |
| 285 | | { |
| 286 | readonly _tag: "UnknownUpdate"; |
| 287 | readonly updateType: string; |
| 288 | readonly rawPayload: unknown; |
| 289 | }; |
| 290 | |
| 291 | const boundedContentMetadata = (value: string | null | undefined, maximumLength: number): string => |
| 292 | value?.trim().slice(0, maximumLength) ?? ""; |
| 293 | |
| 294 | const MAX_ACP_CONTENT_TEXT_LENGTH = 65_536; |
| 295 | |
| 296 | // Preserve whitespace in streamed model output while keeping one provider frame bounded. |
| 297 | const boundedContentText = (value: string): string => value.slice(0, MAX_ACP_CONTENT_TEXT_LENGTH); |
| 298 | |
| 299 | const boundedContentUri = (value: string | null | undefined): string => { |
| 300 | const uri = boundedContentMetadata(value, 4_096); |
| 301 | return uri.toLowerCase().startsWith("data:") ? "" : uri; |
| 302 | }; |
| 303 | |
| 304 | /** Convert every ACP output block into bounded, displayable text without retaining base64 data. */ |
| 305 | export function acpContentBlockDisplayText( |
| 306 | content: EffectAcpSchema.ContentBlock, |
| 307 | ): string | undefined { |
| 308 | switch (content.type) { |
| 309 | case "text": |
| 310 | return boundedContentText(content.text); |
| 311 | case "resource_link": { |
| 312 | const label = |
| 313 | boundedContentMetadata(content.title, 512) || |
| 314 | boundedContentMetadata(content.name, 512) || |
| 315 | "resource"; |
| 316 | const description = boundedContentMetadata(content.description, 2_048); |
| 317 | const uri = boundedContentUri(content.uri); |
| 318 | return `${description ? `${label}: ${description}` : label}${uri ? `\n${uri}` : ""}`; |
| 319 | } |
| 320 | case "resource": { |
| 321 | if ("text" in content.resource) { |
| 322 | return boundedContentText(content.resource.text); |
| 323 | } |
| 324 | const mimeType = boundedContentMetadata(content.resource.mimeType, 256); |
| 325 | const uri = boundedContentUri(content.resource.uri); |
| 326 | return `[ACP binary resource${mimeType ? ` (${mimeType})` : ""}${uri ? `: ${uri}` : ""}]`; |
| 327 | } |
| 328 | case "image": { |
| 329 | const mimeType = boundedContentMetadata(content.mimeType, 256) || "unknown type"; |
| 330 | const uri = boundedContentUri(content.uri); |
| 331 | return `[ACP image (${mimeType})${uri ? `: ${uri}` : ""}]`; |
| 332 | } |
| 333 | case "audio": { |
| 334 | const mimeType = boundedContentMetadata(content.mimeType, 256) || "unknown type"; |
| 335 | return `[ACP audio (${mimeType})]`; |
| 336 | } |
| 337 | case "_t3_unknown": |
| 338 | return `[Unsupported ACP content: ${boundedContentMetadata(content.originalType, 128) || "unknown"}]`; |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | function sanitizeAcpToolCallContent( |
| 343 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent>, |
| 344 | ): ReadonlyArray<EffectAcpSchema.ToolCallContent> { |
| 345 | return content.map((entry) => { |
| 346 | switch (entry.type) { |
| 347 | case "content": |
| 348 | return { |
| 349 | type: "content", |
| 350 | content: { |
| 351 | type: "text", |
| 352 | text: acpContentBlockDisplayText(entry.content) ?? "", |
| 353 | }, |
| 354 | }; |
| 355 | case "diff": |
| 356 | if ("changes" in entry) { |
| 357 | return { |
| 358 | type: "diff", |
| 359 | changes: entry.changes, |
| 360 | ...(entry.patch === undefined ? {} : { patch: entry.patch }), |
| 361 | }; |
| 362 | } |
| 363 | return { |
| 364 | type: "diff", |
| 365 | path: entry.path, |
| 366 | ...(entry.oldText !== undefined ? { oldText: entry.oldText } : {}), |
| 367 | newText: entry.newText, |
| 368 | }; |
| 369 | case "terminal": |
| 370 | return { type: "terminal", terminalId: entry.terminalId }; |
| 371 | case "_t3_unknown": |
| 372 | return { |
| 373 | type: "_t3_unknown", |
| 374 | originalType: boundedContentMetadata(entry.originalType, 128) || "unknown", |
| 375 | raw: null, |
| 376 | }; |
| 377 | } |
| 378 | }); |
| 379 | } |
| 380 | |
| 381 | type AcpSessionSetupResponse = |
| 382 | | EffectAcpSchema.ForkSessionResponse |
| 383 | | EffectAcpSchema.LoadSessionResponse |
| 384 | | EffectAcpSchema.NewSessionResponse |
| 385 | | EffectAcpSchema.ResumeSessionResponse; |
| 386 | |
| 387 | type AcpToolCallUpdate = Extract< |
| 388 | EffectAcpSchema.SessionNotification["update"], |
| 389 | { readonly sessionUpdate: "tool_call" | "tool_call_update" } |
| 390 | >; |
| 391 | |
| 392 | export function extractModelConfigId(sessionResponse: AcpSessionSetupResponse): string | undefined { |
| 393 | const configOptions = sessionResponse.configOptions; |
| 394 | if (!configOptions) return undefined; |
| 395 | for (const opt of configOptions) { |
| 396 | if (opt.category === "model" && opt.id.trim().length > 0) { |
| 397 | return opt.id.trim(); |
| 398 | } |
| 399 | } |
| 400 | return undefined; |
| 401 | } |
| 402 | |
| 403 | export function findSessionConfigOption( |
| 404 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined, |
| 405 | configId: string, |
| 406 | ): EffectAcpSchema.SessionConfigOption | undefined { |
| 407 | if (!configOptions) { |
| 408 | return undefined; |
| 409 | } |
| 410 | const normalizedConfigId = configId.trim(); |
| 411 | if (!normalizedConfigId) { |
| 412 | return undefined; |
| 413 | } |
| 414 | return configOptions.find((option) => option.id.trim() === normalizedConfigId); |
| 415 | } |
| 416 | |
| 417 | export function collectSessionConfigOptionValues( |
| 418 | configOption: EffectAcpSchema.SessionConfigOption, |
| 419 | ): ReadonlyArray<string> { |
| 420 | if (configOption.type !== "select") { |
| 421 | return []; |
| 422 | } |
| 423 | return configOption.options.flatMap((entry) => |
| 424 | "value" in entry ? [entry.value] : entry.options.map((option) => option.value), |
| 425 | ); |
| 426 | } |
| 427 | |
| 428 | export function parseSessionModeState( |
| 429 | sessionResponse: AcpSessionSetupResponse, |
| 430 | ): AcpSessionModeState | undefined { |
| 431 | const modeConfig = sessionResponse.configOptions?.find( |
| 432 | (option) => option.category === "mode" && option.type === "select", |
| 433 | ); |
| 434 | const modes = |
| 435 | sessionResponse.modes ?? |
| 436 | (modeConfig?.type === "select" |
| 437 | ? { |
| 438 | currentModeId: modeConfig.currentValue, |
| 439 | availableModes: modeConfig.options |
| 440 | .flatMap((option) => ("options" in option ? option.options : [option])) |
| 441 | .map((option) => ({ |
| 442 | id: option.value, |
| 443 | name: option.name, |
| 444 | description: option.description, |
| 445 | })), |
| 446 | } |
| 447 | : undefined); |
| 448 | if (!modes) return undefined; |
| 449 | const currentModeId = modes.currentModeId.trim(); |
| 450 | if (!currentModeId) { |
| 451 | return undefined; |
| 452 | } |
| 453 | const availableModes: Array<AcpSessionMode> = []; |
| 454 | for (const mode of modes.availableModes) { |
| 455 | const id = mode.id.trim(); |
| 456 | const name = mode.name.trim(); |
| 457 | if (!id || !name) { |
| 458 | continue; |
| 459 | } |
| 460 | const description = mode.description?.trim() || undefined; |
| 461 | availableModes.push( |
| 462 | description !== undefined |
| 463 | ? ({ id, name, description } satisfies AcpSessionMode) |
| 464 | : ({ id, name } satisfies AcpSessionMode), |
| 465 | ); |
| 466 | } |
| 467 | if (availableModes.length === 0) { |
| 468 | return undefined; |
| 469 | } |
| 470 | return { |
| 471 | currentModeId, |
| 472 | availableModes, |
| 473 | }; |
| 474 | } |
| 475 | |
| 476 | function normalizePlanStepStatus(raw: unknown): "pending" | "inProgress" | "completed" { |
| 477 | switch (raw) { |
| 478 | case "completed": |
| 479 | return "completed"; |
| 480 | case "in_progress": |
| 481 | case "inProgress": |
| 482 | return "inProgress"; |
| 483 | default: |
| 484 | return "pending"; |
| 485 | } |
| 486 | } |
| 487 | |
| 488 | function normalizeToolCallStatus( |
| 489 | raw: unknown, |
| 490 | fallback?: "pending" | "inProgress" | "completed" | "failed", |
| 491 | ): "pending" | "inProgress" | "completed" | "failed" | undefined { |
| 492 | switch (raw) { |
| 493 | case "pending": |
| 494 | return "pending"; |
| 495 | case "in_progress": |
| 496 | case "inProgress": |
| 497 | return "inProgress"; |
| 498 | case "completed": |
| 499 | return "completed"; |
| 500 | case "failed": |
| 501 | return "failed"; |
| 502 | default: |
| 503 | return fallback; |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | function normalizeCommandValue(value: unknown): string | undefined { |
| 508 | if (typeof value === "string" && value.trim().length > 0) { |
| 509 | return value.trim(); |
| 510 | } |
| 511 | if (!Array.isArray(value)) { |
| 512 | return undefined; |
| 513 | } |
| 514 | const parts: Array<string> = []; |
| 515 | for (const entry of value) { |
| 516 | if (typeof entry === "string") { |
| 517 | const part = entry.trim(); |
| 518 | if (part.length > 0) { |
| 519 | parts.push(part); |
| 520 | } |
| 521 | } |
| 522 | } |
| 523 | return parts.length > 0 ? parts.join(" ") : undefined; |
| 524 | } |
| 525 | |
| 526 | function extractCommandFromTitle(title: string | undefined): string | undefined { |
| 527 | if (!title) { |
| 528 | return undefined; |
| 529 | } |
| 530 | const match = /`([^`]+)`/.exec(title); |
| 531 | return match?.[1]?.trim() || undefined; |
| 532 | } |
| 533 | |
| 534 | function extractToolCallCommand( |
| 535 | rawInput: unknown, |
| 536 | title: string | undefined, |
| 537 | kind: string | undefined, |
| 538 | ): string | undefined { |
| 539 | if (isRecord(rawInput)) { |
| 540 | const directCommand = normalizeCommandValue(rawInput.command); |
| 541 | if (directCommand) { |
| 542 | return directCommand; |
| 543 | } |
| 544 | const executable = typeof rawInput.executable === "string" ? rawInput.executable.trim() : ""; |
| 545 | const args = normalizeCommandValue(rawInput.args); |
| 546 | if (executable && args) { |
| 547 | return `${executable} ${args}`; |
| 548 | } |
| 549 | if (executable) { |
| 550 | return executable; |
| 551 | } |
| 552 | } |
| 553 | // Titles like `Read \`src/a.ts\`` are not commands. Only execute tools |
| 554 | // fall back to a backtick or bare title. |
| 555 | if (kind !== "execute") { |
| 556 | return undefined; |
| 557 | } |
| 558 | return extractCommandFromTitle(title); |
| 559 | } |
| 560 | |
| 561 | function filePathFromToolValue(value: unknown): string | undefined { |
| 562 | if (typeof value !== "string") { |
| 563 | return undefined; |
| 564 | } |
| 565 | const trimmed = value.trim(); |
| 566 | return trimmed.length > 0 ? trimmed : undefined; |
| 567 | } |
| 568 | |
| 569 | function locationsFromToolCallInput(input: { |
| 570 | readonly locations?: ReadonlyArray<EffectAcpSchema.ToolCallLocation> | null | undefined; |
| 571 | readonly content?: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined; |
| 572 | readonly rawInput?: unknown; |
| 573 | readonly rawOutput?: unknown; |
| 574 | }): ReadonlyArray<EffectAcpSchema.ToolCallLocation> | undefined { |
| 575 | if (input.locations === null || input.locations?.length === 0) { |
| 576 | return []; |
| 577 | } |
| 578 | const locations: EffectAcpSchema.ToolCallLocation[] = []; |
| 579 | const seen = new Set<string>(); |
| 580 | const pushLocation = (location: EffectAcpSchema.ToolCallLocation) => { |
| 581 | const path = filePathFromToolValue(location.path); |
| 582 | if (!path || seen.has(path)) { |
| 583 | return; |
| 584 | } |
| 585 | seen.add(path); |
| 586 | locations.push({ ...location, path }); |
| 587 | }; |
| 588 | const pushPath = (value: unknown) => { |
| 589 | const path = filePathFromToolValue(value); |
| 590 | if (!path || seen.has(path)) { |
| 591 | return; |
| 592 | } |
| 593 | seen.add(path); |
| 594 | locations.push({ path }); |
| 595 | }; |
| 596 | |
| 597 | if (input.locations) { |
| 598 | for (const location of input.locations) { |
| 599 | pushLocation(location); |
| 600 | } |
| 601 | } |
| 602 | if (input.content) { |
| 603 | for (const entry of input.content) { |
| 604 | if (entry.type === "diff" && "path" in entry) { |
| 605 | pushPath(entry.path); |
| 606 | } |
| 607 | } |
| 608 | } |
| 609 | if (isRecord(input.rawInput)) { |
| 610 | pushPath(input.rawInput.path); |
| 611 | pushPath(input.rawInput.filePath); |
| 612 | pushPath(input.rawInput.file_path); |
| 613 | } |
| 614 | if (isRecord(input.rawOutput)) { |
| 615 | pushPath(input.rawOutput.path); |
| 616 | pushPath(input.rawOutput.filePath); |
| 617 | pushPath(input.rawOutput.file_path); |
| 618 | } |
| 619 | |
| 620 | return locations.length > 0 ? locations : undefined; |
| 621 | } |
| 622 | |
| 623 | // Some ACP agents (observed with Grok's CLI) resend the ENTIRE accumulated tool-call |
| 624 | // output on every `tool_call_update` notification instead of a delta, so a redrawing |
| 625 | // terminal progress bar can balloon a single tool call to hundreds of KB per update at |
| 626 | // several updates per second. Cap what we retain/emit to a bounded tail so one busy tool |
| 627 | // call cannot flood runtime event ingestion. We always keep the tail: `tool_call_update` |
| 628 | // deltas routinely omit `kind`, so there is no reliable way to tell a redrawing terminal |
| 629 | // from another tool here, and the end is the useful part of any live-growing output. |
| 630 | const TOOL_CALL_CONTENT_MAX_CHARS = 8_000; |
| 631 | const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n"; |
| 632 | |
| 633 | function boundToolCallOutputText(text: string): string { |
| 634 | if (text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { |
| 635 | return text; |
| 636 | } |
| 637 | const tail = text.slice(text.length - TOOL_CALL_CONTENT_MAX_CHARS); |
| 638 | return `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${tail}`; |
| 639 | } |
| 640 | |
| 641 | const RAW_OUTPUT_TEXT_FIELDS = ["content", "stdout", "stderr", "output"] as const; |
| 642 | |
| 643 | // `rawOutput` is provider-defined and, for terminal-shaped tools, mirrors the same |
| 644 | // cumulative text-growth problem as `content` (see the comment above). Bound its known |
| 645 | // text-bearing fields the same way so a chatty provider cannot smuggle unbounded output |
| 646 | // through this field instead. |
| 647 | function boundToolCallRawOutput(rawOutput: unknown): unknown { |
| 648 | if (!isRecord(rawOutput)) { |
| 649 | return rawOutput; |
| 650 | } |
| 651 | let changed = false; |
| 652 | const bounded: Record<string, unknown> = { ...rawOutput }; |
| 653 | for (const field of RAW_OUTPUT_TEXT_FIELDS) { |
| 654 | const value = rawOutput[field]; |
| 655 | if (typeof value === "string" && value.length > TOOL_CALL_CONTENT_MAX_CHARS) { |
| 656 | bounded[field] = boundToolCallOutputText(value); |
| 657 | changed = true; |
| 658 | } |
| 659 | } |
| 660 | return changed ? bounded : rawOutput; |
| 661 | } |
| 662 | |
| 663 | interface ExtractedToolCallContent { |
| 664 | readonly text: string | undefined; |
| 665 | readonly content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | undefined; |
| 666 | } |
| 667 | |
| 668 | function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | undefined { |
| 669 | if (entry.type !== "content" || entry.content.type !== "text") { |
| 670 | return undefined; |
| 671 | } |
| 672 | return entry.content.text; |
| 673 | } |
| 674 | |
| 675 | // Trim is used for display `text`, so whitespace-only (or whitespace-padded) entries never |
| 676 | // contribute to `chunks` and used to take the early returns with the original array. Bound |
| 677 | // each text entry independently so those paths cannot persist an unbounded terminal buffer |
| 678 | // on `toolCall.data.content` / `rawPayload`. |
| 679 | function boundToolCallContentEntries( |
| 680 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent>, |
| 681 | ): ReadonlyArray<EffectAcpSchema.ToolCallContent> { |
| 682 | let changed = false; |
| 683 | const bounded = content.map((entry) => { |
| 684 | const text = toolCallContentText(entry); |
| 685 | if (text === undefined || text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { |
| 686 | return entry; |
| 687 | } |
| 688 | changed = true; |
| 689 | const trimmed = text.trim(); |
| 690 | return { |
| 691 | type: "content", |
| 692 | content: { |
| 693 | type: "text", |
| 694 | text: boundToolCallOutputText(trimmed.length > 0 ? trimmed : text), |
| 695 | }, |
| 696 | } as const; |
| 697 | }); |
| 698 | return changed ? bounded : content; |
| 699 | } |
| 700 | |
| 701 | function extractTextContentFromToolCallContent( |
| 702 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined, |
| 703 | ): ExtractedToolCallContent { |
| 704 | if (!content) { |
| 705 | return { text: undefined, content: undefined }; |
| 706 | } |
| 707 | const chunks: Array<string> = []; |
| 708 | for (const entry of content) { |
| 709 | const text = toolCallContentText(entry)?.trim(); |
| 710 | if (text) { |
| 711 | chunks.push(text); |
| 712 | } |
| 713 | } |
| 714 | if (chunks.length === 0) { |
| 715 | return { text: undefined, content: boundToolCallContentEntries(content) }; |
| 716 | } |
| 717 | const joined = chunks.join("\n"); |
| 718 | if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) { |
| 719 | return { text: joined, content: boundToolCallContentEntries(content) }; |
| 720 | } |
| 721 | const bounded = boundToolCallOutputText(joined); |
| 722 | const tail = joined.slice(joined.length - TOOL_CALL_CONTENT_MAX_CHARS); |
| 723 | return { |
| 724 | text: bounded, |
| 725 | content: distributeRetainedTailAcrossContent(content, tail), |
| 726 | }; |
| 727 | } |
| 728 | |
| 729 | // Walk the original text entries from the joined tail window so a retained slice that |
| 730 | // spans entries around an image/diff stays on those entries. Non-text kinds keep their |
| 731 | // relative order; blank text entries are dropped; the truncation marker is prepended to |
| 732 | // the first remaining text entry. |
| 733 | function distributeRetainedTailAcrossContent( |
| 734 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent>, |
| 735 | tail: string, |
| 736 | ): ReadonlyArray<EffectAcpSchema.ToolCallContent> { |
| 737 | const textRanges: Array< |
| 738 | | { |
| 739 | readonly start: number; |
| 740 | readonly end: number; |
| 741 | readonly text: string; |
| 742 | } |
| 743 | | undefined |
| 744 | > = Array.from({ length: content.length }); |
| 745 | let offset = 0; |
| 746 | let seenText = false; |
| 747 | for (const [index, entry] of content.entries()) { |
| 748 | const text = toolCallContentText(entry)?.trim(); |
| 749 | if (!text) { |
| 750 | continue; |
| 751 | } |
| 752 | if (seenText) { |
| 753 | offset += 1; |
| 754 | } |
| 755 | seenText = true; |
| 756 | const start = offset; |
| 757 | const end = offset + text.length; |
| 758 | textRanges[index] = { start, end, text }; |
| 759 | offset = end; |
| 760 | } |
| 761 | const tailStart = Math.max(0, offset - tail.length); |
| 762 | let markerPending = true; |
| 763 | return content.flatMap((entry, index) => { |
| 764 | if (toolCallContentText(entry) === undefined) { |
| 765 | return [entry]; |
| 766 | } |
| 767 | const range = textRanges[index]; |
| 768 | if (range === undefined) { |
| 769 | return []; |
| 770 | } |
| 771 | const overlapStart = Math.max(range.start, tailStart); |
| 772 | const overlapEnd = Math.min(range.end, offset); |
| 773 | if (overlapEnd <= overlapStart) { |
| 774 | return []; |
| 775 | } |
| 776 | let piece = range.text.slice(overlapStart - range.start, overlapEnd - range.start); |
| 777 | if (markerPending) { |
| 778 | piece = `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${piece}`; |
| 779 | markerPending = false; |
| 780 | } |
| 781 | return [{ type: "content", content: { type: "text", text: piece } } as const]; |
| 782 | }); |
| 783 | } |
| 784 | |
| 785 | function normalizeToolKind(kind: unknown): string | undefined { |
| 786 | return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined; |
| 787 | } |
| 788 | |
| 789 | /** |
| 790 | * Map an ACP tool kind onto the canonical runtime item type used by the |
| 791 | * thread activity model. Unknown kinds fall back to a generic tool call. |
| 792 | */ |
| 793 | export function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { |
| 794 | switch (kind) { |
| 795 | case "read": |
| 796 | return "dynamic_tool_call"; |
| 797 | case "execute": |
| 798 | return "command_execution"; |
| 799 | case "edit": |
| 800 | case "delete": |
| 801 | case "move": |
| 802 | return "file_change"; |
| 803 | case "search": |
| 804 | case "fetch": |
| 805 | return "web_search"; |
| 806 | default: |
| 807 | return "dynamic_tool_call"; |
| 808 | } |
| 809 | } |
| 810 | |
| 811 | function makeToolCallState( |
| 812 | input: { |
| 813 | readonly toolCallId: string; |
| 814 | readonly title?: string | null | undefined; |
| 815 | readonly kind?: EffectAcpSchema.ToolKind | null | undefined; |
| 816 | readonly status?: EffectAcpSchema.ToolCallStatus | null | undefined; |
| 817 | readonly rawInput?: unknown; |
| 818 | readonly rawOutput?: unknown; |
| 819 | readonly content?: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined; |
| 820 | readonly locations?: ReadonlyArray<EffectAcpSchema.ToolCallLocation> | null | undefined; |
| 821 | readonly _meta?: unknown; |
| 822 | }, |
| 823 | options?: { |
| 824 | readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; |
| 825 | }, |
| 826 | ): AcpToolCallState | undefined { |
| 827 | const toolCallId = input.toolCallId.trim(); |
| 828 | if (!toolCallId) { |
| 829 | return undefined; |
| 830 | } |
| 831 | const title = input.title?.trim() || undefined; |
| 832 | const kind = normalizeToolKind(input.kind); |
| 833 | const command = extractToolCallCommand(input.rawInput, title, kind); |
| 834 | const extractedContent = extractTextContentFromToolCallContent(input.content); |
| 835 | const textContent = extractedContent.text; |
| 836 | const normalizedTitle = |
| 837 | title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" |
| 838 | ? title |
| 839 | : undefined; |
| 840 | const data: Record<string, unknown> = { toolCallId }; |
| 841 | if (kind) { |
| 842 | data.kind = kind; |
| 843 | } |
| 844 | if (title) { |
| 845 | // The agent's verbatim title. Presentation summarizes `title` on the |
| 846 | // state (e.g. a command line becomes "Ran command"), so identity checks |
| 847 | // such as MCP-fallback detection need the raw value preserved here. |
| 848 | data.title = title; |
| 849 | } |
| 850 | if (command) { |
| 851 | data.command = command; |
| 852 | } |
| 853 | if (input.rawInput !== undefined) { |
| 854 | data.rawInput = input.rawInput; |
| 855 | } |
| 856 | if (isRecord(input._meta)) { |
| 857 | // Some agents identify MCP calls only here (goose `goose.toolCall`, |
| 858 | // qwen `toolName`/`serverId`, claude-acp `claudeCode.toolName`). |
| 859 | data.meta = input._meta; |
| 860 | } |
| 861 | if (input.rawOutput !== undefined) { |
| 862 | data.rawOutput = boundToolCallRawOutput(input.rawOutput); |
| 863 | } |
| 864 | if (input.content != null) { |
| 865 | data.content = sanitizeAcpToolCallContent(extractedContent.content ?? input.content); |
| 866 | } |
| 867 | const locations = locationsFromToolCallInput(input); |
| 868 | if (locations !== undefined) { |
| 869 | data.locations = locations; |
| 870 | } |
| 871 | if (isRecord(input._meta)) { |
| 872 | data.meta = input._meta; |
| 873 | } |
| 874 | const fallbackDetail = command ?? normalizedTitle ?? textContent; |
| 875 | const hasPresentationSeed = |
| 876 | title !== undefined || |
| 877 | kind !== undefined || |
| 878 | command !== undefined || |
| 879 | normalizedTitle !== undefined || |
| 880 | textContent !== undefined; |
| 881 | const presentation = hasPresentationSeed |
| 882 | ? deriveToolActivityPresentation({ |
| 883 | itemType: canonicalItemTypeFromAcpToolKind(kind), |
| 884 | title, |
| 885 | detail: fallbackDetail, |
| 886 | data, |
| 887 | fallbackSummary: title ?? "Tool", |
| 888 | }) |
| 889 | : undefined; |
| 890 | const status = normalizeToolCallStatus(input.status, options?.fallbackStatus); |
| 891 | return { |
| 892 | toolCallId, |
| 893 | ...(kind ? { kind } : {}), |
| 894 | ...(presentation?.summary ? { title: presentation.summary } : {}), |
| 895 | ...(status ? { status } : {}), |
| 896 | ...(command ? { command } : {}), |
| 897 | ...(presentation?.detail ? { detail: presentation.detail } : {}), |
| 898 | data, |
| 899 | }; |
| 900 | } |
| 901 | |
| 902 | function parseTypedToolCallState( |
| 903 | event: AcpToolCallUpdate, |
| 904 | options?: { |
| 905 | readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; |
| 906 | }, |
| 907 | ): AcpToolCallState | undefined { |
| 908 | return makeToolCallState( |
| 909 | { |
| 910 | toolCallId: event.toolCallId, |
| 911 | title: event.title, |
| 912 | kind: event.kind, |
| 913 | status: event.status, |
| 914 | rawInput: event.rawInput, |
| 915 | rawOutput: event.rawOutput, |
| 916 | content: event.content, |
| 917 | locations: event.locations, |
| 918 | _meta: event._meta, |
| 919 | }, |
| 920 | options, |
| 921 | ); |
| 922 | } |
| 923 | |
| 924 | export function mergeToolCallState( |
| 925 | previous: AcpToolCallState | undefined, |
| 926 | next: AcpToolCallState, |
| 927 | ): AcpToolCallState { |
| 928 | const nextKind = next.kind ?? (typeof next.data.kind === "string" ? next.data.kind : undefined); |
| 929 | const kind = nextKind ?? previous?.kind; |
| 930 | const title = next.title ?? previous?.title; |
| 931 | const status = next.status ?? previous?.status; |
| 932 | const command = next.command ?? previous?.command; |
| 933 | const detail = next.detail ?? previous?.detail; |
| 934 | const data = mergeToolActivityData(previous?.data, next.data) ?? next.data; |
| 935 | return { |
| 936 | toolCallId: next.toolCallId, |
| 937 | ...(kind ? { kind } : {}), |
| 938 | ...(title ? { title } : {}), |
| 939 | ...(status ? { status } : {}), |
| 940 | ...(command ? { command } : {}), |
| 941 | ...(detail ? { detail } : {}), |
| 942 | data, |
| 943 | }; |
| 944 | } |
| 945 | |
| 946 | // Even with bounded content (see TOOL_CALL_CONTENT_MAX_CHARS above), a redrawing terminal |
| 947 | // can still shift its bounded tail window on nearly every notification, which would emit |
| 948 | // a runtime event per redraw. Coalesce those: only emit early when the tool call's detail |
| 949 | // has grown meaningfully since the last emission, otherwise batch up to a small number of |
| 950 | // skipped updates before emitting anyway, so the UI still gets periodic progress and the |
| 951 | // final (completed/failed) state is always emitted immediately. |
| 952 | const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256; |
| 953 | const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10; |
| 954 | |
| 955 | export interface AcpToolCallEmitDecisionInput { |
| 956 | readonly previous: AcpToolCallState | undefined; |
| 957 | readonly next: AcpToolCallState; |
| 958 | readonly lastEmittedDetailLength: number | undefined; |
| 959 | readonly skippedSinceEmit: number; |
| 960 | } |
| 961 | |
| 962 | export interface AcpToolCallEmitDecision { |
| 963 | readonly emit: boolean; |
| 964 | readonly skippedSinceEmit: number; |
| 965 | } |
| 966 | |
| 967 | function toolCallOutputUnchanged(previous: AcpToolCallState, next: AcpToolCallState): boolean { |
| 968 | return ( |
| 969 | previous.data.content === next.data.content && previous.data.rawOutput === next.data.rawOutput |
| 970 | ); |
| 971 | } |
| 972 | |
| 973 | // Command tools keep `detail` equal to the command, so live stdout lives on |
| 974 | // `data.content` / `data.rawOutput`. Measure that too, otherwise coalescing never |
| 975 | // sees growth and in-progress output is held until completed/failed. |
| 976 | export function toolCallProgressLength(state: AcpToolCallState): number { |
| 977 | let contentChars = 0; |
| 978 | const content = state.data.content; |
| 979 | if (Array.isArray(content)) { |
| 980 | for (const entry of content) { |
| 981 | if (!isRecord(entry)) { |
| 982 | continue; |
| 983 | } |
| 984 | const text = toolCallContentText(entry as EffectAcpSchema.ToolCallContent); |
| 985 | if (text) { |
| 986 | contentChars += text.length; |
| 987 | } |
| 988 | } |
| 989 | } |
| 990 | let rawOutputChars = 0; |
| 991 | const rawOutput = state.data.rawOutput; |
| 992 | if (isRecord(rawOutput)) { |
| 993 | for (const field of RAW_OUTPUT_TEXT_FIELDS) { |
| 994 | const value = rawOutput[field]; |
| 995 | if (typeof value === "string") { |
| 996 | rawOutputChars += value.length; |
| 997 | } |
| 998 | } |
| 999 | } |
| 1000 | return Math.max(state.detail?.length ?? 0, contentChars, rawOutputChars); |
| 1001 | } |
| 1002 | |
| 1003 | function toolCallContentTexts(state: AcpToolCallState): string { |
| 1004 | const content = state.data.content; |
| 1005 | if (!Array.isArray(content)) return ""; |
| 1006 | return content |
| 1007 | .map((entry) => |
| 1008 | isRecord(entry) ? (toolCallContentText(entry as EffectAcpSchema.ToolCallContent) ?? "") : "", |
| 1009 | ) |
| 1010 | .join("\u0000"); |
| 1011 | } |
| 1012 | |
| 1013 | // The output a user watches: detail, text content, and any `rawOutput`. A |
| 1014 | // streamed diff or `rawInput` is not part of it. |
| 1015 | export function toolCallVisibleOutputChanged( |
| 1016 | previous: AcpToolCallState, |
| 1017 | next: AcpToolCallState, |
| 1018 | ): boolean { |
| 1019 | return ( |
| 1020 | previous.detail !== next.detail || |
| 1021 | toolCallContentTexts(previous) !== toolCallContentTexts(next) || |
| 1022 | JSON.stringify(previous.data.rawOutput) !== JSON.stringify(next.data.rawOutput) |
| 1023 | ); |
| 1024 | } |
| 1025 | |
| 1026 | export function decideToolCallUpdateEmission( |
| 1027 | input: AcpToolCallEmitDecisionInput, |
| 1028 | ): AcpToolCallEmitDecision { |
| 1029 | const { previous, next, lastEmittedDetailLength, skippedSinceEmit } = input; |
| 1030 | if (next.status === "completed" || next.status === "failed") { |
| 1031 | return { emit: true, skippedSinceEmit: 0 }; |
| 1032 | } |
| 1033 | if (previous === undefined || previous.title !== next.title || previous.status !== next.status) { |
| 1034 | return { emit: true, skippedSinceEmit: 0 }; |
| 1035 | } |
| 1036 | if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) { |
| 1037 | return { emit: false, skippedSinceEmit }; |
| 1038 | } |
| 1039 | const progressLength = toolCallProgressLength(next); |
| 1040 | const grewMeaningfully = |
| 1041 | lastEmittedDetailLength === undefined || |
| 1042 | Math.abs(progressLength - lastEmittedDetailLength) >= TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS; |
| 1043 | if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) { |
| 1044 | return { emit: true, skippedSinceEmit: 0 }; |
| 1045 | } |
| 1046 | return { emit: false, skippedSinceEmit: skippedSinceEmit + 1 }; |
| 1047 | } |
| 1048 | |
| 1049 | export interface AcpMcpToolCallIdentity { |
| 1050 | readonly server: string; |
| 1051 | readonly tool: string; |
| 1052 | readonly input?: Record<string, unknown>; |
| 1053 | } |
| 1054 | |
| 1055 | /** Matches an invocation of T3's `acp-mcp-call` bridge fallback CLI. */ |
| 1056 | const ACP_MCP_FALLBACK_CALL = /(?:^|[\s"'=])acp-mcp-call[\s"']+([A-Za-z0-9_.-]+)(?:\s+(.+?))?\s*$/u; |
| 1057 | |
| 1058 | function acpMcpFallbackInput(value: string | undefined): Record<string, unknown> | undefined { |
| 1059 | if (value === undefined) return undefined; |
| 1060 | const trimmed = value.trim(); |
| 1061 | const candidates = [ |
| 1062 | trimmed, |
| 1063 | ...(trimmed.startsWith("'") && trimmed.endsWith("'") ? [trimmed.slice(1, -1)] : []), |
| 1064 | ]; |
| 1065 | for (const candidate of candidates) { |
| 1066 | try { |
| 1067 | const parsed: unknown = JSON.parse(candidate); |
| 1068 | const unwrapped = typeof parsed === "string" ? JSON.parse(parsed) : parsed; |
| 1069 | if (isRecord(unwrapped)) return unwrapped; |
| 1070 | } catch { |
| 1071 | // ACP agents choose their own shell quoting. An unrecognized form keeps |
| 1072 | // the MCP identity but leaves the input empty instead of misreporting it. |
| 1073 | } |
| 1074 | } |
| 1075 | return undefined; |
| 1076 | } |
| 1077 | |
| 1078 | /** |
| 1079 | * Agents flatten injected MCP tools into model-facing function names with no |
| 1080 | * shared convention (survey of the 2026-08 registry builds): Kilo and |
| 1081 | * opencode use `t3-code_<tool>`, claude-acp and qwen `mcp__t3-code__<tool>`, |
| 1082 | * Amp `mcp__t3_code__<tool>` (hyphens mangled), droid `t3-code___<tool>`, |
| 1083 | * Copilot `t3-code-<tool>`, cline appends `: <args json>`. T3 always injects |
| 1084 | * its server as "t3-code", and matches are additionally gated on the known |
| 1085 | * T3 tool inventory, so the separator match can stay loose. |
| 1086 | */ |
| 1087 | const T3_MCP_TITLE_CALL = |
| 1088 | /^(?:mcp[-_]{1,2})?t3[-_ ]?code[-_.:/ ]{1,3}(?<tool>[A-Za-z0-9][A-Za-z0-9_.-]*)(?::.*)?$/i; |
| 1089 | |
| 1090 | /** |
| 1091 | * Gemini CLI titles injected MCP calls "<tool> (<server> MCP Server)" and |
| 1092 | * qwen-code appends ": <args json>" to the same template; Auggie namespaces |
| 1093 | * tool-first as "<tool>_t3-code". |
| 1094 | */ |
| 1095 | const T3_MCP_TITLE_SUFFIX_CALL = |
| 1096 | /^(?<tool>[A-Za-z0-9][A-Za-z0-9_.-]*?)(?: \(t3[-_ ]?code MCP Server\)(?::|$)|[-_.]t3[-_ ]?code$)/i; |
| 1097 | |
| 1098 | /** |
| 1099 | * glm-acp-agent and Kimi CLI register injected MCP tools under their bare |
| 1100 | * names; Kimi additionally appends ": <raw args json>". Safe only because the |
| 1101 | * match is gated on the known T3 tool inventory. |
| 1102 | */ |
| 1103 | const T3_MCP_BARE_TITLE_CALL = /^(?<tool>[A-Za-z0-9_]+)(?::\s|$)/; |
| 1104 | |
| 1105 | /** |
| 1106 | * Best-effort recovery of MCP identity from a generic ACP tool call. |
| 1107 | * |
| 1108 | * ACP has no typed MCP tool-call item, so agents surface MCP calls in |
| 1109 | * agent-specific shapes: codex-acp tags execute calls with |
| 1110 | * `rawInput.server`/`rawInput.tool`, while agents on T3's terminal fallback |
| 1111 | * run the `acp-mcp-call <tool>` CLI through their command or an embedded |
| 1112 | * client terminal. Recovered identity lets the projection render the same |
| 1113 | * branded MCP item that native providers produce. |
| 1114 | */ |
| 1115 | export function extractMcpToolCallIdentity( |
| 1116 | toolCall: AcpToolCallState, |
| 1117 | options?: { |
| 1118 | /** Command lines of client terminals embedded in this tool call. */ |
| 1119 | readonly embeddedTerminalCommands?: ReadonlyArray<string>; |
| 1120 | }, |
| 1121 | ): AcpMcpToolCallIdentity | undefined { |
| 1122 | const rawInput = isRecord(toolCall.data.rawInput) ? toolCall.data.rawInput : undefined; |
| 1123 | const meta = isRecord(toolCall.data.meta) ? toolCall.data.meta : undefined; |
| 1124 | const server = typeof rawInput?.server === "string" ? rawInput.server.trim() : ""; |
| 1125 | const tool = typeof rawInput?.tool === "string" ? rawInput.tool.trim() : ""; |
| 1126 | if (meta?.is_mcp_tool_call === true && server.length > 0 && tool.length > 0) { |
| 1127 | return { server, tool }; |
| 1128 | } |
| 1129 | // Agents without tagged rawInput identify their MCP calls through _meta |
| 1130 | // (goose, qwen, claude-acp) or only through the namespaced function name |
| 1131 | // in the title. The verbatim wire title survives merges even when a later |
| 1132 | // titleless or LLM-enriched update replaces the presentation title, so |
| 1133 | // match those rather than the summarized state title. |
| 1134 | const claudeCode = isRecord(meta?.claudeCode) ? meta.claudeCode : undefined; |
| 1135 | const gooseToolCall = isRecord(meta?.goose) |
| 1136 | ? isRecord(meta.goose.toolCall) |
| 1137 | ? meta.goose.toolCall |
| 1138 | : undefined |
| 1139 | : undefined; |
| 1140 | // qwen asserts the origin server explicitly, so any known tool suffix in |
| 1141 | // its toolName identifies the call even under future prefix formats. |
| 1142 | const metaServerId = typeof meta?.serverId === "string" ? meta.serverId.trim() : ""; |
| 1143 | const metaToolName = typeof meta?.toolName === "string" ? meta.toolName.trim() : ""; |
| 1144 | if (/^t3[-_ ]?code$/i.test(metaServerId) && metaToolName.length > 0) { |
| 1145 | for (const knownTool of T3_MCP_TOOL_NAMES) { |
| 1146 | const boundary = metaToolName.length - knownTool.length - 1; |
| 1147 | if ( |
| 1148 | metaToolName === knownTool || |
| 1149 | (metaToolName.endsWith(knownTool) && |
| 1150 | boundary >= 0 && |
| 1151 | !/[A-Za-z0-9]/.test(metaToolName.charAt(boundary))) |
| 1152 | ) { |
| 1153 | return { server: "t3-code", tool: knownTool }; |
| 1154 | } |
| 1155 | } |
| 1156 | } |
| 1157 | const gooseExtension = |
| 1158 | typeof gooseToolCall?.extensionName === "string" ? gooseToolCall.extensionName.trim() : ""; |
| 1159 | const assertsForeignOrigin = |
| 1160 | (metaServerId.length > 0 && !/^t3[-_ ]?code$/i.test(metaServerId)) || |
| 1161 | (gooseExtension.length > 0 && !/^t3[-_ ]?code$/i.test(gooseExtension)); |
| 1162 | // A foreign origin never brands as T3. qwen's serverId marks a real MCP |
| 1163 | // server, but goose reports its built-in extensions (developer__shell, |
| 1164 | // edits) the same way as user MCP servers, so goose stays unclassified and |
| 1165 | // keeps its command and file-change projections. |
| 1166 | if (assertsForeignOrigin) { |
| 1167 | if (metaServerId.length === 0 || metaToolName.length === 0) return undefined; |
| 1168 | const prefix = [`mcp__${metaServerId}__`, `mcp::${metaServerId}::`].find((prefix) => |
| 1169 | metaToolName.startsWith(prefix), |
| 1170 | ); |
| 1171 | const tool = prefix === undefined ? metaToolName : metaToolName.slice(prefix.length); |
| 1172 | return tool ? { server: metaServerId, tool } : undefined; |
| 1173 | } |
| 1174 | const candidates = [ |
| 1175 | meta?.toolName, |
| 1176 | claudeCode?.toolName, |
| 1177 | gooseToolCall?.toolName, |
| 1178 | toolCall.data.title, |
| 1179 | ].filter((value): value is string => typeof value === "string"); |
| 1180 | for (const candidate of candidates) { |
| 1181 | const trimmed = candidate.trim(); |
| 1182 | const qualified = /^mcp__(.+?)__(.+)$/i.exec(trimmed); |
| 1183 | if (qualified?.[1] && qualified[2] && !/^t3[-_ ]?code$/i.test(qualified[1])) |
| 1184 | return { server: qualified[1], tool: qualified[2] }; |
| 1185 | const match = |
| 1186 | T3_MCP_TITLE_CALL.exec(trimmed) ?? |
| 1187 | T3_MCP_TITLE_SUFFIX_CALL.exec(trimmed) ?? |
| 1188 | T3_MCP_BARE_TITLE_CALL.exec(trimmed); |
| 1189 | const candidateTool = match?.groups?.tool; |
| 1190 | if (candidateTool !== undefined && T3_MCP_TOOL_NAMES.has(candidateTool)) { |
| 1191 | return { server: "t3-code", tool: candidateTool }; |
| 1192 | } |
| 1193 | } |
| 1194 | const commands = [ |
| 1195 | ...(toolCall.command === undefined ? [] : [toolCall.command]), |
| 1196 | // pi-acp reports the exec command line only as the verbatim title. |
| 1197 | ...(typeof toolCall.data.title === "string" ? [toolCall.data.title] : []), |
| 1198 | ...(options?.embeddedTerminalCommands ?? []), |
| 1199 | ]; |
| 1200 | for (const command of commands) { |
| 1201 | const match = ACP_MCP_FALLBACK_CALL.exec(command); |
| 1202 | if (match?.[1] !== undefined) { |
| 1203 | // The acp-mcp-call CLI exists only as T3's bridge fallback, so the |
| 1204 | // server identity is T3's by construction. |
| 1205 | const input = acpMcpFallbackInput(match[2]); |
| 1206 | return { server: "t3-code", tool: match[1], ...(input === undefined ? {} : { input }) }; |
| 1207 | } |
| 1208 | } |
| 1209 | return undefined; |
| 1210 | } |
| 1211 | |
| 1212 | /** |
| 1213 | * Terminal ids embedded in a raw tool_call update, captured before |
| 1214 | * {@link resolveEmbeddedTerminalContent} rewrites them into plain text, so the |
| 1215 | * projection can still resolve the terminal's command line. |
| 1216 | */ |
| 1217 | export function embeddedTerminalIdsFromSessionUpdate( |
| 1218 | notification: EffectAcpSchema.SessionNotification, |
| 1219 | ): { readonly toolCallId: string; readonly terminalIds: ReadonlyArray<string> } | undefined { |
| 1220 | const update = notification.update; |
| 1221 | if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { |
| 1222 | return undefined; |
| 1223 | } |
| 1224 | const terminalIds = (update.content ?? []) |
| 1225 | .filter((entry) => entry.type === "terminal") |
| 1226 | .map((entry) => entry.terminalId); |
| 1227 | return terminalIds.length === 0 ? undefined : { toolCallId: update.toolCallId, terminalIds }; |
| 1228 | } |
| 1229 | |
| 1230 | export function parsePermissionRequest( |
| 1231 | params: EffectAcpSchema.RequestPermissionRequest, |
| 1232 | ): AcpPermissionRequest { |
| 1233 | const toolCall = makeToolCallState( |
| 1234 | { |
| 1235 | toolCallId: params.toolCall.toolCallId, |
| 1236 | title: params.toolCall.title, |
| 1237 | kind: params.toolCall.kind, |
| 1238 | status: params.toolCall.status, |
| 1239 | rawInput: params.toolCall.rawInput, |
| 1240 | rawOutput: params.toolCall.rawOutput, |
| 1241 | content: params.toolCall.content, |
| 1242 | locations: params.toolCall.locations, |
| 1243 | }, |
| 1244 | { fallbackStatus: "pending" }, |
| 1245 | ); |
| 1246 | const kind = normalizeToolKind(params.toolCall.kind) ?? "unknown"; |
| 1247 | const detail = |
| 1248 | toolCall?.command ?? |
| 1249 | toolCall?.title ?? |
| 1250 | toolCall?.detail ?? |
| 1251 | (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined); |
| 1252 | return { |
| 1253 | kind, |
| 1254 | ...(detail ? { detail } : {}), |
| 1255 | ...(toolCall ? { toolCall } : {}), |
| 1256 | }; |
| 1257 | } |
| 1258 | |
| 1259 | export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotification): boolean { |
| 1260 | const meta = params._meta; |
| 1261 | return isRecord(meta) && meta.isReplay === true; |
| 1262 | } |
| 1263 | |
| 1264 | /** Replay chunks and substantive updates during session/load; not Grok keepalives. */ |
| 1265 | export function sessionUpdateCountsAsLoadReplayActivity( |
| 1266 | params: EffectAcpSchema.SessionNotification, |
| 1267 | gatedSessionId?: string, |
| 1268 | ): boolean { |
| 1269 | if (gatedSessionId !== undefined && params.sessionId !== gatedSessionId) { |
| 1270 | return false; |
| 1271 | } |
| 1272 | if (sessionUpdateIsReplay(params)) return true; |
| 1273 | const update = params.update; |
| 1274 | switch (update.sessionUpdate) { |
| 1275 | case "agent_message_chunk": |
| 1276 | case "agent_thought_chunk": |
| 1277 | return (acpContentBlockDisplayText(update.content)?.length ?? 0) > 0; |
| 1278 | case "agent_message": |
| 1279 | case "agent_thought": |
| 1280 | return (update.content ?? []).some( |
| 1281 | (content) => (acpContentBlockDisplayText(content)?.length ?? 0) > 0, |
| 1282 | ); |
| 1283 | case "tool_call": |
| 1284 | case "tool_call_update": |
| 1285 | case "plan": |
| 1286 | case "plan_update": |
| 1287 | case "plan_removed": |
| 1288 | case "available_commands_update": |
| 1289 | case "current_mode_update": |
| 1290 | case "config_option_update": |
| 1291 | case "session_info_update": |
| 1292 | case "usage_update": |
| 1293 | case "compaction_update": |
| 1294 | case "compaction_summary_chunk": |
| 1295 | return true; |
| 1296 | default: |
| 1297 | return false; |
| 1298 | } |
| 1299 | } |
| 1300 | |
| 1301 | export interface SessionLoadGate { |
| 1302 | readonly active: boolean; |
| 1303 | /** Only notifications for this session refresh load-replay idle activity. */ |
| 1304 | readonly sessionId: string; |
| 1305 | readonly lastActivityAtMillis: number | undefined; |
| 1306 | readonly idleGap: Duration.Duration; |
| 1307 | readonly initializeResult: EffectAcpSchema.InitializeResponse; |
| 1308 | readonly candidateConfigOptions?: ReadonlyArray<EffectAcpSchema.SessionConfigOption>; |
| 1309 | } |
| 1310 | |
| 1311 | export const waitForSessionLoadReplayIdle = (input: { |
| 1312 | readonly gateRef: Ref.Ref<Option.Option<SessionLoadGate>>; |
| 1313 | }): Effect.Effect<EffectAcpSchema.LoadSessionResponse, never> => |
| 1314 | Effect.gen(function* () { |
| 1315 | const pollInterval = Duration.millis(25); |
| 1316 | while (true) { |
| 1317 | const gate = yield* Ref.get(input.gateRef); |
| 1318 | if ( |
| 1319 | Option.isSome(gate) && |
| 1320 | gate.value.active && |
| 1321 | gate.value.lastActivityAtMillis !== undefined |
| 1322 | ) { |
| 1323 | const idleGapMillis = Duration.toMillis(gate.value.idleGap); |
| 1324 | const nowMillis = yield* Clock.currentTimeMillis; |
| 1325 | if (nowMillis - gate.value.lastActivityAtMillis >= idleGapMillis) { |
| 1326 | return syntheticLoadSessionResponseFromInitialize(gate.value.initializeResult); |
| 1327 | } |
| 1328 | } |
| 1329 | yield* Effect.sleep(pollInterval); |
| 1330 | } |
| 1331 | }); |
| 1332 | |
| 1333 | /** |
| 1334 | * Model state some agents (Grok) advertise in `initialize._meta.modelState`, before any |
| 1335 | * session exists. Undefined when the agent does not advertise it or the shape is unknown. |
| 1336 | */ |
| 1337 | export function sessionModelStateFromInitialize( |
| 1338 | initializeResult: EffectAcpSchema.InitializeResponse, |
| 1339 | ): EffectAcpSchema.SessionModelState | undefined { |
| 1340 | const meta = initializeResult._meta; |
| 1341 | const modelState = isRecord(meta) ? meta.modelState : undefined; |
| 1342 | return isSessionModelState(modelState) ? modelState : undefined; |
| 1343 | } |
| 1344 | |
| 1345 | export function syntheticLoadSessionResponseFromInitialize( |
| 1346 | initializeResult: EffectAcpSchema.InitializeResponse, |
| 1347 | ): EffectAcpSchema.LoadSessionResponse { |
| 1348 | const meta = initializeResult._meta; |
| 1349 | const modeState = isRecord(meta) ? meta.modeState : undefined; |
| 1350 | |
| 1351 | const modes = isSessionModeState(modeState) ? modeState : undefined; |
| 1352 | |
| 1353 | return { |
| 1354 | ...(modes ? { modes } : {}), |
| 1355 | _meta: { |
| 1356 | t3SessionLoadReady: "replay_idle", |
| 1357 | }, |
| 1358 | }; |
| 1359 | } |
| 1360 | |
| 1361 | // The parsed AcpToolCallState already carries bounded content (see makeToolCallState / |
| 1362 | // extractTextContentFromToolCallContent above), but the raw JSON-RPC notification is also |
| 1363 | // threaded through as `rawPayload` for logging/debugging and ends up persisted on the |
| 1364 | // runtime event. Substitute the same bounded `content`/`rawOutput` there so an oversized |
| 1365 | // cumulative update cannot smuggle the unbounded buffer back in through the raw payload. |
| 1366 | function boundToolCallRawPayload( |
| 1367 | params: EffectAcpSchema.SessionNotification, |
| 1368 | update: AcpToolCallUpdate, |
| 1369 | toolCall: AcpToolCallState, |
| 1370 | ): unknown { |
| 1371 | const boundedContent = toolCall.data.content; |
| 1372 | const boundedRawOutput = toolCall.data.rawOutput; |
| 1373 | const contentBounded = update.content !== undefined && boundedContent !== update.content; |
| 1374 | const rawOutputBounded = update.rawOutput !== undefined && boundedRawOutput !== update.rawOutput; |
| 1375 | if (!contentBounded && !rawOutputBounded) { |
| 1376 | return params; |
| 1377 | } |
| 1378 | return { |
| 1379 | ...params, |
| 1380 | update: { |
| 1381 | ...update, |
| 1382 | ...(contentBounded ? { content: boundedContent } : {}), |
| 1383 | ...(rawOutputBounded ? { rawOutput: boundedRawOutput } : {}), |
| 1384 | }, |
| 1385 | }; |
| 1386 | } |
| 1387 | |
| 1388 | export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { |
| 1389 | readonly modeId?: string; |
| 1390 | readonly events: ReadonlyArray<AcpParsedSessionEvent>; |
| 1391 | } { |
| 1392 | const upd = params.update; |
| 1393 | const events: Array<AcpParsedSessionEvent> = []; |
| 1394 | let modeId: string | undefined; |
| 1395 | |
| 1396 | switch (upd.sessionUpdate) { |
| 1397 | case "config_option_update": { |
| 1398 | const modes = parseSessionModeState({ configOptions: upd.configOptions }); |
| 1399 | if (modes !== undefined) { |
| 1400 | modeId = modes.currentModeId; |
| 1401 | events.push({ _tag: "ModeChanged", modeId }); |
| 1402 | } |
| 1403 | events.push({ |
| 1404 | _tag: "ConfigOptionsUpdated", |
| 1405 | configOptions: upd.configOptions, |
| 1406 | rawPayload: params, |
| 1407 | }); |
| 1408 | break; |
| 1409 | } |
| 1410 | case "available_commands_update": { |
| 1411 | events.push({ |
| 1412 | _tag: "AvailableCommandsUpdated", |
| 1413 | availableCommands: upd.availableCommands, |
| 1414 | rawPayload: params, |
| 1415 | }); |
| 1416 | break; |
| 1417 | } |
| 1418 | case "current_mode_update": { |
| 1419 | modeId = upd.currentModeId.trim(); |
| 1420 | if (modeId) { |
| 1421 | events.push({ |
| 1422 | _tag: "ModeChanged", |
| 1423 | modeId, |
| 1424 | }); |
| 1425 | } |
| 1426 | break; |
| 1427 | } |
| 1428 | case "plan": { |
| 1429 | const plan = upd.entries.map((entry, index) => ({ |
| 1430 | step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, |
| 1431 | status: normalizePlanStepStatus(entry.status), |
| 1432 | })); |
| 1433 | if (plan.length > 0) { |
| 1434 | events.push({ |
| 1435 | _tag: "PlanUpdated", |
| 1436 | payload: { |
| 1437 | nativePlanId: "legacy", |
| 1438 | kind: "items", |
| 1439 | plan, |
| 1440 | }, |
| 1441 | rawPayload: params, |
| 1442 | }); |
| 1443 | } |
| 1444 | break; |
| 1445 | } |
| 1446 | case "plan_update": { |
| 1447 | const nativePlanId = upd.plan.planId.trim(); |
| 1448 | if (nativePlanId.length === 0) { |
| 1449 | break; |
| 1450 | } |
| 1451 | if (upd.plan.type === "items" && "entries" in upd.plan && Array.isArray(upd.plan.entries)) { |
| 1452 | const entries: ReadonlyArray<unknown> = upd.plan.entries; |
| 1453 | const plan = entries.flatMap((entry, index) => { |
| 1454 | if (!isRecord(entry) || typeof entry.content !== "string") return []; |
| 1455 | const status = typeof entry.status === "string" ? entry.status : "pending"; |
| 1456 | return [ |
| 1457 | { |
| 1458 | step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`, |
| 1459 | status: normalizePlanStepStatus(status), |
| 1460 | }, |
| 1461 | ]; |
| 1462 | }); |
| 1463 | events.push({ |
| 1464 | _tag: "PlanUpdated", |
| 1465 | payload: { nativePlanId, kind: "items", plan }, |
| 1466 | rawPayload: params, |
| 1467 | }); |
| 1468 | } else if ( |
| 1469 | upd.plan.type === "markdown" && |
| 1470 | "content" in upd.plan && |
| 1471 | typeof upd.plan.content === "string" |
| 1472 | ) { |
| 1473 | events.push({ |
| 1474 | _tag: "PlanUpdated", |
| 1475 | payload: { nativePlanId, kind: "markdown", markdown: upd.plan.content }, |
| 1476 | rawPayload: params, |
| 1477 | }); |
| 1478 | } else if ( |
| 1479 | upd.plan.type === "file" && |
| 1480 | "uri" in upd.plan && |
| 1481 | typeof upd.plan.uri === "string" |
| 1482 | ) { |
| 1483 | events.push({ |
| 1484 | _tag: "PlanUpdated", |
| 1485 | payload: { nativePlanId, kind: "file", uri: upd.plan.uri }, |
| 1486 | rawPayload: params, |
| 1487 | }); |
| 1488 | } else { |
| 1489 | events.push({ |
| 1490 | _tag: "PlanUpdated", |
| 1491 | payload: { nativePlanId, kind: "unknown", contentType: upd.plan.type }, |
| 1492 | rawPayload: params, |
| 1493 | }); |
| 1494 | } |
| 1495 | break; |
| 1496 | } |
| 1497 | case "plan_removed": { |
| 1498 | const nativePlanId = upd.planId.trim(); |
| 1499 | if (nativePlanId.length > 0) { |
| 1500 | events.push({ |
| 1501 | _tag: "PlanUpdated", |
| 1502 | payload: { nativePlanId, kind: "removed" }, |
| 1503 | rawPayload: params, |
| 1504 | }); |
| 1505 | } |
| 1506 | break; |
| 1507 | } |
| 1508 | case "tool_call": { |
| 1509 | const toolCall = parseTypedToolCallState(upd, { |
| 1510 | fallbackStatus: "pending", |
| 1511 | }); |
| 1512 | if (toolCall) { |
| 1513 | events.push({ |
| 1514 | _tag: "ToolCallUpdated", |
| 1515 | toolCall, |
| 1516 | rawPayload: boundToolCallRawPayload(params, upd, toolCall), |
| 1517 | }); |
| 1518 | } |
| 1519 | break; |
| 1520 | } |
| 1521 | case "tool_call_update": { |
| 1522 | const toolCall = parseTypedToolCallState(upd); |
| 1523 | if (toolCall) { |
| 1524 | events.push({ |
| 1525 | _tag: "ToolCallUpdated", |
| 1526 | toolCall, |
| 1527 | rawPayload: boundToolCallRawPayload(params, upd, toolCall), |
| 1528 | }); |
| 1529 | } |
| 1530 | break; |
| 1531 | } |
| 1532 | case "agent_message_chunk": { |
| 1533 | const text = acpContentBlockDisplayText(upd.content); |
| 1534 | if (text !== undefined && text.length > 0) { |
| 1535 | events.push({ |
| 1536 | _tag: "ContentDelta", |
| 1537 | ...(upd.messageId ? { messageId: upd.messageId } : {}), |
| 1538 | text, |
| 1539 | rawPayload: params, |
| 1540 | }); |
| 1541 | } |
| 1542 | break; |
| 1543 | } |
| 1544 | case "agent_thought_chunk": { |
| 1545 | if (upd.content.type === "text" && upd.content.text.length > 0) { |
| 1546 | events.push({ |
| 1547 | _tag: "ThoughtDelta", |
| 1548 | text: upd.content.text, |
| 1549 | rawPayload: params, |
| 1550 | }); |
| 1551 | } |
| 1552 | break; |
| 1553 | } |
| 1554 | case "compaction_update": { |
| 1555 | const summary = (upd.summary ?? []) |
| 1556 | .flatMap((content) => { |
| 1557 | const text = acpContentBlockDisplayText(content); |
| 1558 | return text === undefined || text.length === 0 ? [] : [text]; |
| 1559 | }) |
| 1560 | .join("\n"); |
| 1561 | const status = |
| 1562 | upd.status === "completed" |
| 1563 | ? ("completed" as const) |
| 1564 | : upd.status === "failed" || upd.status === "cancelled" |
| 1565 | ? ("failed" as const) |
| 1566 | : ("inProgress" as const); |
| 1567 | const toolCall = makeToolCallState({ |
| 1568 | toolCallId: `acp-compaction:${upd.compactionId}`, |
| 1569 | title: "Compact context", |
| 1570 | kind: "think", |
| 1571 | status, |
| 1572 | rawOutput: (upd.error ?? summary) || undefined, |
| 1573 | _meta: upd._meta, |
| 1574 | }); |
| 1575 | if (toolCall !== undefined) { |
| 1576 | events.push({ _tag: "ToolCallUpdated", toolCall, rawPayload: params }); |
| 1577 | } |
| 1578 | break; |
| 1579 | } |
| 1580 | case "compaction_summary_chunk": { |
| 1581 | const text = acpContentBlockDisplayText(upd.content); |
| 1582 | const toolCall = makeToolCallState({ |
| 1583 | toolCallId: `acp-compaction:${upd.compactionId}`, |
| 1584 | title: "Compact context", |
| 1585 | kind: "think", |
| 1586 | status: "inProgress", |
| 1587 | ...(text === undefined ? {} : { rawOutput: text }), |
| 1588 | _meta: upd._meta, |
| 1589 | }); |
| 1590 | if (toolCall !== undefined) { |
| 1591 | events.push({ _tag: "ToolCallUpdated", toolCall, rawPayload: params }); |
| 1592 | } |
| 1593 | break; |
| 1594 | } |
| 1595 | case "state_update": { |
| 1596 | if (upd.state === "idle" && upd.usage !== undefined && upd.usage !== null) { |
| 1597 | events.push({ |
| 1598 | _tag: "UsageUpdated", |
| 1599 | usage: { usedTokens: upd.usage.totalTokens }, |
| 1600 | rawPayload: params, |
| 1601 | }); |
| 1602 | } |
| 1603 | break; |
| 1604 | } |
| 1605 | case "usage_update": { |
| 1606 | const currency = upd.cost?.currency.trim().slice(0, 32); |
| 1607 | events.push({ |
| 1608 | _tag: "UsageUpdated", |
| 1609 | usage: { |
| 1610 | usedTokens: upd.used, |
| 1611 | ...(upd.size > 0 ? { maxTokens: upd.size } : {}), |
| 1612 | ...(upd.cost && currency ? { cost: { amount: upd.cost.amount, currency } } : {}), |
| 1613 | }, |
| 1614 | rawPayload: params, |
| 1615 | }); |
| 1616 | break; |
| 1617 | } |
| 1618 | case "session_info_update": { |
| 1619 | const title = upd.title?.trim().slice(0, 1_024); |
| 1620 | const updatedAt = upd.updatedAt?.trim().slice(0, 128); |
| 1621 | events.push({ |
| 1622 | _tag: "SessionInfoUpdated", |
| 1623 | metadata: { |
| 1624 | ...(upd.title === null ? { title: null } : title ? { title } : {}), |
| 1625 | ...(upd.updatedAt === null ? { updatedAt: null } : updatedAt ? { updatedAt } : {}), |
| 1626 | }, |
| 1627 | rawPayload: params, |
| 1628 | }); |
| 1629 | break; |
| 1630 | } |
| 1631 | case "_t3_unknown": { |
| 1632 | events.push({ |
| 1633 | _tag: "UnknownUpdate", |
| 1634 | updateType: boundedContentMetadata(upd.originalSessionUpdate, 128) || "unknown", |
| 1635 | rawPayload: params, |
| 1636 | }); |
| 1637 | break; |
| 1638 | } |
| 1639 | default: |
| 1640 | break; |
| 1641 | } |
| 1642 | |
| 1643 | return { ...(modeId !== undefined ? { modeId } : {}), events }; |
| 1644 | } |