diff --git a/electron/ai-edition/chat-persistence.ts b/electron/ai-edition/chat-persistence.ts index 79d6d9079..5a86a6987 100644 --- a/electron/ai-edition/chat-persistence.ts +++ b/electron/ai-edition/chat-persistence.ts @@ -14,6 +14,7 @@ const messageSchema = z.object({ id: z.string().min(1), role: z.enum(["user", "assistant"]), content: z.string(), + stopped: z.boolean().optional(), createdAt: z.string().min(1), toolCalls: z.array(toolCallSchema).optional(), }); diff --git a/electron/ai-edition/chat-service.toolloop.test.ts b/electron/ai-edition/chat-service.toolloop.test.ts index 11719179d..1d7e42d1c 100644 --- a/electron/ai-edition/chat-service.toolloop.test.ts +++ b/electron/ai-edition/chat-service.toolloop.test.ts @@ -433,3 +433,78 @@ describe("allowAgentEdits reaches the agent", () => { expect(result.document).toBeDefined(); }); }); + +describe("stopped chat history", () => { + it("passes the signal through, persists partial replies and tools, and keeps the session usable", async () => { + const { mkdtemp, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { configureChatPersistence, flushChatPersistence, selectSession } = await import( + "./chat-service" + ); + const root = await mkdtemp(join(tmpdir(), "openscreen-stop-history-")); + try { + configureChatPersistence(root); + const controller = new AbortController(); + const sink = { text: vi.fn(), error: vi.fn(), document: vi.fn() }; + invokeMock.mockImplementationOnce(async (args) => { + expect(args.signal).toBe(controller.signal); + args.sink.text("Partial reply"); + args.sink.toolEnd("addTrim", true, "Kept trim"); + controller.abort(); + args.sink.error("AbortError"); + args.sink.text("Late"); + return { text: "Partial reply", document: args.document, mutated: true, cancelled: true }; + }); + const session = createSession("stop_history"); + const result = await runChat( + "stop_history", + session.id, + "cut", + stubConfig(), + fixtureDocument(), + sink, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + success: true, + cancelled: true, + assistantMessage: { + content: "Partial reply", + stopped: true, + toolCalls: [{ name: "addTrim", summary: "Kept trim" }], + }, + }); + expect(result.userMessageCheckpointId).toBeDefined(); + expect(sink.error).not.toHaveBeenCalled(); + expect(sink.text).toHaveBeenCalledOnce(); + await flushChatPersistence(); + configureChatPersistence(root); + const stored = selectSession("stop_history", session.id)!; + expect(stored.messages[1]).toMatchObject({ stopped: true, content: "Partial reply" }); + expect(stored.messages[0].checkpointId).toBeNull(); + await runChat("stop_history", session.id, "continue", stubConfig(), fixtureDocument()); + expect(selectSession("stop_history", session.id)?.messages).toHaveLength(4); + await flushChatPersistence(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("stores an explicit stopped assistant even when stopped before any text", async () => { + invokeMock.mockImplementationOnce(async (args) => ({ + text: "", + document: args.document, + mutated: false, + cancelled: true, + })); + const session = createSession("stop_before_text"); + const result = await runChat("stop_before_text", session.id, "hello", stubConfig()); + expect(result).toMatchObject({ + success: true, + cancelled: true, + assistantMessage: { content: "", stopped: true }, + }); + expect(result.error).toBeUndefined(); + }); +}); diff --git a/electron/ai-edition/chat-service.ts b/electron/ai-edition/chat-service.ts index e056f04b1..081e25463 100644 --- a/electron/ai-edition/chat-service.ts +++ b/electron/ai-edition/chat-service.ts @@ -310,9 +310,11 @@ export interface ChatEventSink { toolEnd?: (name: string, ok: boolean, summary?: string) => void; /** The agent loop hit a fatal error (provider 4xx, network, parse). */ error?: (message: string) => void; + document?: (document: AxcutDocument) => void; } export interface ChatRunEnv { + signal?: AbortSignal; /** Reads recorded cursor telemetry for an asset. Built in `electron/ipc/ * handlers.ts`, where the path allow-list lives. */ cursor?: CursorTelemetryReader; @@ -334,6 +336,7 @@ const NOOP_SINK: Required = { toolStart: noop, toolEnd: noop, error: noop, + document: noop, }; export async function runChat( @@ -433,24 +436,37 @@ export async function runChat( const appliedToolCalls: AiEditionToolCallSummary[] = []; + let acceptingEvents = true; + const canEmit = () => acceptingEvents && !env.signal?.aborted; const agentSink = { - text: (delta: string) => emit.text(delta), - thinking: (delta: string) => emit.thinking(delta), + document: (document: AxcutDocument) => { + if (editsAllowed && canEmit()) emit.document(document); + }, + text: (delta: string) => { + if (canEmit()) emit.text(delta); + }, + thinking: (delta: string) => { + if (canEmit()) emit.thinking(delta); + }, toolStart: (name: string, args: unknown) => { - emit.toolStart(name, args); + if (canEmit()) emit.toolStart(name, args); }, toolEnd: (name: string, ok: boolean, summary?: string) => { + if (!canEmit()) return; emit.toolEnd(name, ok, summary); if (ok && summary) { appliedToolCalls.push({ name, summary }); } }, - error: (message: string) => emit.error(message), + error: (message: string) => { + if (canEmit()) emit.error(message); + }, }; const { invokeOpenScreenAgent } = await import("./deep-agent/service"); const result = await invokeOpenScreenAgent({ + signal: env.signal, document: workingDocument ?? emptyDocumentForTextOnly(projectId), model: { provider: config.provider, @@ -469,13 +485,15 @@ export async function runChat( checkShaderEffect: env.checkShaderEffect, }); + acceptingEvents = false; + // The project was deleted while the agent ran: its sessions are gone, so a // reply recorded now would be reported as sent and then never be seen again. if (sessionsByProject.get(projectId) !== sessions) { return { success: false, error: "The project was deleted while the agent was replying." }; } - if (!result.text) { + if (!result.text && !result.cancelled) { // ponytail: surface the deep-agent's diagnostic so the user can see // *why* the model produced no text (e.g. MiniMax streaming only // thinking blocks, or a non-text content shape that our extractor @@ -494,6 +512,7 @@ export async function runChat( id: randomUUID(), role: "assistant", content: result.text, + stopped: result.cancelled || undefined, createdAt: new Date().toISOString(), toolCalls: appliedToolCalls.length ? appliedToolCalls : undefined, }; @@ -503,11 +522,9 @@ export async function runChat( return { success: true, assistantMessage, - // ponytail: belt and braces on `editsAllowed`. This returned document is - // the ONLY path to disk (ChatStripPanel → applyAgentDocument → saveDocument), - // so it is where a write that somehow escaped the executor's guard would - // still land. Cheap, and it makes the setting's guarantee structural - // rather than dependent on one predicate holding everywhere. + cancelled: result.cancelled, + // Return the accumulated edits even on Stop. The panel applies this once + // at the end of the turn, preserving one save and one undo step per turn. document: result.mutated && editsAllowed ? result.document : undefined, toolCalls: appliedToolCalls.length ? appliedToolCalls : undefined, userMessageCheckpointId: userMessage.checkpointId ?? undefined, diff --git a/electron/ai-edition/deep-agent/cancellation.test.ts b/electron/ai-edition/deep-agent/cancellation.test.ts new file mode 100644 index 000000000..bd4738280 --- /dev/null +++ b/electron/ai-edition/deep-agent/cancellation.test.ts @@ -0,0 +1,236 @@ +import { once } from "node:events"; +import { createServer, type ServerResponse } from "node:http"; +import type { StructuredToolInterface } from "@langchain/core/tools"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEmptyDocument } from "../../../src/lib/ai-edition/schema"; +import type { ShaderEffectCheck } from "../../../src/lib/ai-edition/shaderEffects"; +import { buildTools, invokeOpenScreenAgent, type OpenScreenAgentSink } from "./service"; + +const doc = () => createEmptyDocument({ projectId: "stop_test", title: "Before" }); +function sink(): OpenScreenAgentSink { + return { + text: vi.fn(), + thinking: vi.fn(), + toolStart: vi.fn(), + toolEnd: vi.fn(), + error: vi.fn(), + document: vi.fn(), + }; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const servers: ReturnType[] = []; +afterEach(() => { + for (const server of servers.splice(0)) { + server.closeAllConnections(); + server.close(); + } +}); + +async function provider(onRequest: (res: ServerResponse, call: number) => void) { + let calls = 0; + const server = createServer(async (req, res) => { + for await (const _ of req) { + /* consume request */ + } + res.writeHead(200, { "Content-Type": "text/event-stream" }); + onRequest(res, ++calls); + }); + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing provider address"); + return { + provider: "openai-compatible", + model: "test", + apiKey: "test", + baseUrl: `http://127.0.0.1:${address.port}/v1`, + maxRetries: 0, + }; +} +function chunk(res: ServerResponse, delta: unknown, finish: string | null = null) { + res.write( + `data: ${JSON.stringify({ id: "chat-test", object: "chat.completion.chunk", created: 1, model: "test", choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`, + ); +} +function end(res: ServerResponse) { + res.end("data: [DONE]\n\n"); +} + +// This uses the real createAgent, ChatOpenAI and HTTP streaming transport. Only +// the provider is local, so a mock accepting a signal cannot make it pass. +describe("agent cancellation", () => { + it("aborts the actual model HTTP stream and preserves partial text without an error", async () => { + const started = deferred(); + const disconnected = deferred(); + const model = await provider((res) => { + res.on("close", () => disconnected.resolve()); + chunk(res, { role: "assistant", content: "Already answered" }); + started.resolve(); + }); + const controller = new AbortController(); + const events = sink(); + const running = invokeOpenScreenAgent({ + document: doc(), + model, + history: [], + userMessage: "hello", + sink: events, + signal: controller.signal, + }); + await started.promise; + await vi.waitFor(() => expect(events.text).toHaveBeenCalledWith("Already answered")); + controller.abort(); + const result = await running; + expect(result).toMatchObject({ cancelled: true, text: "Already answered", mutated: false }); + expect(events.error).not.toHaveBeenCalled(); + await disconnected.promise; + }); + + it("treats a stop during the final text callback as cancellation, with one retained answer", async () => { + const model = await provider((res) => { + chunk(res, { role: "assistant", content: "Done" }); + chunk(res, {}, "stop"); + end(res); + }); + const controller = new AbortController(); + const events = sink(); + events.text = () => controller.abort(); + const result = await invokeOpenScreenAgent({ + document: doc(), + model, + history: [], + userMessage: "hello", + sink: events, + signal: controller.signal, + }); + expect(result).toMatchObject({ cancelled: true, text: "Done" }); + expect(events.error).not.toHaveBeenCalled(); + }); + + it("handles a stop before model setup without reporting a model failure", async () => { + const controller = new AbortController(); + controller.abort(); + const events = sink(); + const result = await invokeOpenScreenAgent({ + document: doc(), + model: { provider: "openai", model: "test" }, + history: [], + userMessage: "hello", + sink: events, + signal: controller.signal, + }); + expect(result).toMatchObject({ cancelled: true, text: "", mutated: false }); + expect(events.error).not.toHaveBeenCalled(); + }); + + it("keeps completed tool edits, suppresses queued writes and discards an in-flight shader check", async () => { + const controller = new AbortController(); + const holder = { current: doc() }; + const events = sink(); + const checking = deferred(); + // A read-only compiler can finish after stop; it must never reach the executor. + let finishCheck!: (value: ShaderEffectCheck) => void; + const check = new Promise((resolve) => { + finishCheck = resolve; + }); + const tools = buildTools(holder, events, true, { + signal: controller.signal, + checkShaderEffect: async () => { + checking.resolve(); + return await check; + }, + }); + const find = (name: string) => + tools.find((tool) => tool.name === name)! as StructuredToolInterface; + await find("setVocabulary").invoke({ vocabulary: "Kept" }); + const pending = find("addEffect").invoke({ + startSec: 0, + endSec: 1, + effect: { + name: "test", + description: "test", + code: "fn effect(uv: vec2) -> vec4 { return frame(uv); }", + }, + }); + const queued = find("setVocabulary").invoke({ vocabulary: "Late" }); + const failed = Promise.allSettled([pending, queued]); + await checking.promise; + controller.abort(); + finishCheck({}); + const results = await failed; + expect(results.every((result) => result.status === "rejected")).toBe(true); + expect(holder.current.project.vocabulary).toBe("Kept"); + expect(events.document).toHaveBeenCalledOnce(); + expect(events.toolEnd).toHaveBeenCalledOnce(); + }); + + it("returns completed edits when stopped between tool execution and the final answer", async () => { + const waiting = deferred(); + const model = await provider((res, call) => { + if (call === 1) { + chunk(res, { + role: "assistant", + tool_calls: [ + { + index: 0, + id: "tool-1", + type: "function", + function: { name: "setVocabulary", arguments: '{"vocabulary":"Kept"}' }, + }, + ], + }); + chunk(res, {}, "tool_calls"); + end(res); + } else { + chunk(res, { role: "assistant", content: "Applied. " }); + waiting.resolve(); + } + }); + const controller = new AbortController(); + const events = sink(); + const run = invokeOpenScreenAgent({ + document: doc(), + model, + history: [], + userMessage: "set vocabulary", + sink: events, + signal: controller.signal, + }); + await waiting.promise; + controller.abort(); + const result = await run; + expect(result.cancelled).toBe(true); + expect(result.document.project.vocabulary).toBe("Kept"); + expect(result.mutated).toBe(true); + expect(events.document).toHaveBeenCalledOnce(); + expect(events.error).not.toHaveBeenCalled(); + }); + + it("lets completion win when stop arrives after the last event", async () => { + const model = await provider((res) => { + chunk(res, { role: "assistant", content: "Done" }); + chunk(res, {}, "stop"); + end(res); + }); + const controller = new AbortController(); + const result = await invokeOpenScreenAgent({ + document: doc(), + model, + history: [], + userMessage: "hello", + sink: sink(), + signal: controller.signal, + }); + controller.abort(); + expect(result).toMatchObject({ text: "Done", mutated: false }); + expect(result.cancelled).not.toBe(true); + }); +}); diff --git a/electron/ai-edition/deep-agent/cancellation.ts b/electron/ai-edition/deep-agent/cancellation.ts new file mode 100644 index 000000000..35f5f7e33 --- /dev/null +++ b/electron/ai-edition/deep-agent/cancellation.ts @@ -0,0 +1,15 @@ +/** Await read-only work without requiring an uncooperative provider to settle. + * The caller must still guard every commit after an await with throwIfAborted. */ +export async function abortable(work: PromiseLike, signal: AbortSignal): Promise { + let abort: () => void = () => undefined; + try { + return await new Promise((resolve, reject) => { + abort = () => reject(signal.reason); + if (signal.aborted) reject(signal.reason); + else signal.addEventListener("abort", abort, { once: true }); + Promise.resolve(work).then(resolve, reject); + }); + } finally { + signal.removeEventListener("abort", abort); + } +} diff --git a/electron/ai-edition/deep-agent/service.ts b/electron/ai-edition/deep-agent/service.ts index 00fc819ee..12d41fbcd 100644 --- a/electron/ai-edition/deep-agent/service.ts +++ b/electron/ai-edition/deep-agent/service.ts @@ -101,6 +101,7 @@ import { setVocabularyArgs, } from "../caption-tools"; import { setEditorSettingsArgs } from "../editor-settings-tool"; +import { abortable } from "./cancellation"; import { createOpenScreenChatModel, messageContentToText, @@ -118,6 +119,8 @@ export interface OpenScreenAgentSink { toolStart: (name: string, args: unknown) => void; toolEnd: (name: string, ok: boolean, summary?: string) => void; error: (message: string) => void; + /** A completed tool edit, committed before cancellation can interleave. */ + document?: (document: AxcutDocument) => void; } // The tool arg schemas live in agent-tools.ts (imported above) — one source of truth for @@ -322,6 +325,7 @@ export interface CursorTelemetryReader { } export interface ToolRuntime { + signal?: AbortSignal; cursor?: CursorTelemetryReader; availableByAssetId?: Record; /** Asks the compositor whether this machine can cut out the camera background. */ @@ -376,9 +380,14 @@ function documentTool( return tool( (args: z.infer) => inTurn(async () => { + runtime.signal?.throwIfAborted(); sink.toolStart(name, args); const execution = await runDocumentTool(holder.current, name, args, editsAllowed, runtime); - if (execution.document) holder.current = execution.document; + runtime.signal?.throwIfAborted(); + if (execution.document) { + holder.current = execution.document; + sink.document?.(execution.document); + } sink.toolEnd(name, execution.ok, execution.summary); return execution.resultJson; }), @@ -425,6 +434,7 @@ export async function runDocumentTool( editsAllowed: boolean, runtime: ToolRuntime, ): Promise { + runtime.signal?.throwIfAborted(); const load = TOOLS_READING_CURSOR.has(name) ? await loadCursorTelemetry(document, args, runtime) : undefined; @@ -441,6 +451,9 @@ export async function runDocumentTool( await runtime.checkShaderEffect(effectSource.code, effectSource.params), ) : undefined; + // Async work above is read-only. An abort discards its result before the + // synchronous executor or document holder can commit anything. + runtime.signal?.throwIfAborted(); return executeAgentTool(document, name, JSON.stringify(args ?? {}), { editsAllowed, cursorTelemetry: { availableByAssetId: runtime.availableByAssetId, load }, @@ -600,6 +613,7 @@ export function anthropicCachingMiddleware(chatModel: { getName?: () => string } } export interface InvokeArgs { + signal?: AbortSignal; document: AxcutDocument; model: OpenScreenChatModelConfig; history: Array<{ role: "user" | "assistant" | "system"; content: string }>; @@ -664,6 +678,7 @@ export async function probeCursorTelemetry( const SILENT_TOOL_EVENTS: ReadonlySet = new Set(["on_tool_start", "on_tool_end"]); export interface InvokeResult { + cancelled?: boolean; text: string; document: AxcutDocument; mutated: boolean; @@ -677,6 +692,10 @@ export interface InvokeResult { export async function invokeOpenScreenAgent(args: InvokeArgs): Promise { const { document, model, history, userMessage, sink } = args; const editsAllowed = args.editsAllowed !== false; + const lifetime = new AbortController(); + const signal = args.signal ? AbortSignal.any([args.signal, lifetime.signal]) : lifetime.signal; + let finalText = ""; + let chatModelChunks: unknown[] = []; const holder: DocumentHolder = { current: document }; const initialDocumentJSON = JSON.stringify(document); @@ -685,39 +704,34 @@ export async function invokeOpenScreenAgent(args: InvokeArgs): Promise AsyncIterable>; } - ).streamEvents({ messages }, undefined); - - let finalText = ""; + ).streamEvents({ messages }, { signal }); const nonChatEvents: Array<{ event: string; name: string }> = []; - for await (const event of stream) { + const iterator = stream[Symbol.asyncIterator](); + while (true) { + const next = await abortable(iterator.next(), signal); + signal.throwIfAborted(); + if (next.done) break; + const event = next.value; const eventType = typeof event.event === "string" ? event.event : ""; const data = event.data as Record | undefined; const name = typeof event.name === "string" ? (event.name as string) : ""; @@ -788,6 +805,14 @@ export async function invokeOpenScreenAgent(args: InvokeArgs): Promise { + it("matches owner, project, session and turn, and accepts repeat stops idempotently", () => { + const runs = new ChatRuns(); + const owner = {}; + const turn = runs.start(owner, "p", "s", "r")!; + expect(runs.start({}, "p", "s2", "r2")).toBeNull(); + expect(runs.cancel({}, "p", "s", "r")).toBe(false); + expect(runs.cancel(owner, "other", "s", "r")).toBe(false); + expect(runs.cancel(owner, "p", "other", "r")).toBe(false); + expect(runs.cancel(owner, "p", "s", "old")).toBe(false); + expect(turn.signal.aborted).toBe(false); + expect(runs.cancel(owner, "p", "s", "r")).toBe(true); + expect(runs.cancel(owner, "p", "s", "r")).toBe(true); + expect(turn.signal.aborted).toBe(true); + }); + + it("isolates projects and keeps stale cancellation/cleanup out of the next turn", () => { + const runs = new ChatRuns(); + const owner = {}; + const old = runs.start(owner, "p", "s", "old")!; + const other = runs.start(owner, "other", "s", "other")!; + runs.cancel(owner, "p", "s", "old"); + old.finish(); + const next = runs.start(owner, "p", "s", "new")!; + old.finish(); + expect(runs.cancel(owner, "p", "s", "old")).toBe(false); + expect(runs.start(owner, "p", "s", "another")).toBeNull(); + expect(next.signal.aborted).toBe(false); + expect(other.signal.aborted).toBe(false); + next.finish(); + expect(runs.cancel(owner, "p", "s", "new")).toBe(false); + }); +}); diff --git a/electron/ipc/chatRuns.ts b/electron/ipc/chatRuns.ts new file mode 100644 index 000000000..130b6d56e --- /dev/null +++ b/electron/ipc/chatRuns.ts @@ -0,0 +1,35 @@ +/** Main-process ownership of agent turns. One writer per project, even across windows. */ +export class ChatRuns { + private readonly projects = new Map< + string, + { + owner: Owner; + sessionId: string; + runId: string; + controller: AbortController; + } + >(); + + start(owner: Owner, projectId: string, sessionId: string, runId: string) { + if (this.projects.has(projectId)) return null; + const turn = { owner, sessionId, runId, controller: new AbortController() }; + this.projects.set(projectId, turn); + return { + signal: turn.controller.signal, + finish: () => { + if (this.projects.get(projectId) === turn) this.projects.delete(projectId); + // Detached tools must stay sealed after all exit paths. + turn.controller.abort(); + }, + }; + } + + cancel(owner: Owner, projectId: string, sessionId: string, runId: string): boolean { + const turn = this.projects.get(projectId); + if (!turn || turn.owner !== owner || turn.sessionId !== sessionId || turn.runId !== runId) { + return false; + } + turn.controller.abort(); + return true; + } +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index ee36e105f..0697c0144 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -5221,8 +5221,9 @@ export function registerIpcHandlers( getAiEditionDocuments: () => aiEditionDocuments, getStylePresets: () => stylePresets, getAiEditionLlmConfig, - runAiEditionChat: (projectId, sessionId, message, document, sink) => + runAiEditionChat: (projectId, sessionId, message, document, sink, signal) => runChat(projectId, sessionId, message, getAiEditionLlmConfig(), document, sink, { + signal, cursor: agentCursorTelemetryReader, cameraSegmentation: () => loudnessService.probeSegmentation(), checkShaderEffect: (code, params) => loudnessService.checkShaderEffect(code, params), diff --git a/electron/ipc/nativeBridge.chat.test.ts b/electron/ipc/nativeBridge.chat.test.ts new file mode 100644 index 000000000..4b4b6ef65 --- /dev/null +++ b/electron/ipc/nativeBridge.chat.test.ts @@ -0,0 +1,123 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AiEditionChatResult, NativeBridgeResponse } from "../../src/native/contracts"; +import type { ChatEventSink } from "../ai-edition/chat-service"; +import { type NativeBridgeContext, registerNativeBridgeHandlers } from "./nativeBridge"; + +const electron = vi.hoisted(() => ({ handle: vi.fn() })); +vi.mock("electron", () => ({ + app: { getAppPath: () => "", isPackaged: false }, + ipcMain: { handle: electron.handle, removeHandler: vi.fn() }, + shell: {}, +})); +vi.mock("../native-bridge/services/compositorViewService", () => ({ + CompositorViewService: class {}, +})); +class Sender extends EventEmitter { + send = vi.fn(); +} +let handler: (event: { sender: Sender }, request: unknown) => Promise; +const runChat = vi.fn(); +beforeEach(() => { + electron.handle.mockClear(); + runChat.mockReset(); + registerNativeBridgeHandlers({ + getPlatform: () => "linux", + getAiEditionDocuments: () => ({}), + runAiEditionChat: runChat, + } as unknown as NativeBridgeContext); + handler = electron.handle.mock.calls[0][1]; +}); +const payload = { projectId: "p", sessionId: "s", runId: "r", message: "hello" }; +function invoke(sender: Sender, action: string, data: unknown = payload) { + return handler({ sender }, { domain: "aiEdition", action, payload: data }); +} +function pendingTurn() { + let finish!: (result: AiEditionChatResult) => void; + let signal: AbortSignal | undefined; + let sink: ChatEventSink | undefined; + runChat.mockImplementation((_p, _s, _m, _d, events, abort) => { + signal = abort; + sink = events; + return new Promise((resolve) => { + finish = resolve; + }); + }); + return { finish: () => finish({ success: true }), signal: () => signal!, sink: () => sink! }; +} + +describe("chat IPC", () => { + it.each([ + null, + {}, + { ...payload, runId: "" }, + { ...payload, projectId: 1 }, + { ...payload, sessionId: "../s" }, + ])("validates cancel identities before dispatch: %s", async (data) => { + const response = await invoke(new Sender(), "chat.cancel", data); + expect(response.ok ? null : response.error.code).toBe("INVALID_REQUEST"); + }); + + it("rejects empty messages and documents belonging to another project", async () => { + for (const data of [ + { ...payload, message: " " }, + { ...payload, document: { project: { id: "other" } } }, + ]) { + const response = await invoke(new Sender(), "chat.run", data); + expect(response.ok ? null : response.error.code).toBe("INVALID_REQUEST"); + } + expect(runChat).not.toHaveBeenCalled(); + }); + + it("owns the AbortController, sends turn-scoped events, and silences late events", async () => { + const pending = pendingTurn(); + const owner = new Sender(); + const stranger = new Sender(); + const running = invoke(owner, "chat.run"); + pending.sink().text?.("before"); + expect(owner.send).toHaveBeenCalledWith("ai-edition.chat-event", { + projectId: "p", + sessionId: "s", + runId: "r", + kind: "text", + delta: "before", + }); + for (const [sender, data] of [ + [stranger, payload], + [owner, { ...payload, projectId: "other" }], + [owner, { ...payload, runId: "old" }], + ] as const) { + const response = await invoke(sender, "chat.cancel", data); + expect(response.ok && response.data).toEqual({ accepted: false }); + } + expect(pending.signal().aborted).toBe(false); + for (let i = 0; i < 2; i++) { + const response = await invoke(owner, "chat.cancel"); + expect(response.ok && response.data).toEqual({ accepted: true }); + } + expect(pending.signal().aborted).toBe(true); + pending.sink().text?.("late"); + pending.sink().document?.({} as never); + expect(owner.send).toHaveBeenCalledOnce(); + pending.finish(); + await running; + expect(owner.listenerCount("destroyed")).toBe(0); + const response = await invoke(owner, "chat.cancel"); + expect(response.ok && response.data).toEqual({ accepted: false }); + }); + + it("aborts on window destruction and releases the project after a failed run", async () => { + const pending = pendingTurn(); + const owner = new Sender(); + const running = invoke(owner, "chat.run"); + owner.emit("destroyed"); + expect(pending.signal().aborted).toBe(true); + pending.finish(); + await running; + runChat.mockRejectedValueOnce(new Error("failed")); + await invoke(owner, "chat.run", { ...payload, runId: "fail" }); + expect(owner.listenerCount("destroyed")).toBe(0); + runChat.mockResolvedValueOnce({ success: true }); + expect((await invoke(owner, "chat.run", { ...payload, runId: "next" })).ok).toBe(true); + }); +}); diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 46e75ae58..602d2925b 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -24,6 +24,7 @@ import { CursorService } from "../native-bridge/services/cursorService"; import { ProjectService } from "../native-bridge/services/projectService"; import { SystemService } from "../native-bridge/services/systemService"; import { createNativeBridgeState } from "../native-bridge/store"; +import { ChatRuns } from "./chatRuns"; import { ExportJobs, isExportId } from "./exportJobs"; export interface NativeBridgeContext { @@ -71,6 +72,7 @@ export interface NativeBridgeContext { message: string, document?: unknown, sink?: ChatEventSink, + signal?: AbortSignal, ) => Promise; undoAiEditionToolBatch: ( projectId: string, @@ -178,25 +180,31 @@ function isBridgeRequest(value: unknown): value is NativeBridgeRequest { return typeof candidate.domain === "string" && typeof candidate.action === "string"; } -// build a ChatEventSink that broadcasts each event to the renderer -// that requested the chat run. The webContents may be gone by the time a late -// delta fires (tab closed, window destroyed) — webContents.send throws, so we -// swallow the error to keep the loop running. The renderer treats a missing -// "end of stream" as "the run was abandoned" and shows an inline error. -function buildChatEventSink(sender: Electron.WebContents, sessionId: string): ChatEventSink { +// Route progress only to the requesting renderer and only while its turn is +// active. Window teardown aborts the run; a send racing teardown is harmless. +function buildChatEventSink( + sender: Electron.WebContents, + projectId: string, + sessionId: string, + runId: string, + signal: AbortSignal, +): ChatEventSink { + const identity = { projectId, runId }; const send = (payload: AiEditionChatEvent) => { try { - sender.send("ai-edition.chat-event", payload); + if (!signal.aborted) sender.send("ai-edition.chat-event", payload); } catch { // webContents gone — keep the loop running silently. } }; return { - text: (delta) => send({ kind: "text", sessionId, delta }), - thinking: (delta) => send({ kind: "thinking", sessionId, delta }), - toolStart: (name, args) => send({ kind: "toolStart", sessionId, name, args }), - toolEnd: (name, ok, summary) => send({ kind: "toolEnd", sessionId, name, ok, summary }), - error: (message) => send({ kind: "error", sessionId, message }), + document: (document) => send({ ...identity, kind: "document", sessionId, document }), + text: (delta) => send({ ...identity, kind: "text", sessionId, delta }), + thinking: (delta) => send({ ...identity, kind: "thinking", sessionId, delta }), + toolStart: (name, args) => send({ ...identity, kind: "toolStart", sessionId, name, args }), + toolEnd: (name, ok, summary) => + send({ ...identity, kind: "toolEnd", sessionId, name, ok, summary }), + error: (message) => send({ ...identity, kind: "error", sessionId, message }), }; } @@ -252,6 +260,7 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { }); const exportJobs = new ExportJobs(); + const chatRuns = new ChatRuns(); ipcMain.handle(NATIVE_BRIDGE_CHANNEL, async (event, request: unknown) => { if (!isBridgeRequest(request)) { return createErrorResponse(undefined, "INVALID_REQUEST", "Invalid native bridge request."); @@ -650,19 +659,67 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { ); case "mcp.regenerateToken": return createSuccessResponse(requestId, await aiEditionService.mcpRegenerateToken()); + case "chat.cancel": case "chat.run": { - const sessionId = request.payload.sessionId; - const sink = buildChatEventSink(event.sender, sessionId); - return createSuccessResponse( - requestId, - await aiEditionService.chatRun( - request.payload.projectId, - sessionId, - request.payload.message, - request.payload.document, - sink, - ), - ); + const payload = request.payload; + const validId = (id: unknown): id is string => + typeof id === "string" && /^[A-Za-z0-9_-]{1,200}$/.test(id); + if ( + !payload || + !validId(payload.projectId) || + !validId(payload.sessionId) || + !validId(payload.runId) + ) { + return createErrorResponse( + requestId, + "INVALID_REQUEST", + "Invalid chat turn identity.", + ); + } + const { projectId, sessionId, runId } = payload; + if (request.action === "chat.cancel") { + return createSuccessResponse(requestId, { + accepted: chatRuns.cancel(event.sender, projectId, sessionId, runId), + }); + } + if (typeof request.payload.message !== "string" || !request.payload.message.trim()) { + return createErrorResponse(requestId, "INVALID_REQUEST", "Invalid chat message."); + } + const documentProjectId = ( + request.payload.document as { project?: { id?: unknown } } | null + )?.project?.id; + if (documentProjectId !== undefined && documentProjectId !== projectId) { + return createErrorResponse( + requestId, + "INVALID_REQUEST", + "The document belongs to another project.", + ); + } + const turn = chatRuns.start(event.sender, projectId, sessionId, runId); + if (!turn) + return createErrorResponse( + requestId, + "UNAVAILABLE", + "An agent turn is already running for this project.", + ); + const abandon = () => chatRuns.cancel(event.sender, projectId, sessionId, runId); + event.sender.once("destroyed", abandon); + try { + return createSuccessResponse( + requestId, + await aiEditionService.chatRun( + projectId, + sessionId, + request.payload.message, + request.payload.document, + buildChatEventSink(event.sender, projectId, sessionId, runId, turn.signal), + turn.signal, + ), + ); + } finally { + event.sender.removeListener("destroyed", abandon); + turn.finish(); + } } case "chat.undoLastBatch": return createSuccessResponse( diff --git a/electron/native-bridge/services/aiEditionService.ts b/electron/native-bridge/services/aiEditionService.ts index 01e880a3e..553a049ff 100644 --- a/electron/native-bridge/services/aiEditionService.ts +++ b/electron/native-bridge/services/aiEditionService.ts @@ -53,6 +53,7 @@ export interface AiEditionServiceOptions { message: string, document?: unknown, sink?: ChatEventSink, + signal?: AbortSignal, ) => Promise; rewindToMessage: ( projectId: string, @@ -314,8 +315,9 @@ export class AiEditionService { message: string, document?: unknown, sink?: ChatEventSink, + signal?: AbortSignal, ): Promise { - return this.options.runChat(projectId, sessionId, message, document, sink); + return this.options.runChat(projectId, sessionId, message, document, sink, signal); } chatUndoLastBatch(_projectId: string, _sessionId: string): AiEditionChatResult { diff --git a/src/components/ai-edition/LeftPanel.stop.test.tsx b/src/components/ai-edition/LeftPanel.stop.test.tsx new file mode 100644 index 000000000..bce7ff467 --- /dev/null +++ b/src/components/ai-edition/LeftPanel.stop.test.tsx @@ -0,0 +1,306 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext"; +import { createEmptyDocument } from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { clearHistory, redo, undo } from "@/lib/ai-edition/store/undo"; +import { past } from "@/lib/ai-edition/store/undoStack"; +import type { AiEditionChatEvent, AiEditionChatResult } from "@/native/contracts"; +import { ChatStripPanel } from "./LeftPanel"; + +const api = vi.hoisted(() => ({ + run: vi.fn(), + cancel: vi.fn(), + save: vi.fn(), + list: vi.fn(), + select: vi.fn(), + create: vi.fn(), + error: vi.fn(), + warning: vi.fn(), +})); +vi.mock("@/native/client", () => ({ + nativeBridgeClient: { + aiEdition: { + llmGetSnapshot: async () => ({ + config: { provider: "openai", model: "test" }, + connectedProviders: ["openai"], + }), + chatListSessions: api.list, + chatSelectSession: api.select, + chatCreateSession: api.create, + chatRun: api.run, + chatCancel: api.cancel, + save: api.save, + chatBudget: async () => null, + }, + }, +})); +vi.mock("@/contexts/I18nContext", () => ({ + useI18n: () => ({ locale: "en" }), + useScopedT: () => (key: string) => key, +})); +vi.mock("sonner", () => ({ toast: { error: api.error, warning: api.warning } })); +let onEvent: (event: AiEditionChatEvent) => void; +let replies: ((value: AiEditionChatResult) => void)[]; +const doc = (title: string, projectId = "p") => createEmptyDocument({ projectId, title }); +const result = (content: string, stopped = false): AiEditionChatResult => ({ + success: true, + cancelled: stopped, + assistantMessage: { + id: "reply", + role: "assistant", + createdAt: new Date().toISOString(), + content, + stopped, + }, +}); +const session = (id = "s") => ({ + id, + projectId: "p", + title: id, + messageCount: 0, + createdAt: "2026-01-01T00:00:00Z", + messages: [], +}); +beforeEach(() => { + vi.clearAllMocks(); + clearHistory(); + replies = []; + Element.prototype.scrollTo = vi.fn(); + vi.stubGlobal( + "ResizeObserver", + class { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + }, + ); + window.electronAPI = { + onAiEditionChatEvent: (listener: typeof onEvent) => { + onEvent = listener; + return vi.fn(); + }, + } as unknown as typeof window.electronAPI; + useProjectStore.setState({ projectId: "p", document: doc("Before"), revision: 1 }); + api.list.mockImplementation(async () => [session()]); + api.select.mockImplementation(async () => session()); + api.create.mockImplementation(async () => session("new")); + api.run.mockImplementation( + () => + new Promise((resolve) => { + replies.push(resolve); + }), + ); + api.cancel.mockResolvedValue({ accepted: true }); + api.save.mockImplementation(async (document) => ({ success: true, document })); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +async function start() { + await screen.findByText("s"); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "Edit" } }); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => expect(api.run).toHaveBeenCalled()); + return api.run.mock.calls.at(-1)![4] as string; +} +function panel() { + return render( + + + + + , + ); +} +function emit( + runId: string, + event: { kind: "text"; delta: string } | { kind: "document"; document: unknown }, +) { + act(() => onEvent({ projectId: "p", sessionId: "s", runId, ...event })); +} + +describe("composer Stop", () => { + it("keeps the Stopping tooltip keyboard-accessible without sending another cancel", async () => { + panel(); + await start(); + const stop = screen.getByRole("button", { name: "chat.stop" }); + fireEvent.click(stop); + expect(stop).toHaveAttribute("aria-disabled", "true"); + expect(stop).not.toBeDisabled(); + fireEvent.keyDown(window, { key: "Tab" }); + act(() => stop.focus()); + expect(await screen.findByRole("tooltip")).toHaveTextContent("chat.stopping"); + expect(stop).toHaveFocus(); + fireEvent.click(stop); + expect(api.cancel).toHaveBeenCalledOnce(); + await act(async () => replies[0](result("Partial", true))); + }); + + it("commits stopped edits once, retains the live reply, ignores old events and starts the next turn", async () => { + panel(); + const id = await start(); + expect(screen.getByRole("button", { name: "chat.stop" })).toBeEnabled(); + emit(id, { kind: "document", document: doc("First") }); + emit(id, { kind: "document", document: doc("Second") }); + emit(id, { kind: "text", delta: "Partial" }); + await act(async () => { + // Flush event work before checking that no intermediate edits were saved. + }); + expect(screen.getByText("Partial")).toBeVisible(); + expect(api.save).not.toHaveBeenCalled(); + expect(past).toHaveLength(0); + expect(useProjectStore.getState().document?.project.title).toBe("Before"); + fireEvent.click(screen.getByRole("button", { name: "chat.stop" })); + fireEvent.click(screen.getByRole("button", { name: "chat.stop" })); + expect(api.cancel).toHaveBeenCalledOnce(); + expect(api.cancel).toHaveBeenCalledWith("p", "s", id); + await act(async () => replies[0]({ ...result("Partial", true), document: doc("Second") })); + expect(screen.getByText("chat.stopped")).toBeVisible(); + expect(screen.getByText("Partial")).toBeVisible(); + expect(screen.getByRole("button", { name: "chat.send" })).toBeInTheDocument(); + expect(api.save).toHaveBeenCalledOnce(); + expect(past).toHaveLength(1); + expect(useProjectStore.getState().document?.project.title).toBe("Second"); + act(() => { + undo(); + }); + expect(useProjectStore.getState().document?.project.title).toBe("Before"); + act(() => { + redo(); + }); + expect(useProjectStore.getState().document?.project.title).toBe("Second"); + const nextId = await start(); + expect(nextId).not.toBe(id); + emit(id, { kind: "text", delta: "STALE" }); + emit(id, { kind: "document", document: doc("STALE") }); + expect(screen.queryByText("STALE")).toBeNull(); + expect(screen.getByRole("button", { name: "chat.stop" })).toBeEnabled(); + await act(async () => replies[1](result("Next reply"))); + expect(screen.getByText("Next reply")).toBeVisible(); + expect(api.error).not.toHaveBeenCalled(); + }); + + it.each([false, true])("keeps a long turn in one undo step (stopped: %s)", async (stopped) => { + useProjectStore.getState().setDocument(doc("Pre-turn"), { history: true }); + panel(); + const id = await start(); + for (let i = 0; i < 60; i++) { + emit(id, { kind: "document", document: doc(`Tool ${i}`) }); + } + await act(async () => { + // Let any queued per-tool writes run so the regression cannot hide in microtasks. + }); + expect(api.save).not.toHaveBeenCalled(); + expect(past).toHaveLength(1); + if (stopped) fireEvent.click(screen.getByRole("button", { name: "chat.stop" })); + await act(async () => + replies[0]({ ...result("Done", stopped), document: doc("Final aggregate") }), + ); + expect(api.save).toHaveBeenCalledOnce(); + expect(past).toHaveLength(2); + expect(useProjectStore.getState().document?.project.title).toBe("Final aggregate"); + act(() => { + undo(); + }); + expect(useProjectStore.getState().document?.project.title).toBe("Pre-turn"); + act(() => { + undo(); + }); + expect(useProjectStore.getState().document?.project.title).toBe("Before"); + }); + + it("applies a stopped aggregate when no document event arrived", async () => { + panel(); + await start(); + fireEvent.click(screen.getByRole("button", { name: "chat.stop" })); + await act(async () => + replies[0]({ ...result("Partial", true), document: doc("Completed edit") }), + ); + expect(api.save).toHaveBeenCalledOnce(); + expect(past).toHaveLength(1); + expect(useProjectStore.getState().document?.project.title).toBe("Completed edit"); + act(() => { + undo(); + }); + expect(useProjectStore.getState().document?.project.title).toBe("Before"); + }); + + it("keeps manual edits during a turn until Apply anyway commits the aggregate once", async () => { + panel(); + const id = await start(); + emit(id, { kind: "document", document: doc("First") }); + act(() => useProjectStore.getState().setDocument(doc("Manual"), { history: true })); + emit(id, { kind: "document", document: doc("Second") }); + await act(async () => replies[0]({ ...result("Done"), document: doc("Second") })); + expect(api.save).not.toHaveBeenCalled(); + expect(useProjectStore.getState().document?.project.title).toBe("Manual"); + expect(api.warning).toHaveBeenCalledWith("chat.agentEditConflict", expect.any(Object)); + await act(async () => api.warning.mock.calls[0][1].action.onClick()); + await waitFor(() => expect(api.save).toHaveBeenCalledOnce()); + expect(useProjectStore.getState().document?.project.title).toBe("Second"); + act(() => { + undo(); + }); + expect(useProjectStore.getState().document?.project.title).toBe("Manual"); + }); + + it("stops while creating the first session without sending a model request", async () => { + api.list.mockResolvedValue([]); + let created!: (value: ReturnType) => void; + api.create.mockImplementation( + () => + new Promise((resolve) => { + created = resolve; + }), + ); + panel(); + await waitFor(() => expect(screen.getByRole("textbox")).toBeEnabled()); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Unsent" } }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + await waitFor(() => expect(api.create).toHaveBeenCalled()); + fireEvent.click(screen.getByRole("button", { name: "chat.stop" })); + await act(async () => created(session())); + expect(api.run).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "chat.send" })).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue("Unsent"); + }); + + it("cancels on project switch and old cleanup cannot end the new turn", async () => { + panel(); + const id = await start(); + act(() => useProjectStore.setState({ projectId: "other", document: doc("Other", "other") })); + await waitFor(() => expect(api.cancel).toHaveBeenCalledWith("p", "s", id)); + await start(); + await act(async () => replies[0]({ ...result("Old reply", true), document: doc("Old") })); + emit(id, { kind: "document", document: doc("Old") }); + expect(screen.queryByText("Old reply")).toBeNull(); + expect(screen.getByRole("button", { name: "chat.stop" })).toBeEnabled(); + expect(useProjectStore.getState().document?.project.title).toBe("Other"); + expect(api.save).not.toHaveBeenCalled(); + await act(async () => replies[1](result("New reply"))); + }); + + it("cancels when selecting a new session or unmounting", async () => { + const view = panel(); + const id = await start(); + fireEvent.click(screen.getByRole("button", { name: "chat.newConversation" })); + await waitFor(() => expect(api.cancel).toHaveBeenCalledWith("p", "s", id)); + await act(async () => replies[0]({ ...result("Old", true), document: doc("Old") })); + expect(screen.queryByText("Old")).toBeNull(); + expect(api.save).not.toHaveBeenCalled(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Next" } }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + await waitFor(() => expect(api.run).toHaveBeenCalledTimes(2)); + const nextId = api.run.mock.calls[1][4]; + view.unmount(); + expect(api.cancel).toHaveBeenLastCalledWith("p", "new", nextId); + await act(async () => replies[1]({ ...result("Disposed", true), document: doc("Disposed") })); + expect(api.save).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 144d0b4c7..af893cb7e 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Check, Loader2, X } from "lucide-react"; +import { ArrowLeft, Check, Loader2, Square, X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; @@ -32,6 +32,7 @@ import styles from "./NewEditorShell.module.css"; import { useChatBudget } from "./useChatBudget"; interface ChatDisplayMessage { + stopped?: boolean; id?: string; role: string; content: string; @@ -438,6 +439,17 @@ export function ChatStripPanel() { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); + const [stopping, setStopping] = useState(false); + const [liveText, setLiveText] = useState(""); + const latestRunIdRef = useRef(null); + const runRef = useRef<{ + id: string; + projectId: string; + sessionId: string | null; + stopping: boolean; + thinking: string; + } | null>(null); + const viewRef = useRef({ projectId, sessionId: null as string | null }); const [llmConfig, setLlmConfig] = useState(null); // The dialog itself is mounted in App.tsx so the app menu can reach it from every mode // (issue #420); this panel only asks for it to be opened, and watches it close. @@ -458,15 +470,14 @@ export function ChatStripPanel() { // freshly-created empty one. const activeSessionIdRef = useRef(activeSessionId); activeSessionIdRef.current = activeSessionId; + viewRef.current = { projectId, sessionId: activeSessionId }; const scrollRef = useRef(null); // ponytail: live reasoning trace for the in-flight turn. Reset at send() // start; deltas append through the chat-event subscription; once the run // resolves we copy it onto the assistant message and clear it. const [thinkingText, setThinkingText] = useState(""); const [thinkingExpanded, setThinkingExpanded] = useState(false); - // sessionId of the in-flight run — late events for prior runs (or for - // other windows) are ignored so a stale stream can't pollute the new turn. - const thinkingRunSessionRef = useRef(null); + // Per-message expand state for completed turns. Using a Set keeps the // default-collapsed preview lightweight and the click-to-expand obvious. const [thinkingExpandedIds, setThinkingExpandedIds] = useState>(() => new Set()); @@ -498,6 +509,7 @@ export function ChatStripPanel() { const refreshSessions = useCallback(async (pid: string, preferFirst = false) => { try { const list = await nativeBridgeClient.aiEdition.chatListSessions(pid); + if (viewRef.current.projectId !== pid) return; setSessions(list); if (list.length === 0) { setActiveSessionId(null); @@ -520,20 +532,71 @@ export function ChatStripPanel() { if (!providerSettingsOpen) void refreshLlm(); }, [providerSettingsOpen, refreshLlm]); - // ponytail: subscribe to streamed chat events so the reasoning trace (and - // any future streaming text deltas) lands live instead of arriving all at - // once when chatRun resolves. We only act on `thinking` here — text deltas - // are ignored in the renderer today because the chat already renders the - // final assistant text on chatRun resolve, and a parallel live stream - // would race the final message. Add `text` handling when that flow lands. + const abandonRun = useCallback(() => { + const run = runRef.current; + if (!run) return; + runRef.current = null; + if (run.sessionId) { + void nativeBridgeClient.aiEdition + .chatCancel(run.projectId, run.sessionId, run.id) + .catch(() => undefined); + } + }, []); + useEffect(() => { - const unsubChatEvent = window.electronAPI.onAiEditionChatEvent((event: AiEditionChatEvent) => { - if (event.kind !== "thinking") return; - if (event.sessionId !== thinkingRunSessionRef.current) return; - setThinkingText((prev) => prev + event.delta); + const unsubscribe = window.electronAPI.onAiEditionChatEvent((event: AiEditionChatEvent) => { + const run = runRef.current; + if ( + !run || + event.runId !== run.id || + event.projectId !== run.projectId || + event.sessionId !== run.sessionId + ) + return; + if ( + viewRef.current.projectId !== run.projectId || + viewRef.current.sessionId !== run.sessionId + ) + return; + if (event.kind === "thinking") { + run.thinking += event.delta; + setThinkingText(run.thinking); + } else if (event.kind === "text") setLiveText((prev) => prev + event.delta); }); - return unsubChatEvent; - }, []); + return () => { + unsubscribe(); + abandonRun(); + }; + }, [abandonRun]); + + useEffect(() => { + const run = runRef.current; + if (run && (run.projectId !== projectId || run.sessionId !== activeSessionId)) { + abandonRun(); + setBusy(false); + setStopping(false); + setThinkingText(""); + setLiveText(""); + } + }, [projectId, activeSessionId, abandonRun]); + + const stop = async () => { + const run = runRef.current; + if (!run || run.stopping) return; + run.stopping = true; + setStopping(true); + if (!run.sessionId) return; // session creation is still pending; send() will stop there. + try { + await nativeBridgeClient.aiEdition.chatCancel(run.projectId, run.sessionId, run.id); + } catch (error) { + if (runRef.current !== run) return; + run.stopping = false; + setStopping(false); + toast.error(t("chat.stopFailed"), { + description: error instanceof Error ? error.message : String(error), + }); + } + }; useEffect(() => { if (!projectId) { @@ -550,12 +613,20 @@ export function ChatStripPanel() { setMessages([]); return; } + let disposed = false; void (async () => { try { const session = await nativeBridgeClient.aiEdition.chatSelectSession( projectId, activeSessionId, ); + if ( + disposed || + runRef.current || + viewRef.current.projectId !== projectId || + viewRef.current.sessionId !== activeSessionId + ) + return; if (session) { setMessages( session.messages.map((m) => ({ @@ -565,6 +636,7 @@ export function ChatStripPanel() { time: m.createdAt, toolCalls: m.toolCalls, checkpointId: m.checkpointId ?? null, + stopped: m.stopped, })), ); } else { @@ -574,6 +646,9 @@ export function ChatStripPanel() { // ponytail: silent — shim mode } })(); + return () => { + disposed = true; + }; }, [projectId, activeSessionId]); useEffect(() => { @@ -582,7 +657,7 @@ export function ChatStripPanel() { const send = async (overrideText?: string) => { const text = (overrideText ?? input).trim(); - if (!projectId || !text || busy) return; + if (!projectId || !text || busy || runRef.current) return; // ponytail: nothing to talk to. Bounce to the settings modal instead of // firing a doomed request. The composer is disabled in this state too, // but Auto-enhance calls send() directly and Enter can slip through. @@ -591,14 +666,29 @@ export function ChatStripPanel() { openProviderSettings(); return; } + const run = { + id: crypto.randomUUID(), + projectId, + sessionId: activeSessionId, + stopping: false, + thinking: "", + }; + runRef.current = run; + latestRunIdRef.current = run.id; + const isCurrent = () => + runRef.current === run && + viewRef.current.projectId === projectId && + viewRef.current.sessionId === run.sessionId; setInput(""); setBusy(true); + setStopping(false); + setLiveText(""); // ponytail: prepare the live reasoning-trace accumulator. Late events // from a previous run (or from another panel/window) won't match this // sessionId and are dropped by the subscription above. setThinkingText(""); setThinkingExpanded(false); - thinkingRunSessionRef.current = null; + // ponytail: pre-seed the user message so the rewind ↩ button is // available before the server confirms. Mirrors axcut's // `before-message` checkpoint that runChat records in chat-service. @@ -621,20 +711,58 @@ export function ChatStripPanel() { if (!sessionId) { const created = await nativeBridgeClient.aiEdition.chatCreateSession(projectId); sessionId = created.id; + if (!isCurrent()) return; + if (run.stopping) { + setMessages((prev) => prev.filter((message) => message.id !== optimisticUserId)); + setInput(text); + return; + } + run.sessionId = sessionId; + viewRef.current.sessionId = sessionId; setSessions((prev) => [...prev, created]); setActiveSessionId(sessionId); } - // ponytail: start collecting the live reasoning trace for THIS run — - // the subscription only appends deltas whose sessionId matches. - thinkingRunSessionRef.current = sessionId; - // Send the current document snapshot so the agent can run edit tools - // against it (P1). Falls back to text-only chat when no doc is open. - // `runAgentTurn` reads the document AND the revision it is at from one snapshot - // before the turn starts, so a manual edit landing while the agent works is - // detectable when its answer comes back. + if (!isCurrent() || run.stopping) return; + run.sessionId = sessionId; + // Capture the document and its revision together before awaiting the turn. + // Stop returns the completed tools' aggregate too: commit it once so the + // entire turn has one save and one undo step, regardless of tool count. const { result, applyDocument } = await runAgentTurn((documentSnapshot) => - nativeBridgeClient.aiEdition.chatRun(projectId, sessionId, text, documentSnapshot), + nativeBridgeClient.aiEdition.chatRun(projectId, sessionId, text, documentSnapshot, run.id), ); + if (!isCurrent()) return; + if (result.document) { + const applyEdits = async (options?: { ignoreConflict?: boolean }) => { + try { + const applied = await applyDocument(options); + if (applied === "save-failed") toast.error(t("chat.applyEditsFailed")); + return applied; + } catch (error) { + toast.error(t("chat.applyEditsFailed"), { + description: error instanceof Error ? error.message : String(error), + }); + return "malformed" as const; + } + }; + const applied = await applyEdits(); + if (!isCurrent()) return; + if (applied === "conflict") + toast.warning(t("chat.agentEditConflict"), { + duration: Number.POSITIVE_INFINITY, + action: { + label: t("chat.applyAnyway"), + onClick: () => { + if ( + latestRunIdRef.current !== run.id || + viewRef.current.projectId !== projectId || + viewRef.current.sessionId !== sessionId + ) + return; + void applyEdits({ ignoreConflict: true }); + }, + }, + }); + } setMessages((prev) => adoptStoredUserMessage(prev, optimisticUserId, result)); // A rewind confirmation opened during the turn names the message too. Stored without a // checkpoint, the message has nothing to rewind to and the confirmation closes. @@ -650,56 +778,19 @@ export function ChatStripPanel() { } const assistant = result.assistantMessage; if (result.success && assistant) { - if (result.document) { - const applyEdits = async (options?: { ignoreConflict?: boolean }) => { - try { - return await applyDocument(options); - } catch (err) { - // Only `ensureDocument` still throws here -- the agent handed back - // something that is not a document. A failed WRITE does not reach this: - // the store reports it itself and `applyDocument` answers "save-failed". - toast.error(t("chat.applyEditsFailed"), { - description: err instanceof Error ? err.message : String(err), - }); - return "malformed" as const; - } - }; - const applyResult = await applyEdits(); - if (applyResult === "save-failed") { - // The store has already said WHY the write failed, with the native error. - // This says what it COST, without a description so the two do not repeat - // each other: the assistant's "done, I removed 14 silences" renders either - // way, so a bare save error next to it leaves the two unconnected. - toast.error(t("chat.applyEditsFailed")); - } else if (applyResult === "conflict") { - // The turn is not lost, it is just not automatically applied: the document - // is still in hand and the assistant's reply is about to be rendered as if - // the edits had landed. The thing that usually moves `revision` here is a - // background transcription finishing, not the user -- so dropping the whole - // turn on the floor and blaming "the project changed" costs them a minute - // of waiting and their tokens for something they never did. Let them take - // it. No auto-dismiss: it is the only way back to this document. - toast.warning(t("chat.agentEditConflict"), { - duration: Number.POSITIVE_INFINITY, - action: { - label: t("chat.applyAnyway"), - onClick: () => void applyEdits({ ignoreConflict: true }), - }, - }); - } - } setMessages((prev) => [ ...prev, { role: "assistant", content: assistant.content, + stopped: assistant.stopped, time: new Date().toISOString(), toolCalls: assistant.toolCalls, // ponytail: snapshot the live reasoning trace onto the // finished message so it can be revisited (collapsed by // default, click-to-expand) instead of vanishing. The // live accumulator is cleared in `finally`. - thinking: thinkingText || undefined, + thinking: run.thinking || undefined, }, ]); void refreshSessions(projectId); @@ -707,17 +798,19 @@ export function ChatStripPanel() { toast.error(result.error ?? t("chat.chatFailed")); } } catch (err) { + if (!isCurrent()) return; toast.error(t("chat.chatFailed"), { description: err instanceof Error ? err.message : String(err), }); } finally { - setBusy(false); - // ponytail: stop accepting thinking deltas and drop the in-flight - // preview — the snapshot was either attached to the assistant - // message above, or there's no message to attach it to (failure). - thinkingRunSessionRef.current = null; - setThinkingText(""); - setThinkingExpanded(false); + if (isCurrent()) { + runRef.current = null; + setBusy(false); + setStopping(false); + setThinkingText(""); + setLiveText(""); + setThinkingExpanded(false); + } } }; @@ -772,6 +865,7 @@ export function ChatStripPanel() { time: m.createdAt, toolCalls: m.toolCalls, checkpointId: m.checkpointId ?? null, + stopped: m.stopped, })), ); setInput(result.prompt); @@ -916,6 +1010,7 @@ export function ChatStripPanel() { time: m.createdAt, toolCalls: m.toolCalls, checkpointId: m.checkpointId ?? null, + stopped: m.stopped, })), ); toast.success(t("chat.compactedSuccess")); @@ -1398,6 +1493,11 @@ export function ChatStripPanel() { + {m.stopped ? ( +
+ {t("chat.stopped")} +
+ ) : null} {m.toolCalls?.length ? (
{m.toolCalls.map((call, j) => ( @@ -1420,6 +1520,8 @@ export function ChatStripPanel() {
{t("chat.authorAssistant")}
+ {liveText ?
{liveText}
: null} + {stopping ?
{t("chat.stopping")}
: null} {thinkingText ? (
- + + ) : ( + - void send()} > - - - - - + + + + + + + )}
+ Promise.resolve({ accepted: false }), chatUndoLastBatch: () => Promise.resolve({ success: false, diff --git a/src/native/client.ts b/src/native/client.ts index 53d09419b..779054801 100644 --- a/src/native/client.ts +++ b/src/native/client.ts @@ -290,11 +290,18 @@ export const nativeBridgeClient = { sessionId: string, message: string, document?: unknown, + runId = crypto.randomUUID(), ): Promise => requireNativeBridgeData({ domain: "aiEdition", action: "chat.run", - payload: { projectId, sessionId, message, document }, + payload: { projectId, sessionId, message, document, runId }, + }), + chatCancel: (projectId: string, sessionId: string, runId: string) => + requireNativeBridgeData<{ accepted: boolean }>({ + domain: "aiEdition", + action: "chat.cancel", + payload: { projectId, sessionId, runId }, }), chatUndoLastBatch: (projectId: string, sessionId: string) => requireNativeBridgeData({ diff --git a/src/native/contracts.ts b/src/native/contracts.ts index 521cc993e..0e72ef45a 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -385,6 +385,7 @@ export interface AiEditionToolCallSummary { } export interface AiEditionChatMessage { + stopped?: boolean; id: string; role: "user" | "assistant"; content: string; @@ -399,6 +400,7 @@ export interface AiEditionChatMessage { } export interface AiEditionChatResult { + cancelled?: boolean; success: boolean; assistantMessage?: AiEditionChatMessage; /** Updated document when the agent ran write tools during this turn. */ @@ -729,6 +731,7 @@ export type NativeBridgeRequest = domain: "aiEdition"; action: "chat.run"; payload: { + runId: string; projectId: string; sessionId: string; message: string; @@ -738,6 +741,12 @@ export type NativeBridgeRequest = }; requestId?: string; } + | { + domain: "aiEdition"; + action: "chat.cancel"; + payload: { projectId: string; sessionId: string; runId: string }; + requestId?: string; + } | { domain: "aiEdition"; action: "chat.undoLastBatch"; @@ -1019,12 +1028,14 @@ export type NativeBridgeEventName = // return. Empty `assistant` slot + `kind: "error"` means the upstream // provider failed and the toast was a side-channel; the renderer shows it // inline so the chat doesn't appear to "echo back my question". -export type AiEditionChatEvent = +export type AiEditionChatEvent = { projectId: string; runId: string } & ( | { kind: "text"; sessionId: string; delta: string } | { kind: "thinking"; sessionId: string; delta: string } | { kind: "toolStart"; sessionId: string; name: string; args: unknown } | { kind: "toolEnd"; sessionId: string; name: string; ok: boolean; summary?: string } - | { kind: "error"; sessionId: string; message: string }; + | { kind: "error"; sessionId: string; message: string } + | { kind: "document"; sessionId: string; document: unknown } +); export interface NativeBridgeEvent { name: NativeBridgeEventName; diff --git a/technical-documentation/architecture/ai-agent.md b/technical-documentation/architecture/ai-agent.md index b09493e13..17fddf104 100644 --- a/technical-documentation/architecture/ai-agent.md +++ b/technical-documentation/architecture/ai-agent.md @@ -76,7 +76,40 @@ Clips and trims use source time. Zoom, speed, annotation, camera-fullscreen, and ## Checkpoints and undo -Before a user message starts an agent turn, `chat-service.ts` clones the current document and associates it with that user message. All write calls produced by the turn operate from that checkpoint lineage, so restoring the message reverts the complete tool batch as one undo unit rather than undoing each model call separately. Rewind also truncates later conversation messages and invalidates later checkpoints. +Before a user message starts an agent turn, `chat-service.ts` clones the current +document and associates it with that user message. Explicit message rewind restores +that snapshot and truncates the later conversation. It is separate from Ctrl+Z: +completed tool edits accumulate in the agent's working document. `runAgentTurn` +captures the live document and its revision before the turn starts. On normal +completion or Stop, `runChat` returns the accumulated document, which the panel +applies once through `applyAgentDocumentIfCurrent` and the existing project store +save/history boundary. Each turn that applies edits has one project save and one +undo step. A revision conflict preserves the live document and offers Apply anyway. + +## Stopping a turn + +`electron/ipc/chatRuns.ts` owns a main-process AbortController per active project. +The `chat.cancel` bridge action must match the sending WebContents, project, session, +and renderer-generated run ID; a stale stop cannot cancel the next turn. Concurrent +turns for the same project are refused. Other projects are independent. + +The signal passes through the bridge service and chat runtime into both +`createAgent` and `streamEvents`. The runtime also stops awaiting an uncooperative +stream or read-only setup operation on abort. Tool calls remain serialized and +check the signal before execution and after asynchronous telemetry reads or shader +checks, before updating the document holder. Those read-only operations may finish +in the background; their results cannot commit edits or emit tool success after stop. +The turn lifetime seals queued tools on every exit, including normal completion. + +Every streamed event carries the project, session and run ID. The panel ignores +foreign or stale events, cancels on project/session change or unmount, and discards +an abandoned turn's result. Window destruction aborts its run in main as well. +Stop stays in a stopping state until the turn replies and the accumulated document's +save settles. It preserves completed tool edits in one undo step, the partial answer, +and completed tool summaries. A stopped assistant message has `stopped: true` in the +existing chat sidecar, so reopening the conversation still distinguishes a user stop +from a provider failure. A later normal completion can win the race with a stop; +repeated stops are harmless. ## Context management diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 340df1480..8feb6a65c 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -994,3 +994,4 @@ These checks are the human slice: an agent cannot click Apple's picker (see abov | 2026-10-17 | PR #1319 (`2236580`), artifact `openscreen-linux` of build run 39046512213, AppImage run directly. Reference: the `v2.1.0` AppImage from the GitHub release | Ubuntu 24.04.4, GNOME 46 Wayland, AMD Radeon 610M (RADV); `[d3d] … -> backend Hardware`, no CPU fallback | **Partial — fix slice only, no defect** | Same MCP pass and project as the rows above, on the hardware Vulkan backend. `orbit` (12 frames) and `left` (4 s, 6 s): the whole card with four rounded corners, no flat cut. `v2.1.0` reproduces the bug (top and bottom edges flat, corners cut from 1.5 s). `none` and the flat frames at 1.0 and 9.5 s are byte-identical to `v2.1.0` (equal md5). **Not covered:** the editor preview; `right`; a full MP4 export. | | 2026-10-17 | `v2.1.1-rc.1`, `release/v2.1.1` @ `6ec38bf` (= `v2.1.0` + #1319), assets of the GitHub pre-release (build run 39069024444). Windows: `Openscreen.Setup.2.1.1-rc.1-x64.exe`, sha512 matched `latest.yml`, unpacked with 7-Zip and run in place, version 2.1.1. macOS: `Openscreen-macOS-Apple-Silicon-2.1.1-rc.1.dmg`, sha256 matched the asset digest; `spctl` accepted, Notarized Developer ID, on the DMG and the app; ticket stapled to the DMG only, as on 2.1.0-rc.6; `CFBundleShortVersionString` 2.1.1-rc.1. Linux: `Openscreen-Linux-2.1.1-rc.1.AppImage`, sha512 matched `latest-linux.yml` | Windows 11 Home 26200, RTX 4070 Ti; macOS 27.0.1 (26A434), Mac mini M1 8 GB; Ubuntu 24.04.4, GNOME 46 Wayland, Radeon 610M, hardware Vulkan backend | **Partial — patch pass toward stable: no defect on any of the three** | Scoped to what the patch changes (the compositor geometry of a tilted filled card), in MCP over stdio with an isolated `--user-data-dir`, on the project of the PR rows above. On all three: `orbit` (12 frames) and `left` show the whole card with four rounded corners and no flat cut; `none` is a flat zoom in a rounded card (byte-identical to `v2.1.0` on Windows and Linux; on Windows every checked frame is byte-identical to the PR build). `exportVideo` at 1080p30: 10.00 s, 1920×1080 H.264 with an AAC track, decodes without error, and its frame at 5 s shows the tilted card with rounded corners. Before the cut, on a dev build of the PR: the four device frames, dual-frame and vertical-stack, under `orbit`, `left` and `none`, rendered byte-identical to `v2.1.0` (49 of 49 frames). **Not covered:** recording, playback and the editor preview, which the patch does not touch; the rest of the checklist (the `v2.1.0-rc.6` rows). | | 2026-10-19 | `v2.1.2-rc.1`, `release/v2.1.2` @ `c4ef8fd` (= `v2.1.1` + #1344), assets of the GitHub pre-release (build run 39224311283). Windows: `Openscreen.Setup.2.1.2-rc.1-x64.exe` unpacked with 7-Zip and run in place. macOS: `Openscreen-Mac-arm64-2.1.2-rc.1.zip`, run in place. Linux: the AppImage, run directly with `--no-sandbox` | Windows 11 Home 26200; macOS 27.0.1, Mac mini M1; Ubuntu 24.04 GNOME Wayland (Xwayland `:0`) | **Partial: fix slice only, no defect** | `openscreen mcp` over stdio, scripted, with a fresh `--user-data-dir` per run. On all three platforms it answered `initialize`, and `usage-ping.json` held today's day, which is saved only once PostHog accepted the event. With `DO_NOT_TRACK=1` no state file was written. stdout held only JSON-RPC on macOS and Linux. On Windows it starts with one `\r\n` that Electron 41 itself writes during browser-process startup, before `main.js` loads: a bare `app.exit(0)` script prints it too, `ELECTRON_RUN_AS_NODE` does not. It predates this change, and the MCP SDK clients skip it with one parse error. Not covered: everything else in this file, including recording, editing, export, the GUI ping and the tray toggle. These runs sent eight real `surface: mcp` events to PostHog on 19/10. | +| 2026-10-22 | Dev `d8bf2d81` + issue #1260 patch (`fix/agent-stop-1260`), Electron 41.2.1; Vite `127.0.0.1:5186`, private `openscreen1260-ui-*` userData, local controlled OpenAI-compatible provider | macOS 15.8, Apple Silicon | Superseded — pre-`0cb26e07` Agent Stop check | **Superseded by `0cb26e07`:** the two-Undo observation below records the old per-tool behavior. The current implementation applies the accumulated document once on completion or Stop, with one save and one undo step per turn. Automated regressions cover normal and stopped turns with 60 tool edits; this desktop check has not been rerun on the new head. **Historical run:** Real Electron operated through computer-use: sent a first turn, observed Stop while the provider held its SSE response open, applied two `setVocabulary` edits, clicked Stop, saw the stopped status and Send return, confirmed the provider connection closed and the partial answer plus `stopped: true` reached the chat sidecar. Document bytes stayed at `Second edit`; one Undo restored `First edit`, a second restored the initial document. A new turn completed with `Next turn works.` Screenshots captured for running and continued states. Skipped: paid/remote providers, Windows/Linux desktop UI, capture, media preview and export (this patch changes Agent chat only). |