Skip to content

Commit d3e20a1

Browse files
grichaclaude
andauthored
Consolidate chat handlers with base class pattern (#33)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 99f5577 commit d3e20a1

9 files changed

Lines changed: 637 additions & 981 deletions

TODO.md

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -77,57 +77,6 @@ Switch from npm-based distribution to standalone binary distribution with curl i
7777

7878
---
7979

80-
### Consolidate Chat Handlers with Adapter Pattern
81-
82-
**Context:** `src/chat/handler.ts` (container) and `src/chat/host-handler.ts` (host) share ~95% identical code:
83-
- `StreamMessage` interface duplicated (L18-40 vs L11-33)
84-
- `processBuffer()` method identical
85-
- `handleStreamMessage()` method ~95% identical
86-
- `interrupt()` method identical
87-
- Default model `'sonnet'` hardcoded in both
88-
89-
Same pattern exists for OpenCode handlers. When we add more agents, this will get worse.
90-
91-
**Task:** Refactor to adapter pattern:
92-
93-
1. Create `src/chat/base-chat-session.ts`:
94-
- Move `StreamMessage` interface here
95-
- Create abstract `BaseChatSession` class with:
96-
- `protected buffer: string`
97-
- `protected sessionId?: string`
98-
- `protected model: string` (default from constant)
99-
- `protected processBuffer(): void` - shared implementation
100-
- `protected handleStreamMessage(msg: StreamMessage): void` - shared implementation
101-
- `async interrupt(): Promise<void>` - shared implementation
102-
- `abstract getSpawnCommand(): string[]` - what differs between container/host
103-
- `abstract getSpawnOptions(): object` - cwd, env differences
104-
105-
2. Create `src/chat/adapters/`:
106-
- `container-adapter.ts` - implements spawn for `docker exec` into container
107-
- `host-adapter.ts` - implements spawn for direct `claude` execution
108-
109-
3. Refactor `ChatSession` and `HostChatSession` to extend `BaseChatSession` and use adapters
110-
111-
4. Do the same for OpenCode handlers (`opencode-handler.ts`, `host-opencode-handler.ts`)
112-
113-
5. Move default model `'sonnet'` to `src/shared/constants.ts` as `DEFAULT_CLAUDE_MODEL`
114-
115-
**Files to create:**
116-
- `src/chat/base-chat-session.ts`
117-
- `src/chat/adapters/container-adapter.ts`
118-
- `src/chat/adapters/host-adapter.ts`
119-
120-
**Files to modify:**
121-
- `src/chat/handler.ts` - extend base, use adapter
122-
- `src/chat/host-handler.ts` - extend base, use adapter
123-
- `src/chat/opencode-handler.ts` - same pattern
124-
- `src/chat/host-opencode-handler.ts` - same pattern
125-
- `src/shared/constants.ts` - add DEFAULT_CLAUDE_MODEL
126-
127-
**Verify:** Chat still works for both container and host workspaces. Run existing chat tests.
128-
129-
---
130-
13180
## Considerations
13281

13382
> Add items here to discuss with project owner before promoting to tasks.

src/chat/base-claude-session.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import type { ChatProcess, ClaudeStreamMessage, MessageCallback, SpawnConfig } from './types';
2+
import { DEFAULT_CLAUDE_MODEL } from '../shared/constants';
3+
4+
export abstract class BaseClaudeSession {
5+
protected process: ChatProcess | null = null;
6+
protected sessionId?: string;
7+
protected model: string;
8+
protected sessionModel: string;
9+
protected onMessage: MessageCallback;
10+
protected buffer: string = '';
11+
12+
constructor(
13+
sessionId: string | undefined,
14+
model: string | undefined,
15+
onMessage: MessageCallback
16+
) {
17+
this.sessionId = sessionId;
18+
this.model = model || DEFAULT_CLAUDE_MODEL;
19+
this.sessionModel = this.model;
20+
this.onMessage = onMessage;
21+
}
22+
23+
protected abstract getSpawnConfig(userMessage: string): SpawnConfig;
24+
protected abstract getLogPrefix(): string;
25+
26+
async sendMessage(userMessage: string): Promise<void> {
27+
const logPrefix = this.getLogPrefix();
28+
const { command, options } = this.getSpawnConfig(userMessage);
29+
30+
console.log(`[${logPrefix}] Running:`, command.join(' '));
31+
32+
this.onMessage({
33+
type: 'system',
34+
content: 'Processing your message...',
35+
timestamp: new Date().toISOString(),
36+
});
37+
38+
try {
39+
const proc = Bun.spawn(command, {
40+
stdin: 'ignore',
41+
stdout: 'pipe',
42+
stderr: 'pipe',
43+
...options,
44+
});
45+
46+
this.process = proc;
47+
48+
if (!proc.stdout || !proc.stderr) {
49+
throw new Error('Failed to get process streams');
50+
}
51+
52+
console.log(`[${logPrefix}] Process spawned, waiting for output...`);
53+
54+
const stderrPromise = new Response(proc.stderr).text();
55+
56+
const decoder = new TextDecoder();
57+
let receivedAnyOutput = false;
58+
59+
for await (const chunk of proc.stdout) {
60+
const text = decoder.decode(chunk);
61+
console.log(`[${logPrefix}] Received chunk:`, text.length, 'bytes');
62+
receivedAnyOutput = true;
63+
this.buffer += text;
64+
this.processBuffer();
65+
}
66+
67+
const exitCode = await proc.exited;
68+
console.log(
69+
`[${logPrefix}] Process exited with code:`,
70+
exitCode,
71+
'receivedOutput:',
72+
receivedAnyOutput
73+
);
74+
75+
const stderrText = await stderrPromise;
76+
if (stderrText) {
77+
console.error(`[${logPrefix}] stderr:`, stderrText);
78+
}
79+
80+
if (exitCode !== 0) {
81+
this.onMessage({
82+
type: 'error',
83+
content: stderrText || `Claude exited with code ${exitCode}`,
84+
timestamp: new Date().toISOString(),
85+
});
86+
return;
87+
}
88+
89+
if (!receivedAnyOutput) {
90+
this.onMessage({
91+
type: 'error',
92+
content: this.getNoOutputErrorMessage(),
93+
timestamp: new Date().toISOString(),
94+
});
95+
return;
96+
}
97+
98+
this.onMessage({
99+
type: 'done',
100+
content: 'Response complete',
101+
timestamp: new Date().toISOString(),
102+
});
103+
} catch (err) {
104+
console.error(`[${logPrefix}] Error:`, err);
105+
this.onMessage({
106+
type: 'error',
107+
content: (err as Error).message,
108+
timestamp: new Date().toISOString(),
109+
});
110+
} finally {
111+
this.process = null;
112+
}
113+
}
114+
115+
protected getNoOutputErrorMessage(): string {
116+
return 'No response from Claude. Check if Claude is authenticated.';
117+
}
118+
119+
protected processBuffer(): void {
120+
const lines = this.buffer.split('\n');
121+
this.buffer = lines.pop() || '';
122+
123+
for (const line of lines) {
124+
if (!line.trim()) continue;
125+
126+
try {
127+
const msg: ClaudeStreamMessage = JSON.parse(line);
128+
this.handleStreamMessage(msg);
129+
} catch {
130+
console.error(`[${this.getLogPrefix()}] Failed to parse:`, line);
131+
}
132+
}
133+
}
134+
135+
protected handleStreamMessage(msg: ClaudeStreamMessage): void {
136+
const timestamp = new Date().toISOString();
137+
138+
if (msg.type === 'system' && msg.subtype === 'init') {
139+
this.sessionId = msg.session_id;
140+
this.sessionModel = this.model;
141+
this.onMessage({
142+
type: 'system',
143+
content: `Session started: ${msg.session_id?.slice(0, 8)}...`,
144+
timestamp,
145+
});
146+
return;
147+
}
148+
149+
if (msg.type === 'assistant' && msg.message?.content) {
150+
for (const block of msg.message.content) {
151+
if (block.type === 'tool_use') {
152+
this.onMessage({
153+
type: 'tool_use',
154+
content: JSON.stringify(block.input, null, 2),
155+
toolName: block.name,
156+
toolId: block.id,
157+
timestamp,
158+
});
159+
}
160+
}
161+
return;
162+
}
163+
164+
if (msg.type === 'stream_event' && msg.event?.type === 'content_block_delta') {
165+
const delta = msg.event?.delta;
166+
if (delta?.type === 'text_delta' && delta?.text) {
167+
this.onMessage({
168+
type: 'assistant',
169+
content: delta.text,
170+
timestamp,
171+
});
172+
}
173+
return;
174+
}
175+
}
176+
177+
async interrupt(): Promise<void> {
178+
if (this.process) {
179+
this.process.kill();
180+
this.process = null;
181+
this.onMessage({
182+
type: 'system',
183+
content: 'Chat interrupted',
184+
timestamp: new Date().toISOString(),
185+
});
186+
}
187+
}
188+
189+
setModel(model: string): void {
190+
if (this.model !== model) {
191+
this.model = model;
192+
if (this.sessionModel !== model) {
193+
this.sessionId = undefined;
194+
this.onMessage({
195+
type: 'system',
196+
content: `Switching to model: ${model}`,
197+
timestamp: new Date().toISOString(),
198+
});
199+
}
200+
}
201+
}
202+
203+
getSessionId(): string | undefined {
204+
return this.sessionId;
205+
}
206+
}

0 commit comments

Comments
 (0)