Skip to content

Commit d0ac900

Browse files
committed
Add subagent transcript inspection across Claude and Codex
- Discover and read Claude subagent transcripts with metadata and model details - Map subagent transcript events through provider adapters and shared contracts - Integrate transcript inspection into chat timelines, headers, and activity views - Add coverage for transcript resolution, mapping, and session behavior
1 parent 50daa7f commit d0ac900

23 files changed

Lines changed: 2432 additions & 368 deletions
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// @effect-diagnostics nodeBuiltinImport:off
2+
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
6+
import * as NodeServices from "@effect/platform-node/NodeServices";
7+
import { assert, describe, it } from "@effect/vitest";
8+
import * as Effect from "effect/Effect";
9+
10+
import { mapClaudeSubagentTranscriptRecords } from "../Layers/ClaudeAdapter.ts";
11+
import {
12+
clearClaudeSubagentTranscriptCaches,
13+
locateClaudeSubagentTranscript,
14+
readClaudeSubagentTranscriptEntries,
15+
} from "./ClaudeSubagentTranscripts.ts";
16+
import { claudeProjectDirectoryName } from "./ClaudeSessionTranscripts.ts";
17+
18+
const SESSION_ID = "550e8400-e29b-41d4-a716-446655440000";
19+
20+
function makeConfigDir(): { readonly configDir: string; readonly cleanup: () => void } {
21+
const configDir = mkdtempSync(path.join(os.tmpdir(), "claude-subagent-transcripts-"));
22+
return {
23+
configDir,
24+
cleanup: () => rmSync(configDir, { recursive: true, force: true }),
25+
};
26+
}
27+
28+
function subagentsDirectory(configDir: string, projectCwd: string): string {
29+
return path.join(
30+
configDir,
31+
"projects",
32+
claudeProjectDirectoryName(projectCwd),
33+
SESSION_ID,
34+
"subagents",
35+
);
36+
}
37+
38+
function transcriptLine(text: string, timestamp?: string, model?: string): string {
39+
return `${JSON.stringify({
40+
type: "assistant",
41+
message: { content: text, ...(model ? { model } : {}) },
42+
...(timestamp ? { timestamp } : {}),
43+
})}\n`;
44+
}
45+
46+
it.layer(NodeServices.layer)("ClaudeSubagentTranscripts", (it) => {
47+
describe("locateClaudeSubagentTranscript", () => {
48+
it.effect("resolves a transcript by its on-disk agent id", () => {
49+
const { configDir, cleanup } = makeConfigDir();
50+
const cwd = "/repo/main";
51+
const directory = subagentsDirectory(configDir, cwd);
52+
const transcriptPath = path.join(directory, "agent-agent-123.jsonl");
53+
mkdirSync(directory, { recursive: true });
54+
writeFileSync(transcriptPath, transcriptLine("hello"));
55+
clearClaudeSubagentTranscriptCaches();
56+
57+
return Effect.gen(function* () {
58+
const location = yield* locateClaudeSubagentTranscript({
59+
environment: { CLAUDE_CONFIG_DIR: configDir },
60+
cwd,
61+
sessionId: SESSION_ID,
62+
agentId: "agent-123",
63+
});
64+
assert.deepEqual(location, { transcriptPath, agentId: "agent-123" });
65+
}).pipe(
66+
Effect.ensuring(
67+
Effect.sync(() => {
68+
clearClaudeSubagentTranscriptCaches();
69+
cleanup();
70+
}),
71+
),
72+
);
73+
});
74+
75+
it.effect("resolves a spawning tool_use id and returns its sidecar metadata", () => {
76+
const { configDir, cleanup } = makeConfigDir();
77+
const cwd = "/repo/main";
78+
const directory = subagentsDirectory(configDir, cwd);
79+
const transcriptPath = path.join(directory, "agent-a720e4804f5b5953a.jsonl");
80+
const meta = {
81+
agentType: "Explore",
82+
description: "Research browser control internals",
83+
toolUseId: "toolu_01DL1vhTz3MADJXec2wnawbi",
84+
spawnDepth: 1,
85+
model: "fable",
86+
};
87+
mkdirSync(directory, { recursive: true });
88+
writeFileSync(transcriptPath, transcriptLine("hello"));
89+
writeFileSync(
90+
path.join(directory, "agent-a720e4804f5b5953a.meta.json"),
91+
JSON.stringify(meta),
92+
);
93+
clearClaudeSubagentTranscriptCaches();
94+
95+
return Effect.gen(function* () {
96+
const location = yield* locateClaudeSubagentTranscript({
97+
environment: { CLAUDE_CONFIG_DIR: configDir },
98+
cwd,
99+
sessionId: SESSION_ID,
100+
agentId: meta.toolUseId,
101+
});
102+
assert.deepEqual(location, {
103+
transcriptPath,
104+
agentId: "a720e4804f5b5953a",
105+
meta,
106+
});
107+
}).pipe(
108+
Effect.ensuring(
109+
Effect.sync(() => {
110+
clearClaudeSubagentTranscriptCaches();
111+
cleanup();
112+
}),
113+
),
114+
);
115+
});
116+
117+
it.effect("resolves from a project directory other than the cwd slug", () => {
118+
const { configDir, cleanup } = makeConfigDir();
119+
const cwd = "/repo/main/.claude/worktrees/feature";
120+
const originalCwd = "/repo/main";
121+
const directory = subagentsDirectory(configDir, originalCwd);
122+
const transcriptPath = path.join(directory, "agent-original.jsonl");
123+
mkdirSync(directory, { recursive: true });
124+
writeFileSync(transcriptPath, transcriptLine("hello"));
125+
clearClaudeSubagentTranscriptCaches();
126+
127+
return Effect.gen(function* () {
128+
const location = yield* locateClaudeSubagentTranscript({
129+
environment: { CLAUDE_CONFIG_DIR: configDir },
130+
cwd,
131+
sessionId: SESSION_ID,
132+
agentId: "original",
133+
});
134+
assert.deepEqual(location, { transcriptPath, agentId: "original" });
135+
}).pipe(
136+
Effect.ensuring(
137+
Effect.sync(() => {
138+
clearClaudeSubagentTranscriptCaches();
139+
cleanup();
140+
}),
141+
),
142+
);
143+
});
144+
145+
it.effect("resolves a transcript nested under a workflow directory", () => {
146+
const { configDir, cleanup } = makeConfigDir();
147+
const cwd = "/repo/main";
148+
const directory = path.join(subagentsDirectory(configDir, cwd), "workflows", "wf-123");
149+
const transcriptPath = path.join(directory, "agent-workflow-agent.jsonl");
150+
mkdirSync(directory, { recursive: true });
151+
writeFileSync(transcriptPath, transcriptLine("hello"));
152+
clearClaudeSubagentTranscriptCaches();
153+
154+
return Effect.gen(function* () {
155+
const location = yield* locateClaudeSubagentTranscript({
156+
environment: { CLAUDE_CONFIG_DIR: configDir },
157+
cwd,
158+
sessionId: SESSION_ID,
159+
agentId: "workflow-agent",
160+
});
161+
assert.deepEqual(location, { transcriptPath, agentId: "workflow-agent" });
162+
}).pipe(
163+
Effect.ensuring(
164+
Effect.sync(() => {
165+
clearClaudeSubagentTranscriptCaches();
166+
cleanup();
167+
}),
168+
),
169+
);
170+
});
171+
172+
it.effect("returns null when no transcript matches", () => {
173+
const { configDir, cleanup } = makeConfigDir();
174+
clearClaudeSubagentTranscriptCaches();
175+
176+
return Effect.gen(function* () {
177+
const location = yield* locateClaudeSubagentTranscript({
178+
environment: { CLAUDE_CONFIG_DIR: configDir },
179+
cwd: "/repo/main",
180+
sessionId: SESSION_ID,
181+
agentId: "missing",
182+
});
183+
assert.equal(location, null);
184+
}).pipe(
185+
Effect.ensuring(
186+
Effect.sync(() => {
187+
clearClaudeSubagentTranscriptCaches();
188+
cleanup();
189+
}),
190+
),
191+
);
192+
});
193+
});
194+
195+
describe("readClaudeSubagentTranscriptEntries", () => {
196+
it.effect("reports the model the agent's own records were produced by", () => {
197+
const { configDir, cleanup } = makeConfigDir();
198+
const transcriptPath = path.join(configDir, "agent-model.jsonl");
199+
writeFileSync(transcriptPath, transcriptLine("first", undefined, "claude-opus-5"));
200+
clearClaudeSubagentTranscriptCaches();
201+
202+
return Effect.gen(function* () {
203+
const read = yield* readClaudeSubagentTranscriptEntries({
204+
transcriptPath,
205+
mapLines: mapClaudeSubagentTranscriptRecords,
206+
});
207+
assert.equal(read.model, "claude-opus-5");
208+
209+
// The model survives a cached read and an incremental append.
210+
assert.equal(
211+
(yield* readClaudeSubagentTranscriptEntries({
212+
transcriptPath,
213+
mapLines: mapClaudeSubagentTranscriptRecords,
214+
})).model,
215+
"claude-opus-5",
216+
);
217+
appendFileSync(transcriptPath, transcriptLine("second"));
218+
assert.equal(
219+
(yield* readClaudeSubagentTranscriptEntries({
220+
transcriptPath,
221+
mapLines: mapClaudeSubagentTranscriptRecords,
222+
})).model,
223+
"claude-opus-5",
224+
);
225+
}).pipe(
226+
Effect.ensuring(
227+
Effect.sync(() => {
228+
clearClaudeSubagentTranscriptCaches();
229+
cleanup();
230+
}),
231+
),
232+
);
233+
});
234+
235+
it.effect("reads appended lines incrementally and re-reads a rewritten file", () => {
236+
const { configDir, cleanup } = makeConfigDir();
237+
const transcriptPath = path.join(configDir, "agent-incremental.jsonl");
238+
writeFileSync(transcriptPath, transcriptLine("first", "2026-07-26T12:00:00.000Z"));
239+
clearClaudeSubagentTranscriptCaches();
240+
241+
return Effect.gen(function* () {
242+
const first = yield* readClaudeSubagentTranscriptEntries({
243+
transcriptPath,
244+
mapLines: mapClaudeSubagentTranscriptRecords,
245+
});
246+
assert.deepEqual(first, {
247+
entries: [
248+
{
249+
role: "assistant",
250+
text: "first",
251+
at: "2026-07-26T12:00:00.000Z",
252+
toolUses: [],
253+
},
254+
],
255+
droppedEntries: 0,
256+
});
257+
258+
appendFileSync(transcriptPath, transcriptLine("second") + transcriptLine("third"));
259+
const appended = yield* readClaudeSubagentTranscriptEntries({
260+
transcriptPath,
261+
mapLines: mapClaudeSubagentTranscriptRecords,
262+
});
263+
assert.deepEqual(
264+
appended.entries.map((entry) => entry.text),
265+
["first", "second", "third"],
266+
);
267+
268+
writeFileSync(transcriptPath, transcriptLine("rewritten"));
269+
const rewritten = yield* readClaudeSubagentTranscriptEntries({
270+
transcriptPath,
271+
mapLines: mapClaudeSubagentTranscriptRecords,
272+
});
273+
assert.deepEqual(
274+
rewritten.entries.map((entry) => entry.text),
275+
["rewritten"],
276+
);
277+
assert.equal(rewritten.droppedEntries, 0);
278+
}).pipe(
279+
Effect.ensuring(
280+
Effect.sync(() => {
281+
clearClaudeSubagentTranscriptCaches();
282+
cleanup();
283+
}),
284+
),
285+
);
286+
});
287+
});
288+
});

0 commit comments

Comments
 (0)