1import * as Clock from "effect/Clock";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";
7import * as EffectAcpSchema from "effect-acp/compat";
8import * as EffectAcpSchemaV1 from "effect-acp/schema-v1";
9import {
10 deriveToolActivityPresentation,
11 mergeToolActivityData,
12} from "@t3tools/shared/toolActivity";
13import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation";
14import type {
15 OrchestrationV2ProviderThreadNativeMetadata,
16 ThreadTokenUsageSnapshot,
17 ToolLifecycleItemType,
18} from "@t3tools/contracts";
19
20function 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.
26const isSessionModelState = Schema.is(EffectAcpSchema.SessionModelState);
27const isSessionModeState = Schema.is(EffectAcpSchemaV1.SessionModeState);
28
29export interface AcpSessionMode {
30 readonly id: string;
31 readonly name: string;
32 readonly description?: string;
33}
34
35export interface AcpSessionModeState {
36 readonly currentModeId: string;
37 readonly availableModes: ReadonlyArray<AcpSessionMode>;
38}
39
40export 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
50export 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
59type AcpAgentTerminalUpdate = Extract<
60 EffectAcpSchema.SessionUpdate,
61 { readonly sessionUpdate: "terminal_update" | "terminal_output_chunk" }
62>;
63
64const MAX_ACP_TERMINAL_OUTPUT_BYTES = 16 * 1024 * 1024;
65const MAX_ACP_TERMINAL_FRAME_BYTES = MAX_ACP_TERMINAL_OUTPUT_BYTES;
66
67function 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 */
82function 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
103function 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
122function 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
136function 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. */
145export 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
209export 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
224export interface AcpPermissionRequest {
225 readonly kind: string | "unknown";
226 readonly detail?: string;
227 readonly toolCall?: AcpToolCallState;
228}
229
230export 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
291const boundedContentMetadata = (value: string | null | undefined, maximumLength: number): string =>
292 value?.trim().slice(0, maximumLength) ?? "";
293
294const MAX_ACP_CONTENT_TEXT_LENGTH = 65_536;
295
296// Preserve whitespace in streamed model output while keeping one provider frame bounded.
297const boundedContentText = (value: string): string => value.slice(0, MAX_ACP_CONTENT_TEXT_LENGTH);
298
299const 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. */
305export 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
342function 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
381type AcpSessionSetupResponse =
382 | EffectAcpSchema.ForkSessionResponse
383 | EffectAcpSchema.LoadSessionResponse
384 | EffectAcpSchema.NewSessionResponse
385 | EffectAcpSchema.ResumeSessionResponse;
386
387type AcpToolCallUpdate = Extract<
388 EffectAcpSchema.SessionNotification["update"],
389 { readonly sessionUpdate: "tool_call" | "tool_call_update" }
390>;
391
392export 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
403export 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
417export 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
428export 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
476function 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
488function 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
507function 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
526function 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
534function 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
561function 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
569function 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.
630const TOOL_CALL_CONTENT_MAX_CHARS = 8_000;
631const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n";
632
633function 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
641const 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.
647function 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
663interface ExtractedToolCallContent {
664 readonly text: string | undefined;
665 readonly content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | undefined;
666}
667
668function 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`.
679function 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
701function 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.
733function 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
785function 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 */
793export 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
811function 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
902function 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
924export 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.
952const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256;
953const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10;
954
955export interface AcpToolCallEmitDecisionInput {
956 readonly previous: AcpToolCallState | undefined;
957 readonly next: AcpToolCallState;
958 readonly lastEmittedDetailLength: number | undefined;
959 readonly skippedSinceEmit: number;
960}
961
962export interface AcpToolCallEmitDecision {
963 readonly emit: boolean;
964 readonly skippedSinceEmit: number;
965}
966
967function 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.
976export 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
1003function 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.
1015export 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
1026export 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
1049export 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. */
1056const ACP_MCP_FALLBACK_CALL = /(?:^|[\s"'=])acp-mcp-call[\s"']+([A-Za-z0-9_.-]+)(?:\s+(.+?))?\s*$/u;
1057
1058function 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 */
1087const 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 */
1095const 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 */
1103const 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 */
1115export 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 */
1217export 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
1230export 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
1259export 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. */
1265export 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
1301export 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
1311export 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 */
1337export 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
1345export 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.
1366function 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
1388export 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}