Skip to content

Commit 210348d

Browse files
centdixclaude
andcommitted
feat: add claude conversation streaming
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 632c504 commit 210348d

17 files changed

Lines changed: 1501 additions & 184 deletions

backend/src/__tests__/agent-service.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,23 @@ describe("agent-service command builders", () => {
118118
expect(command).toContain("claude --dangerously-skip-permissions --continue -- 'fix the tests'");
119119
});
120120

121+
it("uses an explicit Claude session id when refreshing a terminal", () => {
122+
const command = buildAgentPaneCommand({
123+
agent: builtInAgent("claude"),
124+
runtimeEnvPath: "/tmp/gitdir/webmux/runtime.env",
125+
repoRoot: "/repo",
126+
worktreePath: "/repo/__worktrees/feature",
127+
branch: "feature",
128+
profileName: "default",
129+
yolo: true,
130+
launchMode: "resume",
131+
resumeConversationId: "session-123",
132+
});
133+
134+
expect(command).toContain("claude --dangerously-skip-permissions --resume 'session-123'");
135+
expect(command).not.toContain("--continue");
136+
});
137+
121138
it("builds docker commands that exec inside the container", () => {
122139
const shell = buildDockerShellCommand(
123140
"wm-feature-container",

backend/src/__tests__/claude-cli.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,89 @@
11
import { describe, expect, it } from "bun:test";
2-
import { buildClaudeSessionFromText, encodeClaudeProjectDir } from "../adapters/claude-cli";
2+
import { buildClaudeSessionFromText, encodeClaudeProjectDir, parseClaudeStreamLine } from "../adapters/claude-cli";
33

44
describe("claude-cli adapter", () => {
55
it("encodes Claude project directories from cwd", () => {
66
expect(encodeClaudeProjectDir("/tmp/worktrees/feature.one")).toBe("-tmp-worktrees-feature-one");
77
});
88

9+
it("parses text deltas from Claude stream-json output", () => {
10+
expect(parseClaudeStreamLine(JSON.stringify({
11+
type: "stream_event",
12+
session_id: "session-1",
13+
event: {
14+
type: "content_block_delta",
15+
index: 2,
16+
delta: {
17+
type: "text_delta",
18+
text: "hello",
19+
},
20+
},
21+
}))).toEqual({
22+
sessionId: "session-1",
23+
assistantDelta: {
24+
delta: "hello",
25+
blockIndex: 2,
26+
},
27+
messages: [],
28+
completeSessionId: null,
29+
error: null,
30+
});
31+
});
32+
33+
it("parses finalized text and tool messages from Claude stream-json output", () => {
34+
expect(parseClaudeStreamLine(JSON.stringify({
35+
type: "assistant",
36+
session_id: "session-1",
37+
uuid: "assistant-1",
38+
message: {
39+
role: "assistant",
40+
content: [
41+
{ type: "text", text: "Reading the file." },
42+
{ type: "tool_use", id: "tool-1", name: "Read", input: { file_path: "/tmp/foo.txt" } },
43+
],
44+
},
45+
}))?.messages).toEqual([
46+
{
47+
uuid: "assistant-1",
48+
role: "assistant",
49+
kind: "text",
50+
text: "Reading the file.",
51+
createdAt: null,
52+
},
53+
{
54+
uuid: "assistant-1",
55+
role: "assistant",
56+
kind: "toolUse",
57+
toolName: "Read",
58+
toolCallId: "tool-1",
59+
text: `{"file_path":"/tmp/foo.txt"}`,
60+
createdAt: null,
61+
},
62+
]);
63+
64+
expect(parseClaudeStreamLine(JSON.stringify({
65+
type: "user",
66+
session_id: "session-1",
67+
uuid: "tool-result-1",
68+
timestamp: "2026-04-14T15:00:02.000Z",
69+
message: {
70+
role: "user",
71+
content: [
72+
{ type: "tool_result", tool_use_id: "tool-1", content: "hello world" },
73+
],
74+
},
75+
}))?.messages).toEqual([
76+
{
77+
uuid: "tool-result-1",
78+
role: "user",
79+
kind: "toolResult",
80+
toolCallId: "tool-1",
81+
text: "hello world",
82+
createdAt: "2026-04-14T15:00:02.000Z",
83+
},
84+
]);
85+
});
86+
987
it("builds a transcript from Claude session jsonl text", () => {
1088
const session = buildClaudeSessionFromText({
1189
path: "/tmp/session.jsonl",
@@ -153,6 +231,7 @@ describe("claude-cli adapter", () => {
153231
role: "assistant",
154232
kind: "toolUse",
155233
toolName: "Read",
234+
toolCallId: "tool-1",
156235
text: `{"file_path":"/tmp/foo.txt"}`,
157236
createdAt: "2026-04-14T15:00:01.000Z",
158237
},
@@ -161,6 +240,7 @@ describe("claude-cli adapter", () => {
161240
turnId: "user-1",
162241
role: "user",
163242
kind: "toolResult",
243+
toolCallId: "tool-1",
164244
text: "hello world",
165245
createdAt: "2026-04-14T15:00:02.000Z",
166246
},

backend/src/__tests__/claude-conversation-service.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,76 @@ describe("ClaudeConversationService", () => {
148148
lastSeenAt: "2026-04-14T12:00:00.000Z",
149149
});
150150
});
151+
152+
it("keeps a saved session id even before Claude has written the jsonl", async () => {
153+
const metaStore = new Map<string, WorktreeMeta>();
154+
const worktree = makeWorktree();
155+
const gitDir = `${worktree.path}/.git`;
156+
metaStore.set(gitDir, {
157+
...makeMeta(),
158+
conversation: {
159+
provider: "claudeCode",
160+
conversationId: "session-pending",
161+
sessionId: "session-pending",
162+
cwd: worktree.path,
163+
lastSeenAt: "2026-04-14T11:00:00.000Z",
164+
},
165+
});
166+
167+
const claude = new FakeClaudeCliGateway();
168+
const service = new ClaudeConversationService({
169+
claude,
170+
git: new FakeGitGateway(),
171+
now: () => new Date("2026-04-14T12:00:00.000Z"),
172+
readMeta: async (path) => structuredClone(metaStore.get(path) ?? null),
173+
writeMeta: async (path, meta) => {
174+
metaStore.set(path, structuredClone(meta));
175+
},
176+
});
177+
178+
const result = await service.readWorktreeConversation(worktree);
179+
expect(result.ok).toBe(true);
180+
if (!result.ok) return;
181+
182+
expect(result.data.conversation.conversationId).toBe("session-pending");
183+
expect(result.data.conversation.messages).toEqual([]);
184+
expect(result.data.worktree.conversation).toEqual({
185+
provider: "claudeCode",
186+
conversationId: "session-pending",
187+
sessionId: "session-pending",
188+
cwd: worktree.path,
189+
lastSeenAt: "2026-04-14T11:00:00.000Z",
190+
});
191+
});
192+
193+
it("persists a generated Claude session id before the session file exists", async () => {
194+
const metaStore = new Map<string, WorktreeMeta>();
195+
const worktree = makeWorktree();
196+
const gitDir = `${worktree.path}/.git`;
197+
metaStore.set(gitDir, makeMeta());
198+
199+
const claude = new FakeClaudeCliGateway();
200+
const service = new ClaudeConversationService({
201+
claude,
202+
git: new FakeGitGateway(),
203+
now: () => new Date("2026-04-14T12:00:00.000Z"),
204+
readMeta: async (path) => structuredClone(metaStore.get(path) ?? null),
205+
writeMeta: async (path, meta) => {
206+
metaStore.set(path, structuredClone(meta));
207+
},
208+
});
209+
210+
const result = await service.setWorktreeConversationSession(worktree, "session-new");
211+
expect(result.ok).toBe(true);
212+
if (!result.ok) return;
213+
214+
expect(result.data).toEqual({
215+
provider: "claudeCode",
216+
conversationId: "session-new",
217+
sessionId: "session-new",
218+
cwd: worktree.path,
219+
lastSeenAt: "2026-04-14T12:00:00.000Z",
220+
});
221+
expect(metaStore.get(gitDir)?.conversation).toEqual(result.data);
222+
});
151223
});

0 commit comments

Comments
 (0)