Skip to content

Commit 0da7076

Browse files
authored
Merge pull request #15 from PascalOrdano18/l-mendez/chat-ui-redesign
feat(agent): Claude SDK migration with chat UI and worktree isolation
2 parents 44084eb + 5c48cc3 commit 0da7076

38 files changed

Lines changed: 4276 additions & 589 deletions

agent-orchestrator-main/packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"screenshot:install": "npx playwright install chromium"
2828
},
2929
"dependencies": {
30+
"@anthropic-ai/claude-code": "^2.1.87",
3031
"@composio/ao-core": "workspace:*",
3132
"@composio/ao-plugin-agent-claude-code": "workspace:*",
3233
"@composio/ao-plugin-agent-opencode": "workspace:*",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
2+
// See LICENSE.txt for license information.
3+
4+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
5+
6+
// Mock the Claude Code SDK
7+
vi.mock("@anthropic-ai/claude-code", () => ({
8+
claude: vi.fn(),
9+
}));
10+
11+
import { AgentProcessManager } from "../agent-process-manager";
12+
import type { AgentEvent } from "../../src/lib/agent-events";
13+
14+
describe("AgentProcessManager", () => {
15+
let manager: AgentProcessManager;
16+
17+
beforeEach(() => {
18+
manager = new AgentProcessManager();
19+
});
20+
21+
afterEach(() => {
22+
manager.destroyAll();
23+
});
24+
25+
it("returns empty history for unknown session", () => {
26+
expect(manager.getHistory("nonexistent")).toEqual([]);
27+
});
28+
29+
it("getStatus returns idle for unknown session", () => {
30+
expect(manager.getStatus("nonexistent")).toBe("idle");
31+
});
32+
33+
it("tracks sessions after spawn", async () => {
34+
const { claude } = await import("@anthropic-ai/claude-code");
35+
(claude as ReturnType<typeof vi.fn>).mockImplementation(async function* () {
36+
yield { type: "assistant", message: { content: [{ type: "text", text: "hello" }] } };
37+
});
38+
39+
await manager.spawn("test-session", { workspacePath: "/tmp/test" });
40+
expect(manager.getStatus("test-session")).not.toBe("idle");
41+
});
42+
43+
it("addUserEvent appends to history", () => {
44+
manager.addUserEvent("s1", "hello");
45+
const history = manager.getHistory("s1");
46+
expect(history).toHaveLength(1);
47+
expect(history[0].type).toBe("user_message");
48+
});
49+
});
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
2+
// See LICENSE.txt for license information.
3+
4+
import { spawn, type ChildProcess } from "child_process";
5+
import { createInterface } from "readline";
6+
import { resolve } from "path";
7+
import {
8+
createAgentEvent,
9+
type AgentEvent,
10+
type AgentStatus,
11+
type UserMessageData,
12+
type AssistantMessageData,
13+
type ThinkingData,
14+
type ToolUseData,
15+
type StatusData,
16+
} from "../src/lib/agent-events";
17+
18+
const CLAUDE_CLI_PATH = resolve(
19+
process.cwd(),
20+
"node_modules/@anthropic-ai/claude-code/cli.js",
21+
);
22+
23+
interface AgentProcess {
24+
sessionId: string;
25+
claudeSessionId: string | null;
26+
status: AgentStatus;
27+
history: AgentEvent[];
28+
subscribers: Set<(event: AgentEvent) => void>;
29+
childProcess: ChildProcess | null;
30+
workspacePath: string;
31+
}
32+
33+
export interface SpawnConfig {
34+
workspacePath: string;
35+
model?: string;
36+
systemPrompt?: string;
37+
}
38+
39+
export class AgentProcessManager {
40+
private agents = new Map<string, AgentProcess>();
41+
42+
getHistory(sessionId: string): AgentEvent[] {
43+
return this.agents.get(sessionId)?.history ?? [];
44+
}
45+
46+
getStatus(sessionId: string): AgentStatus {
47+
return this.agents.get(sessionId)?.status ?? "idle";
48+
}
49+
50+
subscribe(sessionId: string, callback: (event: AgentEvent) => void): () => void {
51+
const agent = this.agents.get(sessionId);
52+
if (!agent) {
53+
const placeholder: AgentProcess = {
54+
sessionId,
55+
claudeSessionId: null,
56+
status: "idle",
57+
history: [],
58+
subscribers: new Set([callback]),
59+
childProcess: null,
60+
workspacePath: "",
61+
};
62+
this.agents.set(sessionId, placeholder);
63+
return () => { placeholder.subscribers.delete(callback); };
64+
}
65+
agent.subscribers.add(callback);
66+
return () => { agent.subscribers.delete(callback); };
67+
}
68+
69+
addUserEvent(sessionId: string, text: string): void {
70+
const event = createAgentEvent(sessionId, "user_message", { text } as UserMessageData);
71+
this.appendAndBroadcast(sessionId, event);
72+
}
73+
74+
async spawn(sessionId: string, config: SpawnConfig): Promise<void> {
75+
const existing = this.agents.get(sessionId);
76+
const agent: AgentProcess = {
77+
sessionId,
78+
claudeSessionId: existing?.claudeSessionId ?? null,
79+
status: "spawning",
80+
history: existing?.history ?? [],
81+
subscribers: existing?.subscribers ?? new Set(),
82+
childProcess: null,
83+
workspacePath: config.workspacePath,
84+
};
85+
this.agents.set(sessionId, agent);
86+
this.broadcastStatus(sessionId, "spawning");
87+
}
88+
89+
async send(sessionId: string, message: string): Promise<void> {
90+
const agent = this.agents.get(sessionId);
91+
if (!agent) throw new Error(`No agent for session ${sessionId}`);
92+
93+
this.addUserEvent(sessionId, message);
94+
95+
agent.status = "active";
96+
this.broadcastStatus(sessionId, "active");
97+
98+
const args = [
99+
CLAUDE_CLI_PATH,
100+
"--print",
101+
"--output-format", "stream-json",
102+
"--verbose",
103+
"--dangerously-skip-permissions",
104+
];
105+
106+
if (agent.claudeSessionId) {
107+
args.push("--resume", agent.claudeSessionId);
108+
}
109+
110+
args.push(message);
111+
112+
const proc = spawn("node", args, {
113+
cwd: agent.workspacePath,
114+
env: { ...process.env },
115+
stdio: ["ignore", "pipe", "pipe"],
116+
});
117+
118+
agent.childProcess = proc;
119+
120+
const rl = createInterface({ input: proc.stdout! });
121+
rl.on("line", (line: string) => {
122+
if (!line.trim()) return;
123+
try {
124+
const sdkEvent = JSON.parse(line);
125+
this.processSDKEvent(sessionId, sdkEvent);
126+
127+
if (sdkEvent.type === "system" && sdkEvent.subtype === "init" && sdkEvent.session_id) {
128+
agent.claudeSessionId = sdkEvent.session_id;
129+
}
130+
} catch {
131+
// Non-JSON output — ignore
132+
}
133+
});
134+
135+
proc.stderr?.on("data", (data: Buffer) => {
136+
console.warn(`[AgentProcessManager] Claude CLI stderr: ${data.toString().slice(0, 200)}`);
137+
});
138+
139+
return new Promise<void>((resolvePromise) => {
140+
proc.on("close", () => {
141+
agent.childProcess = null;
142+
if (agent.status !== "error") {
143+
agent.status = "idle";
144+
this.broadcastStatus(sessionId, "idle");
145+
}
146+
resolvePromise();
147+
});
148+
149+
proc.on("error", (err) => {
150+
agent.childProcess = null;
151+
agent.status = "error";
152+
const errorEvent = createAgentEvent(sessionId, "error", { message: err.message });
153+
this.appendAndBroadcast(sessionId, errorEvent);
154+
this.broadcastStatus(sessionId, "error");
155+
resolvePromise();
156+
});
157+
});
158+
}
159+
160+
kill(sessionId: string): void {
161+
const agent = this.agents.get(sessionId);
162+
if (!agent) return;
163+
if (agent.childProcess) {
164+
agent.childProcess.kill("SIGTERM");
165+
agent.childProcess = null;
166+
}
167+
agent.status = "idle";
168+
this.broadcastStatus(sessionId, "idle");
169+
}
170+
171+
destroyAll(): void {
172+
for (const [id] of this.agents) {
173+
this.kill(id);
174+
}
175+
this.agents.clear();
176+
}
177+
178+
private processSDKEvent(sessionId: string, sdkEvent: any): void {
179+
if (sdkEvent.type === "assistant" && sdkEvent.message?.content) {
180+
for (const block of sdkEvent.message.content) {
181+
if (block.type === "text") {
182+
const event = createAgentEvent(sessionId, "assistant_message", {
183+
text: block.text,
184+
} as AssistantMessageData);
185+
this.appendAndBroadcast(sessionId, event);
186+
} else if (block.type === "thinking") {
187+
const event = createAgentEvent(sessionId, "thinking", {
188+
text: block.thinking,
189+
} as ThinkingData);
190+
this.appendAndBroadcast(sessionId, event);
191+
} else if (block.type === "tool_use") {
192+
const event = createAgentEvent(sessionId, "tool_use", {
193+
toolName: block.name,
194+
summary: this.summarizeToolInput(block.name, block.input),
195+
callId: block.id,
196+
} as ToolUseData);
197+
this.appendAndBroadcast(sessionId, event);
198+
}
199+
}
200+
}
201+
}
202+
203+
private summarizeToolInput(toolName: string, input: any): string {
204+
if (!input) return toolName;
205+
if (typeof input.file_path === "string") {
206+
const filename = input.file_path.split("/").pop() ?? input.file_path;
207+
return `${filename}`;
208+
}
209+
if (typeof input.command === "string") {
210+
const cmd = input.command.length > 60
211+
? input.command.slice(0, 57) + "..."
212+
: input.command;
213+
return cmd;
214+
}
215+
if (typeof input.pattern === "string") return input.pattern;
216+
if (typeof input.query === "string") return input.query;
217+
return toolName;
218+
}
219+
220+
private appendAndBroadcast(sessionId: string, event: AgentEvent): void {
221+
let agent = this.agents.get(sessionId);
222+
if (!agent) {
223+
agent = {
224+
sessionId,
225+
claudeSessionId: null,
226+
status: "idle",
227+
history: [],
228+
subscribers: new Set(),
229+
childProcess: null,
230+
workspacePath: "",
231+
};
232+
this.agents.set(sessionId, agent);
233+
}
234+
agent.history.push(event);
235+
for (const cb of agent.subscribers) {
236+
try { cb(event); } catch { /* subscriber error */ }
237+
}
238+
}
239+
240+
private broadcastStatus(sessionId: string, status: AgentStatus): void {
241+
const event = createAgentEvent(sessionId, "status", { status } as StatusData);
242+
this.appendAndBroadcast(sessionId, event);
243+
}
244+
}
245+
246+
const agentProcessManager = new AgentProcessManager();
247+
export default agentProcessManager;

agent-orchestrator-main/packages/web/src/app/api/sessions/[id]/send/route.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
recordApiObservation,
99
resolveProjectIdForSessionId,
1010
} from "@/lib/observability";
11+
import agentProcessManager from "../../../../../../server/agent-process-manager";
1112

1213
const MAX_MESSAGE_LENGTH = 10_000;
1314

@@ -42,6 +43,30 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
4243
try {
4344
const { config, sessionManager } = await getServices();
4445
const projectId = resolveProjectIdForSessionId(config, id);
46+
47+
// Route to AgentProcessManager for chat-mode sessions
48+
const session = await sessionManager.get(id);
49+
if (session?.metadata?.["useChatUI"] === "true") {
50+
await agentProcessManager.send(id, message);
51+
recordApiObservation({
52+
config,
53+
method: "POST",
54+
path: "/api/sessions/[id]/send",
55+
correlationId,
56+
startedAt,
57+
outcome: "success",
58+
statusCode: 200,
59+
projectId,
60+
sessionId: id,
61+
data: { messageLength: message.length, chatMode: true },
62+
});
63+
return jsonWithCorrelation(
64+
{ ok: true, sessionId: id, message },
65+
{ status: 200 },
66+
correlationId,
67+
);
68+
}
69+
4570
await sessionManager.send(id, message);
4671
recordApiObservation({
4772
config,

0 commit comments

Comments
 (0)