Skip to content

Commit 5a05b0d

Browse files
committed
fix: preserve chat terminal routing
1 parent 9951c5c commit 5a05b0d

5 files changed

Lines changed: 174 additions & 26 deletions

File tree

apps/ade-cli/src/adeRpcServer.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2897,8 +2897,8 @@ describe("adeRpcServer", () => {
28972897
});
28982898

28992899
it("rejects run_ade_action when the action is not a callable on the domain service", async () => {
2900-
const fixture = createRuntime();
2901-
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
2900+
const { runtime, operationStart, operationFinish } = createRuntime();
2901+
const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" });
29022902
await initialize(handler, { callerId: "agent-1", role: "agent" });
29032903

29042904
const response = await callTool(handler, "run_ade_action", {
@@ -2911,6 +2911,11 @@ describe("adeRpcServer", () => {
29112911
expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain(
29122912
"Action 'git.nonexistent_action' is not callable.",
29132913
);
2914+
expect(operationStart).toHaveBeenCalledTimes(1);
2915+
expect(operationFinish).toHaveBeenCalledWith(expect.objectContaining({
2916+
status: "failed",
2917+
metadataPatch: expect.objectContaining({ resultStatus: "error" }),
2918+
}));
29142919
});
29152920

29162921
it("reads ADE action status snapshots across operation/test/chat/pr", async () => {

apps/ade-cli/src/adeRpcServer.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5245,6 +5245,42 @@ export function createAdeRpcRequestHandler(args: {
52455245
throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported ADE action: ${actionName}`);
52465246
};
52475247

5248+
const callActionWithAudit = async (
5249+
actionName: string,
5250+
actionArgs: Record<string, unknown>,
5251+
): Promise<unknown> => {
5252+
const operation = runtime.operationService.start({
5253+
kind: `ade_action.${actionName}`,
5254+
metadata: {
5255+
actionName,
5256+
callerId: session.identity.callerId,
5257+
callerRole: session.identity.role,
5258+
chatSessionId: session.identity.chatSessionId,
5259+
readOnly: READ_ONLY_TOOLS.has(actionName),
5260+
},
5261+
});
5262+
try {
5263+
const result = await callAction(actionName, actionArgs);
5264+
runtime.operationService.finish({
5265+
operationId: operation.operationId,
5266+
status: "succeeded",
5267+
metadataPatch: { resultStatus: "success" },
5268+
});
5269+
return result;
5270+
} catch (error) {
5271+
const message = error instanceof Error ? error.message : String(error);
5272+
runtime.operationService.finish({
5273+
operationId: operation.operationId,
5274+
status: "failed",
5275+
metadataPatch: {
5276+
resultStatus: "error",
5277+
error: message,
5278+
},
5279+
});
5280+
throw error;
5281+
}
5282+
};
5283+
52485284
const handler = (async (request: JsonRpcRequest): Promise<unknown | null> => {
52495285
const method = typeof request.method === "string" ? request.method : "";
52505286
const params = safeObject(request.params);
@@ -5440,7 +5476,7 @@ export function createAdeRpcRequestHandler(args: {
54405476
const actionName = assertNonEmptyString(params.name, "name");
54415477
const actionArgs = safeObject(params.arguments);
54425478
try {
5443-
return await callAction(actionName, actionArgs);
5479+
return await callActionWithAudit(actionName, actionArgs);
54445480
} catch (error) {
54455481
const message = error instanceof Error ? error.message : String(error);
54465482
return {

apps/desktop/src/main/services/pty/ptyService.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4592,6 +4592,52 @@ describe("ptyService", () => {
45924592
expect(reattached.ptyId).toBe(chatCli.ptyId);
45934593
});
45944594

4595+
it("routes terminal operations to the App Control shell without making it the active chat CLI", async () => {
4596+
const { service, loadPty, sessionService } = createChatHarness();
4597+
const chatPty = createMockPty();
4598+
const appControlPty = createMockPty();
4599+
const spawn = vi.fn()
4600+
.mockReturnValueOnce(chatPty)
4601+
.mockReturnValueOnce(appControlPty);
4602+
loadPty.mockReturnValue({ spawn });
4603+
4604+
const chatCli = await service.create({
4605+
sessionId: "chat-app-control-routing",
4606+
allowNewSessionId: true,
4607+
laneId: "lane-1",
4608+
title: "Claude Chat",
4609+
cols: 80,
4610+
rows: 24,
4611+
chatSessionId: "chat-app-control-routing",
4612+
tracked: true,
4613+
toolType: "claude-chat",
4614+
startupCommand: "claude --resume target",
4615+
});
4616+
const appControlShell = await service.create({
4617+
laneId: "lane-1",
4618+
title: "App Control",
4619+
cols: 80,
4620+
rows: 24,
4621+
chatSessionId: "chat-app-control-routing",
4622+
tracked: true,
4623+
toolType: "shell",
4624+
startupCommand: "npm run dev",
4625+
});
4626+
(chatPty.write as ReturnType<typeof vi.fn>).mockClear();
4627+
(appControlPty.write as ReturnType<typeof vi.fn>).mockClear();
4628+
sessionService.readTranscriptTail.mockResolvedValueOnce("app control output");
4629+
4630+
const read = await service.readTerminal({ chatSessionId: "chat-app-control-routing" });
4631+
await service.writeTerminal({ chatSessionId: "chat-app-control-routing", data: "q\n" });
4632+
service.signalTerminal({ chatSessionId: "chat-app-control-routing", signal: "SIGINT" });
4633+
4634+
expect(read.terminalId).toBe(appControlShell.sessionId);
4635+
expect(appControlPty.write).toHaveBeenNthCalledWith(1, "q\n");
4636+
expect(appControlPty.write).toHaveBeenNthCalledWith(2, "\x03");
4637+
expect(chatPty.write).not.toHaveBeenCalled();
4638+
expect(service.activeForChat({ chatSessionId: "chat-app-control-routing" })?.terminalId).toBe(chatCli.sessionId);
4639+
});
4640+
45954641
it("relaunches a new PTY for a disposed chat-CLI session", async () => {
45964642
const { service, loadPty, sessionStore } = createChatHarness();
45974643
const created = await service.create({

apps/desktop/src/main/services/pty/ptyService.ts

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,7 @@ export function createPtyService({
929929
const exitListeners = new Set<PtyExitListener>();
930930
const terminalChatSessions = new Map<string, string>();
931931
const activeTerminalByChatSession = new Map<string, string>();
932+
const activeAuxiliaryTerminalByChatSession = new Map<string, string>();
932933
const missingResumeTargetBackfillFailures = new Map<string, { toolType: TerminalToolType | null; checkedAtMs: number }>();
933934
const claudeTitleCaptureKeys = new Set<string>();
934935
const resumeRuntimeFlights = new Map<string, Promise<PtyCreateResult>>();
@@ -2443,14 +2444,25 @@ export function createPtyService({
24432444
&& isChatCliRoutingEntry(entry)
24442445
&& activeTerminalByChatSession.get(entry.chatSessionId) === entry.sessionId
24452446
) {
2446-
const replacement = liveChatCliEntriesFor(entry.chatSessionId)
2447-
.find((candidate) => candidate.sessionId !== entry.sessionId) ?? null;
2447+
const replacement = liveChatCliEntriesFor(entry.chatSessionId)[0] ?? null;
24482448
if (replacement) {
24492449
activeTerminalByChatSession.set(entry.chatSessionId, replacement.sessionId);
24502450
} else {
24512451
activeTerminalByChatSession.delete(entry.chatSessionId);
24522452
}
24532453
}
2454+
if (
2455+
entry.chatSessionId
2456+
&& isAuxiliaryRoutingEntry(entry)
2457+
&& activeAuxiliaryTerminalByChatSession.get(entry.chatSessionId) === entry.sessionId
2458+
) {
2459+
const replacement = liveAuxiliaryEntriesFor(entry.chatSessionId)[0] ?? null;
2460+
if (replacement) {
2461+
activeAuxiliaryTerminalByChatSession.set(entry.chatSessionId, replacement.sessionId);
2462+
} else {
2463+
activeAuxiliaryTerminalByChatSession.delete(entry.chatSessionId);
2464+
}
2465+
}
24542466
try {
24552467
onSessionRuntimeSignal?.({
24562468
laneId: entry.laneId,
@@ -2694,6 +2706,9 @@ export function createPtyService({
26942706
const isChatCliRoutingEntry = (entry: PtyEntry): boolean =>
26952707
isPersistedChatToolType(entry.toolTypeHint);
26962708

2709+
const isAuxiliaryRoutingEntry = (entry: PtyEntry): boolean =>
2710+
!isChatCliRoutingEntry(entry);
2711+
26972712
const liveChatCliEntriesFor = (chatSessionId: string): PtyEntry[] =>
26982713
Array.from(ptys.values())
26992714
.filter((entry) => (
@@ -2708,10 +2723,39 @@ export function createPtyService({
27082723
.filter((entry) => (
27092724
entry.chatSessionId === chatSessionId
27102725
&& !entry.disposed
2711-
&& !isChatCliRoutingEntry(entry)
2726+
&& isAuxiliaryRoutingEntry(entry)
27122727
))
27132728
.sort((a, b) => b.createdAt - a.createdAt);
27142729

2730+
const activeEntryFromMap = (
2731+
map: Map<string, string>,
2732+
chatSessionId: string,
2733+
predicate: (entry: PtyEntry) => boolean,
2734+
): PtyEntry | null => {
2735+
const sessionId = map.get(chatSessionId) ?? null;
2736+
if (!sessionId) return null;
2737+
const live = liveEntryBySessionId(sessionId);
2738+
if (live && live[1].chatSessionId === chatSessionId && predicate(live[1])) return live[1];
2739+
map.delete(chatSessionId);
2740+
return null;
2741+
};
2742+
2743+
const activeChatCliEntryFor = (chatSessionId: string): PtyEntry | null => {
2744+
const active = activeEntryFromMap(activeTerminalByChatSession, chatSessionId, isChatCliRoutingEntry);
2745+
if (active) return active;
2746+
const replacement = liveChatCliEntriesFor(chatSessionId)[0] ?? null;
2747+
if (replacement) activeTerminalByChatSession.set(chatSessionId, replacement.sessionId);
2748+
return replacement;
2749+
};
2750+
2751+
const activeAuxiliaryEntryFor = (chatSessionId: string): PtyEntry | null => {
2752+
const active = activeEntryFromMap(activeAuxiliaryTerminalByChatSession, chatSessionId, isAuxiliaryRoutingEntry);
2753+
if (active) return active;
2754+
const replacement = liveAuxiliaryEntriesFor(chatSessionId)[0] ?? null;
2755+
if (replacement) activeAuxiliaryTerminalByChatSession.set(chatSessionId, replacement.sessionId);
2756+
return replacement;
2757+
};
2758+
27152759
const promoteActiveChatCliTerminal = (
27162760
chatSessionId: string,
27172761
sessionId: string,
@@ -2721,6 +2765,27 @@ export function createPtyService({
27212765
activeTerminalByChatSession.set(chatSessionId, sessionId);
27222766
};
27232767

2768+
const promoteActiveAuxiliaryTerminal = (
2769+
chatSessionId: string,
2770+
sessionId: string,
2771+
toolType: TerminalToolType | null,
2772+
): void => {
2773+
if (isPersistedChatToolType(toolType)) return;
2774+
activeAuxiliaryTerminalByChatSession.set(chatSessionId, sessionId);
2775+
};
2776+
2777+
const promoteActiveChatTerminal = (
2778+
chatSessionId: string,
2779+
sessionId: string,
2780+
toolType: TerminalToolType | null,
2781+
): void => {
2782+
if (isPersistedChatToolType(toolType)) {
2783+
promoteActiveChatCliTerminal(chatSessionId, sessionId, toolType);
2784+
} else {
2785+
promoteActiveAuxiliaryTerminal(chatSessionId, sessionId, toolType);
2786+
}
2787+
};
2788+
27242789
const codexReadyRegion = (text: string): string => {
27252790
const lastPrompt = text.lastIndexOf("›");
27262791
if (lastPrompt < 0) return text;
@@ -2891,9 +2956,9 @@ export function createPtyService({
28912956
let active = false;
28922957
if (chatSessionId) {
28932958
if (isPersistedChatToolType(summary.toolType)) {
2894-
active = activeTerminalByChatSession.get(chatSessionId) === summary.id;
2959+
active = activeChatCliEntryFor(chatSessionId)?.sessionId === summary.id;
28952960
} else {
2896-
active = liveAuxiliaryEntriesFor(chatSessionId)[0]?.sessionId === summary.id;
2961+
active = activeAuxiliaryEntryFor(chatSessionId)?.sessionId === summary.id;
28972962
}
28982963
}
28992964
return {
@@ -2928,7 +2993,7 @@ export function createPtyService({
29282993
const chatSessionId = cleanOptionalId(args.chatSessionId);
29292994
if (!chatSessionId) return null;
29302995
// Auxiliary terminals (shell, App Control, etc.) — never route chat-CLI rows.
2931-
return liveAuxiliaryEntriesFor(chatSessionId)[0]?.sessionId ?? null;
2996+
return activeAuxiliaryEntryFor(chatSessionId)?.sessionId ?? null;
29322997
};
29332998

29342999
const service = {
@@ -2990,7 +3055,7 @@ export function createPtyService({
29903055
if (chatSessionId) {
29913056
attachedEntry.chatSessionId = chatSessionId;
29923057
terminalChatSessions.set(existingSession.id, chatSessionId);
2993-
promoteActiveChatCliTerminal(chatSessionId, existingSession.id, existingSession.toolType);
3058+
promoteActiveChatTerminal(chatSessionId, existingSession.id, existingSession.toolType);
29943059
if (existingSession.chatSessionId !== chatSessionId) {
29953060
try { sessionService.setChatSessionId(existingSession.id, chatSessionId); } catch {}
29963061
}
@@ -3289,7 +3354,7 @@ export function createPtyService({
32893354
ptys.set(ptyId, entry);
32903355
if (chatSessionId) {
32913356
terminalChatSessions.set(sessionId, chatSessionId);
3292-
promoteActiveChatCliTerminal(chatSessionId, sessionId, toolTypeHint);
3357+
promoteActiveChatTerminal(chatSessionId, sessionId, toolTypeHint);
32933358
if (existingSession && existingSession.chatSessionId !== chatSessionId) {
32943359
try { sessionService.setChatSessionId(sessionId, chatSessionId); } catch {}
32953360
}
@@ -3831,15 +3896,15 @@ export function createPtyService({
38313896
activeForChat(args: ChatTerminalActiveForChatArgs): ChatTerminalSession | null {
38323897
const chatSessionId = cleanOptionalId(args.chatSessionId);
38333898
if (!chatSessionId) return null;
3834-
const chatCli = liveChatCliEntriesFor(chatSessionId)[0] ?? null;
3899+
const chatCli = activeChatCliEntryFor(chatSessionId);
38353900
if (chatCli) {
38363901
const session = sessionService.get(chatCli.sessionId);
38373902
if (session) {
38383903
promoteActiveChatCliTerminal(chatSessionId, chatCli.sessionId, session.toolType);
38393904
return terminalSessionFromSummary(session);
38403905
}
38413906
}
3842-
const auxiliary = liveAuxiliaryEntriesFor(chatSessionId)[0] ?? null;
3907+
const auxiliary = activeAuxiliaryEntryFor(chatSessionId);
38433908
if (!auxiliary) return null;
38443909
const session = sessionService.get(auxiliary.sessionId);
38453910
return session ? terminalSessionFromSummary(session) : null;
@@ -3851,19 +3916,7 @@ export function createPtyService({
38513916

38523917
// Fast path: an existing live PTY is already bound. Skip the dedup map to
38533918
// keep the no-op cost low.
3854-
const activeTerminalId = activeTerminalByChatSession.get(chatSessionId) ?? null;
3855-
if (activeTerminalId) {
3856-
const liveActive = liveEntryBySessionId(activeTerminalId);
3857-
if (liveActive && isChatCliRoutingEntry(liveActive[1])) {
3858-
return {
3859-
terminalId: activeTerminalId,
3860-
ptyId: liveActive[0],
3861-
pid: liveActive[1].pty.pid ?? null,
3862-
relaunched: false,
3863-
};
3864-
}
3865-
}
3866-
const liveChatCli = liveChatCliEntriesFor(chatSessionId)[0] ?? null;
3919+
const liveChatCli = activeChatCliEntryFor(chatSessionId);
38673920
if (liveChatCli) {
38683921
const liveActive = liveEntryBySessionId(liveChatCli.sessionId);
38693922
if (liveActive) {

docs/features/terminals-and-sessions/pty-and-processes.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,14 @@ user wants to send a message". It only accepts tracked chat-typed
316316
sessions (`isPersistedChatToolType(toolType)`) — non-chat sessions and
317317
untracked rows are rejected with a clear error.
318318

319+
Chat-scoped PTYs are partitioned by tool type. Persisted chat tool
320+
types (`claude-chat`, `codex-chat`, `cursor`, `opencode-chat`,
321+
`droid-chat`) are the only sessions allowed to own the chat-CLI active
322+
route used by `activeForChat` and `reattachChatCli`. Auxiliary PTYs
323+
such as App Control or plain shells may carry the same `chatSessionId`
324+
so they nest under the parent chat, but they are tracked in a separate
325+
auxiliary active route for `terminal.read` / `write` / `signal`.
326+
319327
Behaviour:
320328

321329
- Fast path: if a live PTY is already bound to the chat session, the

0 commit comments

Comments
 (0)