From 42bfd4e04c21ef9877c8d025cd0e2dd3cf170ab1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 6 Aug 2026 16:30:44 +0200 Subject: [PATCH] Stop terminal activity after interrupted turns (#2942) * fix(terminal): clear activity when turns are interrupted Some terminal agents do not emit a stop lifecycle hook after a user interrupt. Treat exact interrupt key frames as cancellation at the terminal input boundary so running state cannot stick, and clear rather than finish to avoid completion attention. * test(terminal): use platform shell in interruption tests Let the terminal test fixture select the platform default shell so the interruption cases reach their assertions on Windows. --- docs/terminal-activity.md | 5 ++ .../activity/terminal-activity-tracker.ts | 7 ++ .../claude/claude.real.e2e.test.ts | 76 ++++++++++++++++++- packages/server/src/terminal/terminal.test.ts | 37 +++++++++ packages/server/src/terminal/terminal.ts | 7 ++ 5 files changed, 129 insertions(+), 3 deletions(-) diff --git a/docs/terminal-activity.md b/docs/terminal-activity.md index 0ada655d0..fd2df0e95 100644 --- a/docs/terminal-activity.md +++ b/docs/terminal-activity.md @@ -53,6 +53,11 @@ Claude hook mapping: - `Stop`, `StopFailure`, `SessionEnd` → `idle` - `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input` +Claude does not run `Stop` when the user interrupts a turn. A standalone Ctrl-C or Escape input +while terminal activity is working clears the activity without finished attention. The same +fallback applies to every hooked terminal agent; exact input matching excludes escape sequences and +pasted content, while later provider idle events remain authoritative. + Codex hook mapping: - `UserPromptSubmit` → `running` diff --git a/packages/server/src/terminal/activity/terminal-activity-tracker.ts b/packages/server/src/terminal/activity/terminal-activity-tracker.ts index 168e12a25..7b73711f8 100644 --- a/packages/server/src/terminal/activity/terminal-activity-tracker.ts +++ b/packages/server/src/terminal/activity/terminal-activity-tracker.ts @@ -47,6 +47,13 @@ export class TerminalActivityTracker { return true; } + interrupt(): void { + if (this.resolvedState !== "working") { + return; + } + this.setState(null, null); + } + private setState( state: TerminalActivityState | null, attentionReason: TerminalActivityAttentionReason | null, diff --git a/packages/server/src/terminal/agent-hooks/claude/claude.real.e2e.test.ts b/packages/server/src/terminal/agent-hooks/claude/claude.real.e2e.test.ts index dbe1d8110..f606d5c89 100644 --- a/packages/server/src/terminal/agent-hooks/claude/claude.real.e2e.test.ts +++ b/packages/server/src/terminal/agent-hooks/claude/claude.real.e2e.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import * as pty from "node-pty"; import { afterEach, describe, expect, it } from "vitest"; -import { resolvePaseoCliBinDir } from "../../terminal.js"; +import { createTerminal, resolvePaseoCliBinDir, type TerminalSession } from "../../terminal.js"; import { installRegisteredAgentHooks } from "../provider-registry.js"; interface ActivityPost { @@ -70,7 +70,7 @@ function readBody(request: IncomingMessage): Promise { }); } -async function createActivityRecorder() { +async function createActivityRecorder(onPost?: (post: ActivityPost) => void) { const posts: ActivityPost[] = []; const server = createServer(async (request: IncomingMessage, response: ServerResponse) => { if (request.method !== "POST" || request.url !== "/api/terminal-activity") { @@ -80,7 +80,9 @@ async function createActivityRecorder() { } const body = await readBody(request); - posts.push(JSON.parse(body) as ActivityPost); + const post = JSON.parse(body) as ActivityPost; + posts.push(post); + onPost?.(post); response.statusCode = 200; response.end("ok"); }); @@ -118,6 +120,23 @@ function statesFor(posts: ActivityPost[], terminalId: string, token: string): st .map((post) => post.state); } +async function waitForRecordedState( + posts: ActivityPost[], + terminalId: string, + token: string, + state: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (statesFor(posts, terminalId, token).includes(state)) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error( + `Timed out waiting for ${state}; observed ${statesFor(posts, terminalId, token).join(", ")}`, + ); +} + describe.skipIf(!claudeAvailability.available)( `real Claude terminal activity hooks (${claudeAvailability.detail})`, () => { @@ -166,6 +185,57 @@ describe.skipIf(!claudeAvailability.available)( await recorder.close(); } }, 100_000); + + it("clears activity for an interrupted interactive Claude turn through TerminalSession", async () => { + const terminalId = "real-claude-interrupt-terminal"; + const token = "real-claude-interrupt-token"; + let session: TerminalSession | null = null; + const recorder = await createActivityRecorder((post) => { + if (post.terminalId !== terminalId || post.token !== token) return; + if (post.state === "running") session?.setActivity("working"); + if (post.state === "idle") session?.setActivity("idle"); + if (post.state === "needs-input") session?.setActivity("attention"); + }); + const configDir = createTempDir("paseo-real-claude-interrupt-config-"); + const paseoCliBinDir = resolvePaseoCliBinDir(); + if (!paseoCliBinDir) { + throw new Error("Could not resolve paseo CLI bin directory"); + } + + installRegisteredAgentHooks({ configDir }); + + try { + session = await createTerminal({ + id: terminalId, + workspaceId: "real-claude-interrupt-workspace", + cwd: process.cwd(), + command: "claude", + args: ["--settings", join(configDir, "settings.json")], + env: { + ...process.env, + PASEO_TERMINAL_ID: terminalId, + PASEO_ACTIVITY_TOKEN: token, + PASEO_TERMINAL_ACTIVITY_URL: recorder.url, + PATH: [paseoCliBinDir, process.env.PATH].filter(isString).join(delimiter), + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 2_000)); + session.send({ + type: "input", + data: "Write a very long essay about the history of computing.\r", + }); + await waitForRecordedState(recorder.posts, terminalId, token, "running", 20_000); + expect(session.getActivity()).toMatchObject({ state: "working" }); + + session.send({ type: "input", data: "\x03" }); + + expect(session.getActivity()).toBeNull(); + } finally { + session?.kill(); + await recorder.close(); + } + }, 45_000); }, ); diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 62e5fbb51..086ab6c86 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1059,3 +1059,40 @@ describe("mouse events", () => { session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "up" }); }); }); + +describe("terminal activity interruption", () => { + it.each([ + ["Ctrl-C", "\x03"], + ["Escape", "\x1b"], + ])("clears working activity when %s is sent", async (_name, data) => { + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + }), + ); + session.setActivity("working"); + + session.send({ type: "input", data }); + + expect(session.getActivity()).toBeNull(); + }); + + it.each([ + ["an arrow sequence", "\x1b[A"], + ["pasted text containing Ctrl-C", "before\x03after"], + ["ordinary input", "hello"], + ])("keeps a working terminal active for %s", async (_name, data) => { + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + }), + ); + session.setActivity("working"); + + session.send({ type: "input", data }); + + expect(session.getActivity()).toMatchObject({ state: "working" }); + }); +}); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index ee2ca377f..8668caf09 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -149,6 +149,10 @@ function resolveInitialTitleMode(presetTitle: string | undefined): "auto" | "man return presetTitle?.trim() ? "manual" : "auto"; } +function isTerminalActivityInterruptInput(data: string): boolean { + return data === "\x03" || data === "\x1b"; +} + interface BuildTerminalEnvironmentInput { shell: string; env: Record; @@ -1222,6 +1226,9 @@ export async function createTerminal(options: CreateTerminalOptions): Promise