@gajae-code/coding-agent 0.12.18 → 0.12.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -9
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/sdk.d.ts +66 -0
- package/dist/types/config/settings-schema.d.ts +34 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
- package/dist/types/modes/acp/acp-agent.d.ts +33 -1
- package/dist/types/modes/components/bash-execution.d.ts +7 -0
- package/dist/types/modes/components/eval-execution.d.ts +7 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
- package/dist/types/sdk/acp/adapter.d.ts +9 -17
- package/dist/types/sdk/acp/index.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +93 -1
- package/dist/types/sdk/broker/ensure.d.ts +17 -0
- package/dist/types/sdk/broker/index.d.ts +1 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
- package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +20 -9
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
- package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -2
- package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/lifecycle-session.d.ts +13 -2
- package/dist/types/sdk/prompt-failure.d.ts +13 -0
- package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
- package/dist/types/sdk/session-reconnect.d.ts +20 -0
- package/dist/types/sdk/startup-capability.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +32 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
- package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
- package/dist/types/session/session-manager.d.ts +301 -27
- package/dist/types/session/session-storage.d.ts +170 -0
- package/dist/types/task/executor.d.ts +2 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tui/output-block.d.ts +6 -0
- package/package.json +8 -8
- package/src/cli/args.ts +75 -34
- package/src/commands/acp.ts +7 -1
- package/src/commands/gc.ts +31 -2
- package/src/commands/sdk.ts +141 -7
- package/src/config/settings-schema.ts +43 -0
- package/src/coordinator-mcp/server.ts +6 -1
- package/src/export/html/index.ts +83 -39
- package/src/gjc-runtime/gc-runtime.ts +1283 -4
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/internal-urls/local-protocol.ts +14 -0
- package/src/main.ts +40 -9
- package/src/modes/acp/acp-agent.ts +564 -37
- package/src/modes/acp/terminal-auth.ts +2 -2
- package/src/modes/components/bash-execution.ts +14 -0
- package/src/modes/components/eval-execution.ts +14 -0
- package/src/modes/controllers/command-controller.ts +63 -8
- package/src/modes/utils/ui-helpers.ts +81 -11
- package/src/sdk/acp/adapter.ts +46 -45
- package/src/sdk/acp/index.ts +1 -1
- package/src/sdk/broker/broker.ts +392 -11
- package/src/sdk/broker/ensure.ts +131 -61
- package/src/sdk/broker/index.ts +1 -0
- package/src/sdk/broker/lifecycle-ledger.ts +59 -5
- package/src/sdk/broker/lifecycle.ts +278 -63
- package/src/sdk/broker/startup-budget.ts +103 -0
- package/src/sdk/bus/chat-daemon-control.ts +9 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
- package/src/sdk/bus/conversation-store.ts +1 -1
- package/src/sdk/bus/discord-daemon.ts +2 -1
- package/src/sdk/bus/discord-live-provider.ts +34 -6
- package/src/sdk/bus/index.ts +118 -20
- package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
- package/src/sdk/bus/prompt-reconciliation.ts +4 -9
- package/src/sdk/bus/slack-daemon.ts +91 -18
- package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
- package/src/sdk/bus/topic-registry.ts +7 -1
- package/src/sdk/cli/session-cli.ts +8 -1
- package/src/sdk/host/query/handlers.ts +1 -1
- package/src/sdk/host/query/revision-store.ts +48 -12
- package/src/sdk/host/session-runtime.test.ts +50 -0
- package/src/sdk/host/session-runtime.ts +12 -2
- package/src/sdk/lifecycle-session.ts +26 -3
- package/src/sdk/mcp/server.ts +6 -1
- package/src/sdk/prompt-failure.ts +42 -0
- package/src/sdk/prompt-watchdog.ts +162 -0
- package/src/sdk/session-reconnect.ts +43 -0
- package/src/sdk/startup-capability.ts +13 -4
- package/src/session/agent-session.ts +525 -222
- package/src/session/blob-store.ts +89 -0
- package/src/session/internal/managed-session-scope.ts +36 -26
- package/src/session/internal/managed-session-storage.ts +303 -32
- package/src/session/internal/session-memory-sidecar.ts +2160 -0
- package/src/session/session-manager.ts +7027 -394
- package/src/session/session-storage.ts +1134 -11
- package/src/task/executor.ts +17 -4
- package/src/tools/bash.ts +109 -7
- package/src/tools/find.ts +55 -26
- package/src/tools/todo-contract.ts +40 -8
- package/src/tui/output-block.ts +11 -1
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
type AcpReverseConnection,
|
|
44
44
|
AcpSdkAdapter,
|
|
45
45
|
AcpSdkAdapterError,
|
|
46
|
+
acpMcpLaunchFailure,
|
|
46
47
|
} from "../../sdk/acp";
|
|
47
48
|
import { resolveAcpFinalText } from "../../sdk/acp/final-text";
|
|
48
49
|
import { ACP_MCP_LIFECYCLE_TIMEOUT_MS, type SessionLifecycleMcpServer } from "../../sdk/acp/mcp";
|
|
@@ -50,6 +51,7 @@ import { ensureBroker } from "../../sdk/broker/ensure";
|
|
|
50
51
|
import { readSdkBrokerDiscovery, SdkClient, SdkClientError } from "../../sdk/client";
|
|
51
52
|
import { SYNTHETIC_PROVIDER_ID } from "../../sdk/model-profile-namespace";
|
|
52
53
|
import type { SdkPromptTerminalOutcome } from "../../sdk/prompt-status";
|
|
54
|
+
import { PromptActivity, type PromptWatchdogClock, systemPromptWatchdogClock } from "../../sdk/prompt-watchdog";
|
|
53
55
|
import {
|
|
54
56
|
buildToolCallStartUpdate,
|
|
55
57
|
mapAgentSessionEventToAcpSessionUpdates,
|
|
@@ -104,6 +106,16 @@ interface PromptWaiter {
|
|
|
104
106
|
terminal?: { outcome: SdkPromptTerminalOutcome; correlation: PromptCorrelation };
|
|
105
107
|
/** Frames for an already-settled correlation held until acknowledgement resolves ownership. */
|
|
106
108
|
deferredFrames: JsonObject[];
|
|
109
|
+
/** Activity frames held until acknowledgement establishes exact prompt ownership. */
|
|
110
|
+
deferredActivityFrames: JsonObject[];
|
|
111
|
+
/** Clock reading of the last inbound frame for this prompt; baseline of the inactivity watchdog. */
|
|
112
|
+
lastFrameAt: number;
|
|
113
|
+
/** Frame (or wire event) type of that last frame, reported when the watchdog expires. */
|
|
114
|
+
lastFrameType: string;
|
|
115
|
+
/** Cancels the armed inactivity watchdog; re-armed by every inbound frame for this prompt. */
|
|
116
|
+
cancelWatchdog?: () => void;
|
|
117
|
+
/** What the host is observably doing — a tool running, a model call unanswered — and the bound that follows from it. */
|
|
118
|
+
activity: PromptActivity;
|
|
107
119
|
resolve: (response: PromptResponse) => void;
|
|
108
120
|
reject: (error: Error) => void;
|
|
109
121
|
}
|
|
@@ -163,6 +175,14 @@ function parseAcpStartupOptions(value: unknown): AcpStartupOptions | undefined {
|
|
|
163
175
|
: undefined;
|
|
164
176
|
}
|
|
165
177
|
|
|
178
|
+
/** Tests inject a virtual clock so watchdog coverage never sleeps in real time. */
|
|
179
|
+
function parsePromptWatchdogClock(value: unknown): PromptWatchdogClock | undefined {
|
|
180
|
+
const candidate = object(value);
|
|
181
|
+
return typeof candidate?.now === "function" && typeof candidate.schedule === "function"
|
|
182
|
+
? (candidate as unknown as PromptWatchdogClock)
|
|
183
|
+
: undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
166
186
|
function object(value: unknown): JsonObject | undefined {
|
|
167
187
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : undefined;
|
|
168
188
|
}
|
|
@@ -300,6 +320,15 @@ function hasCompleteCorrelation(correlation: PromptCorrelation): correlation is
|
|
|
300
320
|
);
|
|
301
321
|
}
|
|
302
322
|
|
|
323
|
+
function clearPromptWatchdog(waiter: PromptWaiter): void {
|
|
324
|
+
waiter.cancelWatchdog?.();
|
|
325
|
+
waiter.cancelWatchdog = undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function describeCorrelation(correlation: PromptCorrelation): string {
|
|
329
|
+
return `commandId=${correlation.commandId ?? "none"} turnId=${correlation.turnId ?? "none"}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
303
332
|
function correlationsExactlyMatch(expected: PromptCorrelation, actual: PromptCorrelation): boolean {
|
|
304
333
|
return (
|
|
305
334
|
hasCompleteCorrelation(expected) &&
|
|
@@ -386,19 +415,106 @@ export interface TranscriptReplayContent {
|
|
|
386
415
|
images: { available: false; reason: "historical_transcript_images_unavailable" };
|
|
387
416
|
}
|
|
388
417
|
|
|
389
|
-
|
|
418
|
+
/** Machine-readable reason replay could not restore a transcript entry. */
|
|
419
|
+
export type TranscriptReplaySkipReason = "transcript_body_unavailable" | "transcript_tool_call_unavailable";
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Replay decides per entry. An entry whose production body is missing is not
|
|
423
|
+
* replayable, and the caller reports that boundary instead of failing the whole
|
|
424
|
+
* load; fabricating an empty body would replay a message that never existed.
|
|
425
|
+
*/
|
|
426
|
+
export type TranscriptReplayEntry =
|
|
427
|
+
| { replayable: true; content: TranscriptReplayContent }
|
|
428
|
+
| { replayable: false; reason: TranscriptReplaySkipReason };
|
|
429
|
+
|
|
430
|
+
export function transcriptReplayContent(entry: unknown): TranscriptReplayEntry {
|
|
390
431
|
const record = object(entry);
|
|
391
|
-
if (typeof record?.body !== "string")
|
|
392
|
-
throw new AcpSdkAdapterError(
|
|
393
|
-
"transcript_body_unavailable",
|
|
394
|
-
"ACP cannot replay a transcript entry without its production body.",
|
|
395
|
-
);
|
|
432
|
+
if (typeof record?.body !== "string") return { replayable: false, reason: "transcript_body_unavailable" };
|
|
396
433
|
return {
|
|
397
|
-
|
|
398
|
-
|
|
434
|
+
replayable: true,
|
|
435
|
+
content: {
|
|
436
|
+
blocks: record.body.length > 0 ? [{ type: "text", text: record.body }] : [],
|
|
437
|
+
images: { available: false, reason: "historical_transcript_images_unavailable" },
|
|
438
|
+
},
|
|
399
439
|
};
|
|
400
440
|
}
|
|
401
441
|
|
|
442
|
+
/**
|
|
443
|
+
* `transcript.list` answers an entry larger than one page with a body-less row
|
|
444
|
+
* `{ id, error: { code: "item_too_large" }, continuations }`. Each continuation is a
|
|
445
|
+
* `Q23` (`resource.body`) descriptor for one indexed string field of that entry, so
|
|
446
|
+
* the row is a pointer to the largest message in the session rather than a broken
|
|
447
|
+
* entry. Replay follows it instead of dropping the message.
|
|
448
|
+
*/
|
|
449
|
+
export interface TranscriptContinuation {
|
|
450
|
+
query: string;
|
|
451
|
+
resourceKind: string;
|
|
452
|
+
resourceId: string;
|
|
453
|
+
revision: string;
|
|
454
|
+
itemId: string;
|
|
455
|
+
field: string;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Fields replay consumes; `textSummary` and the rest stay unread so recovery costs one query per used field. */
|
|
459
|
+
const RECOVERABLE_TRANSCRIPT_FIELDS = ["role", "body", "content", "toolCallId", "toolName", "isError"] as const;
|
|
460
|
+
|
|
461
|
+
/** Stands in for a tool result whose transcript entry replay could not restore. */
|
|
462
|
+
const TRANSCRIPT_TOOL_RESULT_UNAVAILABLE = "The transcript entry holding this tool result could not be replayed.";
|
|
463
|
+
|
|
464
|
+
/** Stands in for a tool call the transcript never recorded a result for. */
|
|
465
|
+
const TRANSCRIPT_TOOL_CALL_UNRESOLVED = "The session transcript ends without a result for this tool call.";
|
|
466
|
+
|
|
467
|
+
/** Stands in for a tool call replay abandoned before it could read back a result. */
|
|
468
|
+
const TRANSCRIPT_REPLAY_INTERRUPTED = "Transcript replay stopped before this tool call reached a result.";
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The published tool calls a replay cleanup pass could not close, with the frame failures
|
|
472
|
+
* that refused them. `#replaySession` reads `unclosedToolCallIds` to name every stranded
|
|
473
|
+
* call in the `session/load` rejection, so a close that could not happen is reported to the
|
|
474
|
+
* caller instead of being discarded with the session record.
|
|
475
|
+
*/
|
|
476
|
+
interface UnclosedReplayToolCalls {
|
|
477
|
+
unclosedToolCallIds: string[];
|
|
478
|
+
failures: unknown[];
|
|
479
|
+
}
|
|
480
|
+
function decodeTranscriptContinuation(field: string, body: string): unknown {
|
|
481
|
+
if (field !== "content" && field !== "isError") return body;
|
|
482
|
+
try {
|
|
483
|
+
const value: unknown = JSON.parse(body);
|
|
484
|
+
if (field === "content") return Array.isArray(value) ? value : undefined;
|
|
485
|
+
return typeof value === "boolean" ? value : undefined;
|
|
486
|
+
} catch {
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function transcriptContinuations(entry: unknown): TranscriptContinuation[] {
|
|
492
|
+
const record = object(entry);
|
|
493
|
+
if (!Array.isArray(record?.continuations)) return [];
|
|
494
|
+
const descriptors: TranscriptContinuation[] = [];
|
|
495
|
+
for (const value of record.continuations) {
|
|
496
|
+
const candidate = object(value);
|
|
497
|
+
if (
|
|
498
|
+
typeof candidate?.query !== "string" ||
|
|
499
|
+
typeof candidate.resourceKind !== "string" ||
|
|
500
|
+
typeof candidate.resourceId !== "string" ||
|
|
501
|
+
typeof candidate.revision !== "string" ||
|
|
502
|
+
typeof candidate.itemId !== "string" ||
|
|
503
|
+
typeof candidate.field !== "string"
|
|
504
|
+
)
|
|
505
|
+
continue;
|
|
506
|
+
descriptors.push({
|
|
507
|
+
query: candidate.query,
|
|
508
|
+
resourceKind: candidate.resourceKind,
|
|
509
|
+
resourceId: candidate.resourceId,
|
|
510
|
+
revision: candidate.revision,
|
|
511
|
+
itemId: candidate.itemId,
|
|
512
|
+
field: candidate.field,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
return descriptors;
|
|
516
|
+
}
|
|
517
|
+
|
|
402
518
|
type ReceivedSdkEvent = {
|
|
403
519
|
event: JsonObject;
|
|
404
520
|
/** Event payload accepted by the ACP event mapper, when this is an agent-wire frame. */
|
|
@@ -426,6 +542,16 @@ function receivedSdkEvent(frame: JsonObject): ReceivedSdkEvent | undefined {
|
|
|
426
542
|
};
|
|
427
543
|
}
|
|
428
544
|
|
|
545
|
+
/**
|
|
546
|
+
* Author of the message a `message_start`/`message_end` frame carries. The host echoes the
|
|
547
|
+
* user prompt and every tool result back through the same events, so only `"assistant"`
|
|
548
|
+
* proves a model call answered.
|
|
549
|
+
*/
|
|
550
|
+
function frameMessageRole(event: JsonObject | undefined): string | undefined {
|
|
551
|
+
const role = object(event?.message)?.role;
|
|
552
|
+
return typeof role === "string" ? role : undefined;
|
|
553
|
+
}
|
|
554
|
+
|
|
429
555
|
const ACP_CONFIG_OPTIONS = [
|
|
430
556
|
{ id: MODEL_CONFIG_ID, name: "Model", category: "model", options: [] },
|
|
431
557
|
{ id: THINKING_CONFIG_ID, name: "Thinking", category: "thought_level", options: [] },
|
|
@@ -889,12 +1015,20 @@ export class AcpAgent implements Agent {
|
|
|
889
1015
|
#broker: Promise<BrokerConnection> | undefined;
|
|
890
1016
|
readonly #startupOptions: AcpStartupOptions | undefined;
|
|
891
1017
|
readonly #cancelSettlementGraceMs: number;
|
|
1018
|
+
readonly #promptWatchdogClock: PromptWatchdogClock;
|
|
892
1019
|
#disposed = false;
|
|
893
1020
|
#disposePromise: Promise<void> | undefined;
|
|
894
1021
|
|
|
895
1022
|
constructor(
|
|
896
1023
|
connection: AgentSideConnection,
|
|
897
|
-
options?:
|
|
1024
|
+
options?:
|
|
1025
|
+
| {
|
|
1026
|
+
agentDir?: string;
|
|
1027
|
+
startupOptions?: AcpStartupOptions;
|
|
1028
|
+
cancelSettlementGraceMs?: number;
|
|
1029
|
+
promptWatchdogClock?: PromptWatchdogClock;
|
|
1030
|
+
}
|
|
1031
|
+
| unknown,
|
|
898
1032
|
) {
|
|
899
1033
|
this.#connection = connection;
|
|
900
1034
|
const candidate = object(options);
|
|
@@ -905,6 +1039,7 @@ export class AcpAgent implements Agent {
|
|
|
905
1039
|
Number.isSafeInteger(candidate.cancelSettlementGraceMs)
|
|
906
1040
|
? candidate.cancelSettlementGraceMs
|
|
907
1041
|
: CANCEL_SETTLEMENT_GRACE_MS;
|
|
1042
|
+
this.#promptWatchdogClock = parsePromptWatchdogClock(candidate?.promptWatchdogClock) ?? systemPromptWatchdogClock;
|
|
908
1043
|
queueMicrotask(() => {
|
|
909
1044
|
if (connection.signal.aborted) {
|
|
910
1045
|
this.#beginDispose();
|
|
@@ -1224,11 +1359,18 @@ export class AcpAgent implements Agent {
|
|
|
1224
1359
|
emittedAssistantText: "",
|
|
1225
1360
|
settled: false,
|
|
1226
1361
|
deferredFrames: [],
|
|
1362
|
+
deferredActivityFrames: [],
|
|
1363
|
+
lastFrameAt: this.#promptWatchdogClock.now(),
|
|
1364
|
+
lastFrameType: "prompt_dispatch",
|
|
1365
|
+
activity: new PromptActivity(),
|
|
1227
1366
|
resolve,
|
|
1228
1367
|
reject,
|
|
1229
1368
|
};
|
|
1230
1369
|
record.activePrompt = waiter;
|
|
1231
1370
|
});
|
|
1371
|
+
// Silence has to be bounded from the moment the prompt owns the session: a host that
|
|
1372
|
+
// dies before it ever answers is exactly the failure that leaves the client running.
|
|
1373
|
+
this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
1232
1374
|
// Echo the user's own message back as `user_message_chunk`. Clients render their
|
|
1233
1375
|
// transcript from session/update, so without this a prompt's text and any attached
|
|
1234
1376
|
// image never appear in the client UI — only the agent's reply does. Replay
|
|
@@ -1263,6 +1405,13 @@ export class AcpAgent implements Agent {
|
|
|
1263
1405
|
waiter.acknowledged = true;
|
|
1264
1406
|
// Frames held while ownership was unknown belong to this prompt only when the
|
|
1265
1407
|
// acknowledgement proves their complete correlation matches exactly.
|
|
1408
|
+
const deferredActivityFrames = waiter.deferredActivityFrames.splice(0);
|
|
1409
|
+
for (const deferredFrame of deferredActivityFrames) {
|
|
1410
|
+
const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
|
|
1411
|
+
if (correlationsExactlyMatch(waiter.correlation, correlationFrom(deferredFrame, deferredEvent)))
|
|
1412
|
+
this.#observePromptActivity(waiter, deferredFrame);
|
|
1413
|
+
}
|
|
1414
|
+
if (deferredActivityFrames.length > 0) this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
1266
1415
|
const deferred = waiter.deferredFrames.splice(0);
|
|
1267
1416
|
for (const deferredFrame of deferred)
|
|
1268
1417
|
if (correlationsExactlyMatch(waiter.correlation, correlationFrom(deferredFrame)))
|
|
@@ -1272,6 +1421,8 @@ export class AcpAgent implements Agent {
|
|
|
1272
1421
|
this.#settlePrompt(record, waiter);
|
|
1273
1422
|
} catch (error) {
|
|
1274
1423
|
waiter.deferredFrames.length = 0;
|
|
1424
|
+
waiter.deferredActivityFrames.length = 0;
|
|
1425
|
+
clearPromptWatchdog(waiter);
|
|
1275
1426
|
waiter.terminal = undefined;
|
|
1276
1427
|
waiter.settled = true;
|
|
1277
1428
|
if (record.activePrompt === waiter) record.activePrompt = undefined;
|
|
@@ -1326,9 +1477,11 @@ export class AcpAgent implements Agent {
|
|
|
1326
1477
|
// when nothing settled the prompt the client already asked to cancel.
|
|
1327
1478
|
if (this.#sessions.get(id) !== record || record.activePrompt !== waiter || waiter.settled) return;
|
|
1328
1479
|
record.activePrompt = undefined;
|
|
1480
|
+
clearPromptWatchdog(waiter);
|
|
1329
1481
|
record.cancelRequested = false;
|
|
1330
1482
|
waiter.settled = true;
|
|
1331
1483
|
waiter.deferredFrames.length = 0;
|
|
1484
|
+
waiter.deferredActivityFrames.length = 0;
|
|
1332
1485
|
waiter.terminal = undefined;
|
|
1333
1486
|
// A late terminal for this turn must stay closed rather than publish over a
|
|
1334
1487
|
// prompt the client has already been told is cancelled.
|
|
@@ -1674,6 +1827,7 @@ export class AcpAgent implements Agent {
|
|
|
1674
1827
|
// MUST catch these errors and return the semantically meaningful `cancelled`
|
|
1675
1828
|
// stop reason." Involuntary teardown (transport loss) still rejects.
|
|
1676
1829
|
if (waiter && !waiter.settled) {
|
|
1830
|
+
clearPromptWatchdog(waiter);
|
|
1677
1831
|
if (reason === "closed" || reason === "discarded") {
|
|
1678
1832
|
waiter.settled = true;
|
|
1679
1833
|
waiter.resolve({ stopReason: "cancelled" });
|
|
@@ -1721,6 +1875,7 @@ export class AcpAgent implements Agent {
|
|
|
1721
1875
|
record.reconnectUnsubscribe();
|
|
1722
1876
|
const waiter = record.activePrompt;
|
|
1723
1877
|
record.activePrompt = undefined;
|
|
1878
|
+
if (waiter) clearPromptWatchdog(waiter);
|
|
1724
1879
|
waiter?.reject(error);
|
|
1725
1880
|
try {
|
|
1726
1881
|
await adapter.close();
|
|
@@ -1806,11 +1961,110 @@ export class AcpAgent implements Agent {
|
|
|
1806
1961
|
return new AcpSdkAdapterError("frame_processing_failed", `ACP session frame processing failed: ${detail}`);
|
|
1807
1962
|
}
|
|
1808
1963
|
|
|
1964
|
+
/**
|
|
1965
|
+
* Bounds how long one prompt may stay silent. A session host that stops producing
|
|
1966
|
+
* never publishes a terminal frame, and an ACP prompt only settles on a terminal, so
|
|
1967
|
+
* without this bound `session/prompt` never returns and the client reports the turn as
|
|
1968
|
+
* running forever behind a dead session.
|
|
1969
|
+
*/
|
|
1970
|
+
#armPromptWatchdog(id: string, record: SessionRecord, waiter: PromptWaiter): void {
|
|
1971
|
+
if (waiter.settled) return;
|
|
1972
|
+
waiter.cancelWatchdog?.();
|
|
1973
|
+
// No frame can still arrive once the transport is known to be gone, so waiting out
|
|
1974
|
+
// the full bound would only add dead time to an outcome that is already decided.
|
|
1975
|
+
const delayMs = this.#promptTransportGone(id, record) ? 0 : waiter.activity.inactivityBoundMs;
|
|
1976
|
+
waiter.cancelWatchdog = this.#promptWatchdogClock.schedule(() => {
|
|
1977
|
+
void this.#expirePromptWatchdog(id, record, waiter);
|
|
1978
|
+
}, delayMs);
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/**
|
|
1982
|
+
* Any frame proves the producer is alive, so every one of them restarts the bound: liveness
|
|
1983
|
+
* is a property of the session host process, not of one turn, and a host still flushing an
|
|
1984
|
+
* abandoned turn's backlog is exactly a producer that has not stopped producing. Which bound
|
|
1985
|
+
* is restarted is a different question, and that one is correlation-owned; see below.
|
|
1986
|
+
*/
|
|
1987
|
+
#refreshPromptWatchdog(id: string, record: SessionRecord, frame: JsonObject): void {
|
|
1988
|
+
const waiter = record.activePrompt;
|
|
1989
|
+
if (!waiter || waiter.settled) return;
|
|
1990
|
+
const event = receivedSdkEvent(frame)?.event;
|
|
1991
|
+
waiter.lastFrameAt = this.#promptWatchdogClock.now();
|
|
1992
|
+
waiter.lastFrameType =
|
|
1993
|
+
typeof event?.type === "string" ? event.type : typeof frame.type === "string" ? frame.type : "unknown";
|
|
1994
|
+
// Which bound applies is per-turn state, so it is fenced by the same identity that gates
|
|
1995
|
+
// settlement: only a frame carrying exactly the acknowledged prompt's command/turn may
|
|
1996
|
+
// start, clear, or extend a bound. Without this fence an already-settled turn's flushed
|
|
1997
|
+
// frames mutate the live turn — a stale `message_update` clears `awaitingModel` and
|
|
1998
|
+
// narrows it off the inference bound, a stale `tool_execution_start` pins it to the tool
|
|
1999
|
+
// bound with no tool running. Frames with no correlation at all (heartbeats,
|
|
2000
|
+
// session-scoped events) are refresh-only for the same reason: they prove the producer
|
|
2001
|
+
// lives but say nothing about what this turn is doing, so they leave the bound as is.
|
|
2002
|
+
// Before acknowledgement the prompt has no identity, so correlated activity is retained
|
|
2003
|
+
// and folded in only after the acknowledgement proves exact ownership.
|
|
2004
|
+
if (!waiter.acknowledged) {
|
|
2005
|
+
if (hasCorrelation(correlationFrom(frame, event))) waiter.deferredActivityFrames.push(frame);
|
|
2006
|
+
} else if (correlationsExactlyMatch(waiter.correlation, correlationFrom(frame, event))) {
|
|
2007
|
+
this.#observePromptActivity(waiter, frame);
|
|
2008
|
+
}
|
|
2009
|
+
this.#armPromptWatchdog(id, record, waiter);
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
#observePromptActivity(waiter: PromptWaiter, frame: JsonObject): void {
|
|
2013
|
+
const event = receivedSdkEvent(frame)?.event;
|
|
2014
|
+
waiter.activity.observe(
|
|
2015
|
+
typeof event?.type === "string" ? event.type : undefined,
|
|
2016
|
+
typeof event?.toolCallId === "string" ? event.toolCallId : undefined,
|
|
2017
|
+
frameMessageRole(event),
|
|
2018
|
+
);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/** Names why the prompt can be settled at once instead of waiting out the inactivity bound. */
|
|
2022
|
+
#promptTransportGone(id: string, record: SessionRecord): string | undefined {
|
|
2023
|
+
if (this.#disposed || this.#connection.signal.aborted) return "the ACP client connection is closed";
|
|
2024
|
+
if (this.#sessions.get(id) !== record) return "the SDK session host record was already discarded";
|
|
2025
|
+
return undefined;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/**
|
|
2029
|
+
* Settles the ACP prompt only. The agent's own work is left alone: this reports that the
|
|
2030
|
+
* turn can no longer be observed, it does not cancel or tear down the session.
|
|
2031
|
+
*/
|
|
2032
|
+
async #expirePromptWatchdog(id: string, record: SessionRecord, waiter: PromptWaiter): Promise<void> {
|
|
2033
|
+
if (record.activePrompt !== waiter || waiter.settled) return;
|
|
2034
|
+
const silenceMs = Math.max(0, this.#promptWatchdogClock.now() - waiter.lastFrameAt);
|
|
2035
|
+
const cause = this.#promptTransportGone(id, record) ?? "the SDK session host stopped producing frames";
|
|
2036
|
+
logger.error("acp_prompt_watchdog_expired", {
|
|
2037
|
+
sessionId: id,
|
|
2038
|
+
cause,
|
|
2039
|
+
silenceMs,
|
|
2040
|
+
lastFrameType: waiter.lastFrameType,
|
|
2041
|
+
inactivityBoundMs: waiter.activity.inactivityBoundMs,
|
|
2042
|
+
toolRunning: waiter.activity.running,
|
|
2043
|
+
awaitingModel: waiter.activity.awaitingModel,
|
|
2044
|
+
...(waiter.correlation.commandId ? { commandId: waiter.correlation.commandId } : {}),
|
|
2045
|
+
...(waiter.correlation.turnId ? { turnId: waiter.correlation.turnId } : {}),
|
|
2046
|
+
});
|
|
2047
|
+
await this.#rejectPrompt(
|
|
2048
|
+
record,
|
|
2049
|
+
id,
|
|
2050
|
+
waiter,
|
|
2051
|
+
new AcpSdkAdapterError(
|
|
2052
|
+
"prompt_abandoned",
|
|
2053
|
+
`ACP prompt was abandoned after ${Math.round(silenceMs / 1_000)}s of silence: ${cause}. Last frame was ` +
|
|
2054
|
+
`"${waiter.lastFrameType}" (${describeCorrelation(waiter.correlation)}). The turn was settled so the ` +
|
|
2055
|
+
`client stops waiting; the session still accepts the next prompt.`,
|
|
2056
|
+
),
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1809
2060
|
#enqueueSdkFrame(id: string, adapter: AcpSdkAdapter, frame: JsonObject): void {
|
|
1810
2061
|
const record = this.#sessions.get(id);
|
|
1811
2062
|
if (!record || record.adapter !== adapter) return;
|
|
1812
2063
|
// Ingress ordering is recorded before queued work begins.
|
|
1813
2064
|
this.#observeSessionActivity(record, frame);
|
|
2065
|
+
// Any frame at all proves the producer is still alive, so it refreshes the
|
|
2066
|
+
// inactivity watchdog before the queued work that may take a long time to run.
|
|
2067
|
+
this.#refreshPromptWatchdog(id, record, frame);
|
|
1814
2068
|
++record.inboundSequence;
|
|
1815
2069
|
const task = record.frameTail.then(async () => await this.#handleSdkFrame(id, adapter, frame));
|
|
1816
2070
|
record.frameTail = task.catch(
|
|
@@ -1828,6 +2082,7 @@ export class AcpAgent implements Agent {
|
|
|
1828
2082
|
const waiter = record.activePrompt;
|
|
1829
2083
|
if (waiter && !waiter.settled && !waiter.terminal) {
|
|
1830
2084
|
record.activePrompt = undefined;
|
|
2085
|
+
clearPromptWatchdog(waiter);
|
|
1831
2086
|
waiter.settled = true;
|
|
1832
2087
|
if (hasCorrelation(waiter.correlation)) {
|
|
1833
2088
|
record.settledPromptCorrelations.push(waiter.correlation);
|
|
@@ -1963,11 +2218,15 @@ export class AcpAgent implements Agent {
|
|
|
1963
2218
|
});
|
|
1964
2219
|
}
|
|
1965
2220
|
}
|
|
1966
|
-
await this.#emitEndOfTurnUpdates(id, adapter);
|
|
1967
|
-
} else if (event.type === "agent_failed") {
|
|
1968
|
-
await this.#emitEndOfTurnUpdates(id, adapter);
|
|
1969
2221
|
}
|
|
2222
|
+
// The terminal frame is the turn's end, so it settles the prompt before anything
|
|
2223
|
+
// else is asked of the session host. `#emitEndOfTurnUpdates` queries `context.get`
|
|
2224
|
+
// and `session.metadata`, and a host that stops producing the moment it publishes
|
|
2225
|
+
// its terminal answers neither: sequencing settlement behind those advisory
|
|
2226
|
+
// queries is what left a finished turn reported as running until the inactivity
|
|
2227
|
+
// watchdog rescued it.
|
|
1970
2228
|
if (activePrompt) this.#settlePrompt(record, activePrompt);
|
|
2229
|
+
if (event.type === "agent_end" || event.type === "agent_failed") await this.#emitEndOfTurnUpdates(id, adapter);
|
|
1971
2230
|
}
|
|
1972
2231
|
|
|
1973
2232
|
async #rejectPrompt(
|
|
@@ -1978,8 +2237,10 @@ export class AcpAgent implements Agent {
|
|
|
1978
2237
|
): Promise<void> {
|
|
1979
2238
|
if (record.activePrompt !== waiter || waiter.settled) return;
|
|
1980
2239
|
record.activePrompt = undefined;
|
|
2240
|
+
clearPromptWatchdog(waiter);
|
|
1981
2241
|
waiter.settled = true;
|
|
1982
2242
|
waiter.deferredFrames.length = 0;
|
|
2243
|
+
waiter.deferredActivityFrames.length = 0;
|
|
1983
2244
|
waiter.terminal = undefined;
|
|
1984
2245
|
if (hasCompleteCorrelation(waiter.correlation)) {
|
|
1985
2246
|
record.settledPromptCorrelations.push(waiter.correlation);
|
|
@@ -2024,6 +2285,7 @@ export class AcpAgent implements Agent {
|
|
|
2024
2285
|
return;
|
|
2025
2286
|
}
|
|
2026
2287
|
record.activePrompt = undefined;
|
|
2288
|
+
clearPromptWatchdog(waiter);
|
|
2027
2289
|
waiter.settled = true;
|
|
2028
2290
|
if (hasCorrelation(waiter.correlation)) {
|
|
2029
2291
|
record.settledPromptCorrelations.push(waiter.correlation);
|
|
@@ -2057,6 +2319,9 @@ export class AcpAgent implements Agent {
|
|
|
2057
2319
|
} catch {
|
|
2058
2320
|
// Session naming is advisory; prompt completion remains authoritative.
|
|
2059
2321
|
}
|
|
2322
|
+
// The prompt settled before these queries were asked, so a host that answers late
|
|
2323
|
+
// must not report the session idle after the next turn has already started.
|
|
2324
|
+
if (this.#sessions.get(id)?.activePrompt) return;
|
|
2060
2325
|
if (typeof usage?.tokens === "number" && typeof usage.contextWindow === "number") {
|
|
2061
2326
|
await this.#publishSessionUpdate(
|
|
2062
2327
|
id,
|
|
@@ -2093,6 +2358,9 @@ export class AcpAgent implements Agent {
|
|
|
2093
2358
|
notification: SessionNotification,
|
|
2094
2359
|
expectedAdapter?: AcpSdkAdapter,
|
|
2095
2360
|
): Promise<void> {
|
|
2361
|
+
// A session that is gone has no update channel, so this is a drop and not a failure:
|
|
2362
|
+
// whoever owned the frame is the one that ended the session. Callers whose bookkeeping
|
|
2363
|
+
// assumes delivery have to end at the session too — see `#replaySession`.
|
|
2096
2364
|
const record = this.#sessions.get(id);
|
|
2097
2365
|
if (!record || (expectedAdapter && record.adapter !== expectedAdapter)) return;
|
|
2098
2366
|
try {
|
|
@@ -2157,21 +2425,247 @@ export class AcpAgent implements Agent {
|
|
|
2157
2425
|
);
|
|
2158
2426
|
}
|
|
2159
2427
|
|
|
2428
|
+
/**
|
|
2429
|
+
* Reassembles a body-less `item_too_large` entry from the `continuations` the
|
|
2430
|
+
* producer already published, reading each field replay consumes through the same
|
|
2431
|
+
* `resource.body` query the descriptor names. Partial recovery is retained so a
|
|
2432
|
+
* failed body continuation cannot discard tool-call identity needed to close an
|
|
2433
|
+
* already-published pending call.
|
|
2434
|
+
*/
|
|
2435
|
+
async #recoverTranscriptEntry(adapter: AcpSdkAdapter, entry: JsonObject): Promise<JsonObject> {
|
|
2436
|
+
const continuations = transcriptContinuations(entry);
|
|
2437
|
+
const recovered: JsonObject = { ...entry };
|
|
2438
|
+
for (const field of RECOVERABLE_TRANSCRIPT_FIELDS) {
|
|
2439
|
+
const continuation = continuations.find(candidate => candidate.field === field);
|
|
2440
|
+
if (!continuation) continue;
|
|
2441
|
+
const body = await this.#readContinuation(adapter, continuation);
|
|
2442
|
+
const value = body === undefined ? undefined : decodeTranscriptContinuation(field, body);
|
|
2443
|
+
// A row that advertises `isError` but will not yield it leaves the outcome
|
|
2444
|
+
// unproven, so replay reports failure rather than claiming a success it cannot
|
|
2445
|
+
// read back. A row that never advertised the field simply had no error flag.
|
|
2446
|
+
if (value === undefined) {
|
|
2447
|
+
if (field === "isError") recovered.isError = true;
|
|
2448
|
+
continue;
|
|
2449
|
+
}
|
|
2450
|
+
recovered[field] = value;
|
|
2451
|
+
}
|
|
2452
|
+
return recovered;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
/** Follows one continuation to its end, joining every page of that field's bytes. */
|
|
2456
|
+
async #readContinuation(adapter: AcpSdkAdapter, continuation: TranscriptContinuation): Promise<string | undefined> {
|
|
2457
|
+
const chunks: string[] = [];
|
|
2458
|
+
let cursor: string | undefined;
|
|
2459
|
+
for (let pageCount = 0; pageCount < MAX_ACP_REPLAY_PAGES; pageCount++) {
|
|
2460
|
+
let response: JsonObject | undefined;
|
|
2461
|
+
try {
|
|
2462
|
+
response = object(
|
|
2463
|
+
await adapter.query(
|
|
2464
|
+
continuation.query,
|
|
2465
|
+
cursor
|
|
2466
|
+
? {}
|
|
2467
|
+
: {
|
|
2468
|
+
resourceKind: continuation.resourceKind,
|
|
2469
|
+
resourceId: continuation.resourceId,
|
|
2470
|
+
revision: continuation.revision,
|
|
2471
|
+
itemId: continuation.itemId,
|
|
2472
|
+
field: continuation.field,
|
|
2473
|
+
},
|
|
2474
|
+
cursor,
|
|
2475
|
+
),
|
|
2476
|
+
);
|
|
2477
|
+
} catch {
|
|
2478
|
+
// A continuation that cannot be read costs one entry, never the whole
|
|
2479
|
+
// `session/load`; the caller reports the boundary instead.
|
|
2480
|
+
return undefined;
|
|
2481
|
+
}
|
|
2482
|
+
const result = object(response?.result) ?? response;
|
|
2483
|
+
const page = object(result?.page);
|
|
2484
|
+
const chunk = object(Array.isArray(page?.items) ? page.items[0] : undefined);
|
|
2485
|
+
if (typeof chunk?.body !== "string") return undefined;
|
|
2486
|
+
chunks.push(chunk.body);
|
|
2487
|
+
cursor = typeof page?.continuationCursor === "string" ? page.continuationCursor : undefined;
|
|
2488
|
+
if (!cursor) return chunks.join("");
|
|
2489
|
+
}
|
|
2490
|
+
return undefined;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
/**
|
|
2494
|
+
* Publishes the terminal update for one replayed tool call and reports whether the client
|
|
2495
|
+
* took it. A `toolResult` replay cannot use leaves its already-published `tool_call` at
|
|
2496
|
+
* `status: "pending"` forever, so replay closes it with the same `tool_execution_end`
|
|
2497
|
+
* shape a real result uses. Replay cannot know the outcome, so it reports failure rather
|
|
2498
|
+
* than claiming a success it cannot prove.
|
|
2499
|
+
*
|
|
2500
|
+
* Publication runs straight off the connection instead of through
|
|
2501
|
+
* {@link AcpAgent.#publishSessionUpdate}: that helper fails the session and deletes its
|
|
2502
|
+
* record on the first rejected frame, which turns every later close into a silent no-op
|
|
2503
|
+
* and would strand every call queued behind the first refusal. The direct path still
|
|
2504
|
+
* checks session ownership before every frame so a concurrent `session/close` stops it.
|
|
2505
|
+
*/
|
|
2506
|
+
async #closeReplayToolCall(
|
|
2507
|
+
id: string,
|
|
2508
|
+
expectedAdapter: AcpSdkAdapter,
|
|
2509
|
+
cwd: string,
|
|
2510
|
+
toolCallId: string,
|
|
2511
|
+
tool: { name: string; args: unknown },
|
|
2512
|
+
reason: string,
|
|
2513
|
+
): Promise<AcpSdkAdapterError | undefined> {
|
|
2514
|
+
try {
|
|
2515
|
+
for (const notification of mapAgentSessionEventToAcpSessionUpdates(
|
|
2516
|
+
{
|
|
2517
|
+
type: "tool_execution_end",
|
|
2518
|
+
toolCallId,
|
|
2519
|
+
toolName: tool.name,
|
|
2520
|
+
result: { content: [{ type: "text", text: reason }] },
|
|
2521
|
+
isError: true,
|
|
2522
|
+
} as never,
|
|
2523
|
+
id,
|
|
2524
|
+
{ cwd, getToolArgs: () => tool.args },
|
|
2525
|
+
)) {
|
|
2526
|
+
const record = this.#sessions.get(id);
|
|
2527
|
+
if (!record || record.adapter !== expectedAdapter) return undefined;
|
|
2528
|
+
await this.#connection.sessionUpdate(notification);
|
|
2529
|
+
}
|
|
2530
|
+
return undefined;
|
|
2531
|
+
} catch (error) {
|
|
2532
|
+
return this.#frameProcessingFailure(error);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
/**
|
|
2537
|
+
* Closes every tool call still open when replay stops, in a single pass, and hands back
|
|
2538
|
+
* the ones the client refused.
|
|
2539
|
+
*
|
|
2540
|
+
* An entry leaves `replayTools` only once its close was accepted. Removing it on the
|
|
2541
|
+
* attempt is what made a refused close disappear from the map before anything could name
|
|
2542
|
+
* it, so the one call the report exists to surface was the one call it never mentioned.
|
|
2543
|
+
*/
|
|
2544
|
+
async #closeOpenReplayToolCalls(
|
|
2545
|
+
id: string,
|
|
2546
|
+
adapter: AcpSdkAdapter,
|
|
2547
|
+
cwd: string,
|
|
2548
|
+
replayTools: Map<string, { name: string; args: unknown }>,
|
|
2549
|
+
reason: string,
|
|
2550
|
+
): Promise<UnclosedReplayToolCalls> {
|
|
2551
|
+
const unclosed: UnclosedReplayToolCalls = { unclosedToolCallIds: [], failures: [] };
|
|
2552
|
+
for (const [toolCallId, tool] of [...replayTools]) {
|
|
2553
|
+
const failure = await this.#closeReplayToolCall(id, adapter, cwd, toolCallId, tool, reason);
|
|
2554
|
+
if (failure === undefined) {
|
|
2555
|
+
replayTools.delete(toolCallId);
|
|
2556
|
+
continue;
|
|
2557
|
+
}
|
|
2558
|
+
unclosed.unclosedToolCallIds.push(toolCallId);
|
|
2559
|
+
unclosed.failures.push(failure);
|
|
2560
|
+
}
|
|
2561
|
+
return unclosed;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2160
2564
|
async #replaySession(id: string): Promise<void> {
|
|
2161
2565
|
const adapter = this.#adapter(id);
|
|
2162
2566
|
const record = this.#sessions.get(id);
|
|
2163
2567
|
if (!record) return;
|
|
2568
|
+
const replayTools = new Map<string, { name: string; args: unknown }>();
|
|
2569
|
+
let replayFailure: unknown;
|
|
2570
|
+
try {
|
|
2571
|
+
await this.#replayTranscriptPages(id, adapter, record, replayTools);
|
|
2572
|
+
} catch (error) {
|
|
2573
|
+
replayFailure = error;
|
|
2574
|
+
}
|
|
2575
|
+
// Every terminal below is addressed to this session, and `session/close`, a failed
|
|
2576
|
+
// session, or connection teardown can remove it mid-replay. That removal takes the
|
|
2577
|
+
// client's view of these calls with it: nothing is left to observe a `pending` one,
|
|
2578
|
+
// and a frame carrying a closed session id is one the client asked to stop receiving.
|
|
2579
|
+
// The obligation ends where the session ends; the next `session/load` replays the same
|
|
2580
|
+
// transcript rows from scratch. The boundary check avoids entering cleanup for an
|
|
2581
|
+
// already-closed session, while `#closeReplayToolCall` repeats the ownership check before
|
|
2582
|
+
// each direct publication so a close during cleanup stops the remaining frames without
|
|
2583
|
+
// restoring the failure side effects that used to silence calls behind a refused close.
|
|
2584
|
+
if (this.#sessions.get(id)?.adapter !== adapter) return;
|
|
2585
|
+
// The one boundary that closes replayed tool calls, covering every way the replay body
|
|
2586
|
+
// can end: normal return, early exit, or a `transcript.list` page that throws. Whatever
|
|
2587
|
+
// is still open was abandoned mid-replay, so it reaches a terminal status now instead of
|
|
2588
|
+
// spinning at `pending` for the life of the session.
|
|
2589
|
+
const unclosed = await this.#closeOpenReplayToolCalls(
|
|
2590
|
+
id,
|
|
2591
|
+
adapter,
|
|
2592
|
+
record.cwd,
|
|
2593
|
+
replayTools,
|
|
2594
|
+
replayFailure === undefined ? TRANSCRIPT_TOOL_CALL_UNRESOLVED : TRANSCRIPT_REPLAY_INTERRUPTED,
|
|
2595
|
+
);
|
|
2596
|
+
if (unclosed.unclosedToolCallIds.length === 0) {
|
|
2597
|
+
if (replayFailure !== undefined) throw replayFailure;
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
const cleanupFailure = aggregateAcpFailure(
|
|
2601
|
+
"frame_processing_failed",
|
|
2602
|
+
`ACP transcript replay could not close published tool calls: ${unclosed.unclosedToolCallIds.join(", ")}`,
|
|
2603
|
+
unclosed.failures,
|
|
2604
|
+
);
|
|
2605
|
+
// A client refusing this session's frames leaves no usable record behind, but teardown
|
|
2606
|
+
// runs only after every open call has been attempted and every refusal named above.
|
|
2607
|
+
await this.#failSession(id, adapter, cleanupFailure);
|
|
2608
|
+
if (replayFailure === undefined) throw cleanupFailure;
|
|
2609
|
+
// Both facts matter: what stopped replay, and which calls it left open. Reporting
|
|
2610
|
+
// only one is what kept a whole session's orphaned tool calls invisible.
|
|
2611
|
+
const detail = [replayFailure, cleanupFailure]
|
|
2612
|
+
.map(failure => (failure instanceof Error ? failure.message : String(failure)))
|
|
2613
|
+
.join("; ");
|
|
2614
|
+
throw aggregateAcpFailure(
|
|
2615
|
+
"frame_processing_failed",
|
|
2616
|
+
`ACP transcript replay failed and left published tool calls unclosed: ${detail}`,
|
|
2617
|
+
[replayFailure, cleanupFailure],
|
|
2618
|
+
);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
/**
|
|
2622
|
+
* Walks the transcript pages for {@link AcpAgent.#replaySession}. `replayTools` stays
|
|
2623
|
+
* owned by the caller so every start this pass published is still closable once it
|
|
2624
|
+
* exits, however it exits.
|
|
2625
|
+
*/
|
|
2626
|
+
async #replayTranscriptPages(
|
|
2627
|
+
id: string,
|
|
2628
|
+
adapter: AcpSdkAdapter,
|
|
2629
|
+
record: SessionRecord,
|
|
2630
|
+
replayTools: Map<string, { name: string; args: unknown }>,
|
|
2631
|
+
): Promise<void> {
|
|
2164
2632
|
let cursor: string | undefined;
|
|
2165
2633
|
let imageLimitationReported = false;
|
|
2166
|
-
|
|
2634
|
+
let unreplayableEntries = 0;
|
|
2635
|
+
let unreplayableReason: TranscriptReplaySkipReason | undefined;
|
|
2167
2636
|
for (let pageCount = 0; pageCount < MAX_ACP_REPLAY_PAGES; pageCount++) {
|
|
2168
2637
|
const response = object(await adapter.query("transcript.list", {}, cursor));
|
|
2169
2638
|
const result = object(response?.result) ?? response;
|
|
2170
2639
|
const page = object(result?.page);
|
|
2171
2640
|
for (const item of Array.isArray(page?.items) ? page.items : []) {
|
|
2172
|
-
const
|
|
2173
|
-
if (!
|
|
2174
|
-
|
|
2641
|
+
const raw = object(item);
|
|
2642
|
+
if (!raw) continue;
|
|
2643
|
+
// A body-less row is usually an oversized message, not a malformed entry:
|
|
2644
|
+
// its `continuations` say exactly how to read it back, so replay follows
|
|
2645
|
+
// them instead of dropping the largest message in the session.
|
|
2646
|
+
const message = typeof raw.body === "string" ? raw : await this.#recoverTranscriptEntry(adapter, raw);
|
|
2647
|
+
const replay = transcriptReplayContent(message);
|
|
2648
|
+
if (!replay.replayable) {
|
|
2649
|
+
unreplayableEntries++;
|
|
2650
|
+
unreplayableReason ??= replay.reason;
|
|
2651
|
+
const unresolvedId = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
|
|
2652
|
+
const unresolved = unresolvedId === undefined ? undefined : replayTools.get(unresolvedId);
|
|
2653
|
+
// The start keeps its place in `replayTools` until the close is accepted, so a
|
|
2654
|
+
// refused close is retried and named by the boundary instead of dropped here.
|
|
2655
|
+
if (unresolvedId !== undefined && unresolved) {
|
|
2656
|
+
const failure = await this.#closeReplayToolCall(
|
|
2657
|
+
id,
|
|
2658
|
+
adapter,
|
|
2659
|
+
record.cwd,
|
|
2660
|
+
unresolvedId,
|
|
2661
|
+
unresolved,
|
|
2662
|
+
TRANSCRIPT_TOOL_RESULT_UNAVAILABLE,
|
|
2663
|
+
);
|
|
2664
|
+
if (failure === undefined) replayTools.delete(unresolvedId);
|
|
2665
|
+
}
|
|
2666
|
+
continue;
|
|
2667
|
+
}
|
|
2668
|
+
const content = replay.content;
|
|
2175
2669
|
if (!imageLimitationReported) {
|
|
2176
2670
|
imageLimitationReported = true;
|
|
2177
2671
|
await this.#publishSessionUpdate(
|
|
@@ -2250,8 +2744,35 @@ export class AcpAgent implements Agent {
|
|
|
2250
2744
|
}
|
|
2251
2745
|
if (message.role === "toolResult" && typeof message.toolCallId === "string") {
|
|
2252
2746
|
const replayTool = replayTools.get(message.toolCallId);
|
|
2253
|
-
|
|
2254
|
-
|
|
2747
|
+
// Pairing outranks content: publishing a result whose `tool_call` start
|
|
2748
|
+
// never reached the client renders an update for a call it never saw begin.
|
|
2749
|
+
if (!replayTool) {
|
|
2750
|
+
unreplayableEntries++;
|
|
2751
|
+
unreplayableReason ??= "transcript_tool_call_unavailable";
|
|
2752
|
+
continue;
|
|
2753
|
+
}
|
|
2754
|
+
// The start already named the call, so a result row that lost its own name
|
|
2755
|
+
// falls back to it rather than dropping a result the client can still place.
|
|
2756
|
+
const toolName =
|
|
2757
|
+
typeof message.toolName === "string" && message.toolName ? message.toolName : replayTool.name;
|
|
2758
|
+
// A start with no usable name anywhere still has to reach a terminal status:
|
|
2759
|
+
// skipping it here is what left the call spinning at `pending` forever.
|
|
2760
|
+
if (!toolName) {
|
|
2761
|
+
unreplayableEntries++;
|
|
2762
|
+
unreplayableReason ??= "transcript_tool_call_unavailable";
|
|
2763
|
+
// The start keeps its place in `replayTools` until the close is accepted, so a
|
|
2764
|
+
// refused close is retried and named by the boundary instead of dropped here.
|
|
2765
|
+
const failure = await this.#closeReplayToolCall(
|
|
2766
|
+
id,
|
|
2767
|
+
adapter,
|
|
2768
|
+
record.cwd,
|
|
2769
|
+
message.toolCallId,
|
|
2770
|
+
replayTool,
|
|
2771
|
+
TRANSCRIPT_TOOL_RESULT_UNAVAILABLE,
|
|
2772
|
+
);
|
|
2773
|
+
if (failure === undefined) replayTools.delete(message.toolCallId);
|
|
2774
|
+
continue;
|
|
2775
|
+
}
|
|
2255
2776
|
const resultContent = richContent
|
|
2256
2777
|
?.map(object)
|
|
2257
2778
|
.filter(
|
|
@@ -2292,7 +2813,30 @@ export class AcpAgent implements Agent {
|
|
|
2292
2813
|
}
|
|
2293
2814
|
}
|
|
2294
2815
|
cursor = typeof page?.continuationCursor === "string" ? page.continuationCursor : undefined;
|
|
2295
|
-
if (
|
|
2816
|
+
if (cursor) continue;
|
|
2817
|
+
// Every start still parked here outlived its result row. `#replaySession` owns the
|
|
2818
|
+
// close at its single boundary, so this pass only accounts for them.
|
|
2819
|
+
if (replayTools.size > 0) {
|
|
2820
|
+
unreplayableEntries += replayTools.size;
|
|
2821
|
+
unreplayableReason ??= "transcript_tool_call_unavailable";
|
|
2822
|
+
}
|
|
2823
|
+
// An entry without its production body is skipped, never fatal: losing one
|
|
2824
|
+
// transcript row must not revoke `session/load` for the whole session.
|
|
2825
|
+
if (unreplayableReason)
|
|
2826
|
+
await this.#publishSessionUpdate(
|
|
2827
|
+
id,
|
|
2828
|
+
{
|
|
2829
|
+
sessionId: id,
|
|
2830
|
+
update: {
|
|
2831
|
+
sessionUpdate: "session_info_update",
|
|
2832
|
+
_meta: {
|
|
2833
|
+
gjcTranscriptReplaySkipped: { count: unreplayableEntries, reason: unreplayableReason },
|
|
2834
|
+
},
|
|
2835
|
+
},
|
|
2836
|
+
},
|
|
2837
|
+
adapter,
|
|
2838
|
+
);
|
|
2839
|
+
return;
|
|
2296
2840
|
}
|
|
2297
2841
|
throw new AcpSdkAdapterError("resource_exhausted", "ACP transcript replay exceeded the page limit.");
|
|
2298
2842
|
}
|
|
@@ -2467,24 +3011,7 @@ export class AcpAgent implements Agent {
|
|
|
2467
3011
|
try {
|
|
2468
3012
|
return await (await this.#brokerAdapter()).global(operation, input, idempotencyKey);
|
|
2469
3013
|
} catch (error) {
|
|
2470
|
-
|
|
2471
|
-
typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
|
|
2472
|
-
? error.code
|
|
2473
|
-
: undefined;
|
|
2474
|
-
if (
|
|
2475
|
-
mcpServers.length === 0 ||
|
|
2476
|
-
code === "invalid_input" ||
|
|
2477
|
-
code === "authentication_failed" ||
|
|
2478
|
-
code === "unknown_model_profile" ||
|
|
2479
|
-
code === "model_profile_registry_error"
|
|
2480
|
-
)
|
|
2481
|
-
throw error;
|
|
2482
|
-
const names = mcpServers
|
|
2483
|
-
.slice(0, 8)
|
|
2484
|
-
.map(server => server.name)
|
|
2485
|
-
.join(", ");
|
|
2486
|
-
const suffix = mcpServers.length > 8 ? `, and ${mcpServers.length - 8} more` : "";
|
|
2487
|
-
throw new AcpSdkAdapterError("unavailable", `MCP server request failed to start (${names}${suffix}).`);
|
|
3014
|
+
throw acpMcpLaunchFailure(error, mcpServers);
|
|
2488
3015
|
}
|
|
2489
3016
|
}
|
|
2490
3017
|
|