Repository navigation
Expand file tree
/
Copy pathdev-replay-agent.ts
More file actions
384 lines (363 loc) · 14.8 KB
/
Copy pathdev-replay-agent.ts
File metadata and controls
384 lines (363 loc) · 14.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
#!/usr/bin/env node
// @effect-diagnostics nodeBuiltinImport:off globalTimers:off
/**
* A free ACP agent for dev servers. It plays recorded ACP transcripts with simulated
* streaming, so the real adapter, orchestration, and UI run without a paid model.
* Each scenario is a "model"; each prompt plays the next recorded turn.
* Unlike acp-replay-agent.ts, it does not assert the client's frames.
*/
import * as NodeCrypto from "node:crypto";
import * as NodeFS from "node:fs";
import * as NodeReadline from "node:readline";
interface LogicalFrame {
readonly kind: "notification" | "request" | "response";
readonly method: string;
readonly params?: unknown;
readonly result?: unknown;
}
interface ReplayEntry {
readonly type: "emit_inbound" | "expect_outbound" | "runtime_exit" | "transcript_start";
readonly frame?: LogicalFrame;
readonly afterMs?: number;
}
interface JsonRpcMessage {
readonly jsonrpc?: string;
readonly id?: string | number | null;
readonly method?: string;
readonly params?: Record<string, unknown>;
readonly result?: unknown;
readonly error?: unknown;
}
export interface ReplayTurn {
/** Frames the agent sends: session updates and agent-to-client requests. */
readonly frames: ReadonlyArray<{ readonly frame: LogicalFrame; readonly afterMs?: number }>;
readonly stopReason: string;
}
const FIXTURES = "../src/orchestration-v2/testkit/fixtures";
// Recorded fixtures the replay tests already pin, plus a hand-written UI showcase.
// Use acpRegistry transcripts: vendor transcripts carry state in frames this agent drops.
export const SCENARIOS = [
{ id: "showcase", name: "Showcase", file: "./dev-replay/showcase_transcript.ndjson" },
{
id: "todo-list",
name: "Plan and reads",
file: `${FIXTURES}/todo_list/registry_transcript.ndjson`,
},
{
id: "approval",
name: "Write approval",
file: `${FIXTURES}/tool_call_read_only_on_request/registry_transcript.ndjson`,
},
{
id: "question",
name: "Question",
file: `${FIXTURES}/acp_elicitation/registry_transcript.ndjson`,
},
{
id: "multi-turn",
name: "Multi turn",
file: `${FIXTURES}/multi_turn/registry_transcript.ndjson`,
},
] as const;
// Session setup and vendor state stay out: they would replace the replay agent's own models.
const REPLAYED_UPDATES = new Set([
"agent_message_chunk",
"agent_thought_chunk",
"tool_call",
"tool_call_update",
"plan",
"plan_update",
"plan_removed",
"session_info_update",
"usage_update",
]);
// Only requests that ask the user. A replayed terminal/create would run a real command.
const REPLAYED_REQUESTS = new Set(["session/request_permission", "elicitation/create"]);
const CHUNK_DELAY_MS = 30;
const FRAME_DELAY_MS = 250;
const MAX_RECORDED_DELAY_MS = 2_000;
const WORDS_PER_CHUNK = 3;
function isReplayedFrame(frame: LogicalFrame): boolean {
if (frame.kind === "request") return REPLAYED_REQUESTS.has(frame.method);
if (frame.kind !== "notification" || frame.method !== "session/update") return false;
const update = (frame.params as { update?: { sessionUpdate?: unknown } } | undefined)?.update;
return typeof update?.sessionUpdate === "string" && REPLAYED_UPDATES.has(update.sessionUpdate);
}
/** Splits a recorded transcript into the turns that follow each `session/prompt`. */
export function parseReplayTurns(ndjson: string): ReadonlyArray<ReplayTurn> {
const turns: Array<{ frames: Array<ReplayTurn["frames"][number]>; stopReason: string }> = [];
// Frames after a prompt's response belong to background work, not to that turn.
let turn: (typeof turns)[number] | undefined;
for (const line of ndjson.split("\n")) {
if (line.trim().length === 0) continue;
const entry = JSON.parse(line) as ReplayEntry;
const frame = entry.frame;
if (frame === undefined) continue;
if (
entry.type === "expect_outbound" &&
frame.kind === "request" &&
frame.method === "session/prompt"
) {
turn = { frames: [], stopReason: "end_turn" };
turns.push(turn);
continue;
}
if (entry.type !== "emit_inbound" || turn === undefined) continue;
if (frame.kind === "response" && frame.method === "session/prompt") {
const stopReason = (frame.result as { stopReason?: unknown } | undefined)?.stopReason;
if (typeof stopReason === "string") turn.stopReason = stopReason;
turn = undefined;
continue;
}
if (isReplayedFrame(frame)) {
turn.frames.push(entry.afterMs === undefined ? { frame } : { frame, afterMs: entry.afterMs });
}
}
return turns;
}
/** Splits streamed text into small chunks so the client sees real deltas. */
export function streamChunks(text: string): ReadonlyArray<string> {
const words = text.match(/\S+\s*|\s+/g) ?? [];
const chunks: Array<string> = [];
for (let index = 0; index < words.length; index += WORDS_PER_CHUNK) {
chunks.push(words.slice(index, index + WORDS_PER_CHUNK).join(""));
}
return chunks.length === 0 ? [text] : chunks;
}
/** Fills the live session, workspace, and per-prompt tool ids, so repeated turns add rows. */
function materialize(
value: unknown,
live: { readonly sessionId: string; readonly workspace: string; readonly turnKey: string },
): unknown {
if (typeof value === "string") return value.replaceAll("<workspace>", live.workspace);
if (Array.isArray(value)) return value.map((entry) => materialize(entry, live));
if (typeof value !== "object" || value === null) return value;
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
key === "sessionId"
? live.sessionId
: key === "toolCallId"
? `${live.turnKey}-${String(entry)}`
: materialize(entry, live),
]),
);
}
/** The tool call a permission answer rejected, if the user declined or cancelled. */
function permissionRejectedToolCallId(request: LogicalFrame, answer: unknown): string | undefined {
if (request.method !== "session/request_permission") return undefined;
const params = request.params as {
toolCall?: { toolCallId?: string };
options?: ReadonlyArray<{ optionId: string; kind: string }>;
};
const outcome = (answer as { outcome?: { outcome?: string; optionId?: string } } | undefined)
?.outcome;
const chosen = params.options?.find((option) => option.optionId === outcome?.optionId);
const rejected = outcome?.outcome !== "selected" || chosen?.kind.startsWith("reject") === true;
return rejected ? params.toolCall?.toolCallId : undefined;
}
function runAgent(): void {
const turnsByScenario = new Map<string, ReadonlyArray<ReplayTurn>>();
const loadTurns = (scenarioId: string) => {
const cached = turnsByScenario.get(scenarioId);
if (cached !== undefined) return cached;
const scenario = SCENARIOS.find((candidate) => candidate.id === scenarioId) ?? SCENARIOS[0];
const turns = parseReplayTurns(
NodeFS.readFileSync(new URL(scenario.file, import.meta.url), "utf8"),
);
turnsByScenario.set(scenarioId, turns);
return turns;
};
const sessions = new Map<
string,
{ cwd: string; model: string; promptCount: number; cancel: (() => void) | undefined }
>();
const pendingAgentRequests = new Map<string, (result: unknown) => void>();
let nextAgentRequestId = 1;
const send = (message: JsonRpcMessage) =>
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
// Resolves when `promise` settles or the turn is cancelled, whichever comes first.
const untilAbort = (promise: Promise<unknown>, signal: AbortSignal) =>
new Promise<void>((resolve) => {
if (signal.aborted) return resolve();
const onAbort = () => resolve();
signal.addEventListener("abort", onAbort, { once: true });
void promise.then(() => {
signal.removeEventListener("abort", onAbort);
resolve();
});
});
const sleep = (ms: number, signal: AbortSignal) =>
untilAbort(
new Promise((resolve) => setTimeout(resolve, Math.min(ms, MAX_RECORDED_DELAY_MS))),
signal,
);
const playTurn = async (
sessionId: string,
turn: ReplayTurn,
signal: AbortSignal,
): Promise<"cancelled" | undefined> => {
const session = sessions.get(sessionId)!;
// Random, so a session loaded after a restart cannot reuse earlier tool rows.
const live = { sessionId, workspace: session.cwd, turnKey: NodeCrypto.randomUUID() };
for (const recorded of turn.frames) {
const frame = materialize(recorded.frame, live) as LogicalFrame;
if (frame.kind === "request") {
await sleep(recorded.afterMs ?? FRAME_DELAY_MS, signal);
if (signal.aborted) return;
const id = nextAgentRequestId++;
let answer: unknown;
const response = new Promise((resolve) =>
pendingAgentRequests.set(String(id), (result) => resolve((answer = result))),
);
send({ id, method: frame.method, params: frame.params as Record<string, unknown> });
await untilAbort(response, signal);
pendingAgentRequests.delete(String(id));
// The client answers "cancelled" when the user stops the turn mid-request.
const outcome = (answer as { outcome?: { outcome?: string } } | undefined)?.outcome;
if (signal.aborted || outcome?.outcome === "cancelled") return "cancelled";
const toolCallId = permissionRejectedToolCallId(frame, answer);
if (toolCallId === undefined) continue;
// The recording only has the approved path, so a rejection ends the turn here.
send({
method: "session/update",
params: {
sessionId,
update: { sessionUpdate: "tool_call_update", toolCallId, status: "failed" },
},
});
return;
}
const params = frame.params as { update: { sessionUpdate: string; content?: unknown } };
const content = params.update.content as { type?: string; text?: string } | undefined;
const text =
(params.update.sessionUpdate === "agent_message_chunk" ||
params.update.sessionUpdate === "agent_thought_chunk") &&
content?.type === "text"
? content.text
: undefined;
// Live recordings hold one frame per token, so text defaults to the chunk pace.
const firstDelay = recorded.afterMs ?? (text === undefined ? FRAME_DELAY_MS : CHUNK_DELAY_MS);
const chunks = text === undefined ? [undefined] : streamChunks(text);
for (const [index, chunk] of chunks.entries()) {
await sleep(index === 0 ? firstDelay : CHUNK_DELAY_MS, signal);
if (signal.aborted) return;
send({
method: frame.method,
params:
chunk === undefined
? params
: { ...params, update: { ...params.update, content: { ...content, text: chunk } } },
});
}
}
};
const handleRequest = async (message: JsonRpcMessage): Promise<unknown> => {
const params = message.params ?? {};
switch (message.method) {
case "initialize":
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
promptCapabilities: { image: false, audio: false, embeddedContext: true },
},
authMethods: [],
agentInfo: { name: "t3-dev-replay", version: "1" },
};
case "session/new":
case "session/load": {
// T3 starts one agent process per thread, so ids must be unique across processes.
// Loading after a restart keeps the thread usable; T3 already holds its history.
const sessionId =
message.method === "session/load" ? String(params.sessionId) : NodeCrypto.randomUUID();
const cwd = typeof params.cwd === "string" ? params.cwd : process.cwd();
sessions.set(sessionId, { cwd, model: SCENARIOS[0].id, promptCount: 0, cancel: undefined });
return {
...(message.method === "session/new" ? { sessionId } : {}),
models: {
currentModelId: SCENARIOS[0].id,
availableModels: SCENARIOS.map((scenario) => ({
modelId: scenario.id,
name: scenario.name,
})),
},
};
}
case "session/set_model": {
const session = sessions.get(String(params.sessionId));
if (!SCENARIOS.some((scenario) => scenario.id === params.modelId)) {
throw Object.assign(new Error(`Unknown replay scenario ${String(params.modelId)}`), {
code: -32602,
});
}
if (session !== undefined && session.model !== params.modelId) {
session.model = String(params.modelId);
session.promptCount = 0;
}
return {};
}
case "session/prompt": {
const sessionId = String(params.sessionId);
const session = sessions.get(sessionId);
if (session === undefined) throw new Error(`Unknown session ${sessionId}`);
const turns = loadTurns(session.model);
const turn = turns[session.promptCount % Math.max(turns.length, 1)];
session.promptCount += 1;
const controller = new AbortController();
session.cancel = () => controller.abort();
let ended: "cancelled" | undefined;
try {
if (turn !== undefined) ended = await playTurn(sessionId, turn, controller.signal);
} finally {
session.cancel = undefined;
}
return {
stopReason:
controller.signal.aborted || ended === "cancelled"
? "cancelled"
: (turn?.stopReason ?? "end_turn"),
};
}
case "session/close":
sessions.get(String(params.sessionId))?.cancel?.();
sessions.delete(String(params.sessionId));
return {};
case "authenticate":
case "session/set_mode":
return {};
default:
throw Object.assign(new Error(`Method not found: ${message.method}`), { code: -32601 });
}
};
const input = NodeReadline.createInterface({ input: process.stdin, crlfDelay: Infinity });
input.on("line", (line) => {
if (line.trim().length === 0) return;
let message: JsonRpcMessage;
try {
message = JSON.parse(line) as JsonRpcMessage;
} catch {
process.stderr.write(`dev replay agent ignored a line that is not JSON\n`);
return;
}
if (message.method === undefined) {
const resolve = pendingAgentRequests.get(String(message.id));
pendingAgentRequests.delete(String(message.id));
resolve?.(message.result);
return;
}
if (message.id === undefined || message.id === null) {
if (message.method === "session/cancel") {
sessions.get(String(message.params?.sessionId))?.cancel?.();
}
return;
}
const id = message.id;
handleRequest(message).then(
(result) => send({ id, result }),
(error: Error & { code?: number }) =>
send({ id, error: { code: error.code ?? -32603, message: error.message } }),
);
});
}
if (import.meta.main) runAgent();