From cd3d025d26fbdc2b42d24f9e97906339255c9c78 Mon Sep 17 00:00:00 2001 From: wuzixuan06 Date: Thu, 22 Oct 2026 16:35:26 +0800 Subject: [PATCH 1/3] feat: add Stop control for agent turns --- electron/ai-edition/chat-persistence.ts | 1 + .../ai-edition/chat-service.toolloop.test.ts | 75 ++++ electron/ai-edition/chat-service.ts | 38 ++- .../deep-agent/cancellation.test.ts | 236 +++++++++++++ .../ai-edition/deep-agent/cancellation.ts | 15 + electron/ai-edition/deep-agent/service.ts | 102 ++++-- electron/ipc/chatRuns.test.ts | 36 ++ electron/ipc/chatRuns.ts | 35 ++ electron/ipc/handlers.ts | 3 +- electron/ipc/nativeBridge.chat.test.ts | 123 +++++++ electron/ipc/nativeBridge.ts | 105 ++++-- .../services/aiEditionService.ts | 4 +- .../ai-edition/LeftPanel.stop.test.tsx | 210 ++++++++++++ src/components/ai-edition/LeftPanel.tsx | 319 ++++++++++++------ src/i18n/locales/ar/editor.json | 6 +- src/i18n/locales/cs/editor.json | 6 +- src/i18n/locales/de/editor.json | 6 +- src/i18n/locales/en/editor.json | 6 +- src/i18n/locales/es/editor.json | 6 +- src/i18n/locales/fr/editor.json | 6 +- src/i18n/locales/it/editor.json | 6 +- src/i18n/locales/ja-JP/editor.json | 6 +- src/i18n/locales/ko-KR/editor.json | 6 +- src/i18n/locales/pt-BR/editor.json | 6 +- src/i18n/locales/ru/editor.json | 6 +- src/i18n/locales/tr/editor.json | 6 +- src/i18n/locales/vi/editor.json | 6 +- src/i18n/locales/zh-CN/editor.json | 6 +- src/i18n/locales/zh-TW/editor.json | 6 +- .../store/agentDocumentStream.test.ts | 80 +++++ .../ai-edition/store/agentDocumentStream.ts | 72 ++++ src/native/browserShim.ts | 2 + src/native/client.ts | 9 +- src/native/contracts.ts | 15 +- .../architecture/ai-agent.md | 33 +- .../testing/manual-e2e-checklist.md | 1 + 36 files changed, 1410 insertions(+), 194 deletions(-) create mode 100644 electron/ai-edition/deep-agent/cancellation.test.ts create mode 100644 electron/ai-edition/deep-agent/cancellation.ts create mode 100644 electron/ipc/chatRuns.test.ts create mode 100644 electron/ipc/chatRuns.ts create mode 100644 electron/ipc/nativeBridge.chat.test.ts create mode 100644 src/components/ai-edition/LeftPanel.stop.test.tsx create mode 100644 src/lib/ai-edition/store/agentDocumentStream.test.ts create mode 100644 src/lib/ai-edition/store/agentDocumentStream.ts diff --git a/electron/ai-edition/chat-persistence.ts b/electron/ai-edition/chat-persistence.ts index 79d6d90792..5a86a69879 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 11719179dc..1d7e42d1ce 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 e056f04b17..3cd1c53f61 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,10 @@ 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, + // Non-streaming callers still receive the completed document. Both this + // fallback and the document event above enforce the edit setting; the + // panel never reapplies this aggregate after individual streamed edits. 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 0000000000..bd47382805 --- /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 0000000000..35f5f7e33a --- /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 00fc819eec..12d41fbcd2 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 0000000000..130b6d56e0 --- /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 8ac4658c79..ec56e955f9 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -5224,8 +5224,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 0000000000..4b4b6ef65b --- /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 46e75ae58a..602d2925be 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 01e880a3ee..553a049ff2 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 0000000000..60e07823ba --- /dev/null +++ b/src/components/ai-edition/LeftPanel.stop.test.tsx @@ -0,0 +1,210 @@ +// @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, 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(), +})); +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: vi.fn() } })); +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 edits and undo steps, retains partial 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 waitFor(() => expect(past).toHaveLength(2)); + 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))); + expect(screen.getByText("chat.stopped")).toBeVisible(); + expect(screen.getByText("Partial")).toBeVisible(); + expect(screen.getByRole("button", { name: "chat.send" })).toBeInTheDocument(); + expect(api.save).toHaveBeenCalledTimes(2); + act(() => { + undo(); + }); + expect(useProjectStore.getState().document?.project.title).toBe("First"); + 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("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))); + 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"); + 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))); + expect(screen.queryByText("Old")).toBeNull(); + 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))); + }); +}); diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 144d0b4c7f..21e43ab9ba 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"; @@ -6,10 +6,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Tooltip } from "@/components/ui/tooltip"; import { useEditorDialogActions, useEditorDialogSection } from "@/contexts/EditorDialogsContext"; import { useScopedT } from "@/contexts/I18nContext"; -import { - applyAgentDocumentIfCurrent, - runAgentTurn, -} from "@/lib/ai-edition/store/agentDocumentApply"; +import { applyAgentDocumentIfCurrent } from "@/lib/ai-edition/store/agentDocumentApply"; +import { AgentDocumentStream } from "@/lib/ai-edition/store/agentDocumentStream"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus"; import { nativeBridgeClient } from "@/native/client"; @@ -32,6 +30,7 @@ import styles from "./NewEditorShell.module.css"; import { useChatBudget } from "./useChatBudget"; interface ChatDisplayMessage { + stopped?: boolean; id?: string; role: string; content: string; @@ -438,6 +437,18 @@ 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; + stream: AgentDocumentStream; + } | 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 +469,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 +508,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 +531,73 @@ 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; + run.stream.close(); + 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 === "document") run.stream.push(event.document); + else 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 +614,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 +637,7 @@ export function ChatStripPanel() { time: m.createdAt, toolCalls: m.toolCalls, checkpointId: m.checkpointId ?? null, + stopped: m.stopped, })), ); } else { @@ -574,6 +647,9 @@ export function ChatStripPanel() { // ponytail: silent — shim mode } })(); + return () => { + disposed = true; + }; }, [projectId, activeSessionId]); useEffect(() => { @@ -582,7 +658,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 +667,30 @@ export function ChatStripPanel() { openProviderSettings(); return; } + const run = { + id: crypto.randomUUID(), + projectId, + sessionId: activeSessionId, + stopping: false, + thinking: "", + stream: new AgentDocumentStream(), + }; + 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 +713,54 @@ 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. - const { result, applyDocument } = await runAgentTurn((documentSnapshot) => - nativeBridgeClient.aiEdition.chatRun(projectId, sessionId, text, documentSnapshot), + if (!isCurrent() || run.stopping) return; + run.sessionId = sessionId; + run.stream = new AgentDocumentStream(); + const documentSnapshot = useProjectStore.getState().document ?? undefined; + const result = await nativeBridgeClient.aiEdition.chatRun( + projectId, + sessionId, + text, + documentSnapshot, + run.id, ); + if (!isCurrent()) return; + // Non-streaming callers keep their existing result path. Completed tool + // edits already in history are never saved again as one aggregate. + if (!run.stream.seen && result.document && !result.cancelled) + run.stream.push(result.document); + await run.stream.finish(); + if (!isCurrent()) return; + if (run.stream.failure === "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 run.stream.applyRemaining().then(() => { + if (run.stream.failure === "save-failed") toast.error(t("chat.applyEditsFailed")); + }); + }, + }, + }); + else if (run.stream.failure === "save-failed") toast.error(t("chat.applyEditsFailed")); 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 +776,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 +796,20 @@ 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); + run.stream.close(); + if (isCurrent()) { + runRef.current = null; + setBusy(false); + setStopping(false); + setThinkingText(""); + setLiveText(""); + setThinkingExpanded(false); + } } }; @@ -772,6 +864,7 @@ export function ChatStripPanel() { time: m.createdAt, toolCalls: m.toolCalls, checkpointId: m.checkpointId ?? null, + stopped: m.stopped, })), ); setInput(result.prompt); @@ -916,6 +1009,7 @@ export function ChatStripPanel() { time: m.createdAt, toolCalls: m.toolCalls, checkpointId: m.checkpointId ?? null, + stopped: m.stopped, })), ); toast.success(t("chat.compactedSuccess")); @@ -1398,6 +1492,11 @@ export function ChatStripPanel() { + {m.stopped ? ( +
+ {t("chat.stopped")} +
+ ) : null} {m.toolCalls?.length ? (
{m.toolCalls.map((call, j) => ( @@ -1420,6 +1519,8 @@ export function ChatStripPanel() {
{t("chat.authorAssistant")}
+ {liveText ?
{liveText}
: null} + {stopping ?
{t("chat.stopping")}
: null} {thinkingText ? (
- + + ) : ( + - void send()} > - - - - - + + + + + + + )}
vi.fn()); +vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: { save } } })); +const document = (title: string, projectId = "p") => createEmptyDocument({ projectId, title }); +beforeEach(() => { + useProjectStore.getState().clear(); + clearHistory(); + useProjectStore.setState({ projectId: "p", document: document("Before"), revision: 1 }); + save.mockReset().mockImplementation(async (doc) => ({ success: true, document: doc })); +}); + +describe("completed agent edits", () => { + it("keeps each applied edit as its existing undo step after stop, without an aggregate resave", async () => { + const stream = new AgentDocumentStream(); + stream.push(document("First")); + stream.push(document("Second")); + await stream.finish(); + stream.push(document("Late")); + expect(save).toHaveBeenCalledTimes(2); + expect(past).toHaveLength(2); + expect(useProjectStore.getState().document?.project.title).toBe("Second"); + expect(undo()).toBe(true); + expect(useProjectStore.getState().document?.project.title).toBe("First"); + expect(undo()).toBe(true); + expect(useProjectStore.getState().document?.project.title).toBe("Before"); + expect(redo()).toBe(true); + expect(useProjectStore.getState().document?.project.title).toBe("First"); + }); + + it("drops queued edits after abandonment and never writes into a switched project", async () => { + const stream = new AgentDocumentStream(); + stream.push(document("Old")); + stream.close(); + useProjectStore.setState({ projectId: "other", document: document("Other", "other") }); + await stream.finish(); + expect(save).not.toHaveBeenCalled(); + expect(useProjectStore.getState().document?.project.title).toBe("Other"); + }); + + it("detects an undo between tool edits instead of overwriting it", async () => { + const stream = new AgentDocumentStream(); + stream.push(document("First")); + await vi.waitFor(() => expect(past).toHaveLength(1)); + undo(); + stream.push(document("Second")); + stream.push(document("Third")); + await stream.finish(); + expect(stream.failure).toBe("conflict"); + expect(save).toHaveBeenCalledOnce(); + expect(useProjectStore.getState().document?.project.title).toBe("Before"); + // The existing Apply anyway action resumes only the unapplied steps. + await stream.applyRemaining(); + expect(save).toHaveBeenCalledTimes(3); + expect(useProjectStore.getState().document?.project.title).toBe("Third"); + expect(undo()).toBe(true); + expect(useProjectStore.getState().document?.project.title).toBe("Second"); + expect(undo()).toBe(true); + expect(useProjectStore.getState().document?.project.title).toBe("Before"); + }); + + it("can start another stream on the stopped turn's applied document", async () => { + const first = new AgentDocumentStream(); + first.push(document("First")); + await first.finish(); + const next = new AgentDocumentStream(); + next.push(document("Next")); + await next.finish(); + expect(next.failure).toBeNull(); + expect(past).toHaveLength(2); + expect(undo()).toBe(true); + expect(useProjectStore.getState().document?.project.title).toBe("First"); + }); +}); diff --git a/src/lib/ai-edition/store/agentDocumentStream.ts b/src/lib/ai-edition/store/agentDocumentStream.ts new file mode 100644 index 0000000000..3626b73181 --- /dev/null +++ b/src/lib/ai-edition/store/agentDocumentStream.ts @@ -0,0 +1,72 @@ +import { type AgentDocumentApplyResult, applyAgentDocumentIfCurrent } from "./agentDocumentApply"; +import { useProjectStore } from "./projectStore"; + +/** Serialises completed tool edits through the existing save/undo boundary. + * Finishing a turn drains these writes; abandoning its panel discards queued edits. + * Never reapply the final aggregate after individual edits have reached history. */ +export class AgentDocumentStream { + private queue: Promise = Promise.resolve(); + private closed = false; + private readonly pending: unknown[] = []; + private revision: number; + private readonly projectId: string | null; + private readonly hasDocument: boolean; + seen = false; + failure: AgentDocumentApplyResult | null = null; + + constructor() { + const state = useProjectStore.getState(); + this.revision = state.revision; + this.projectId = state.projectId; + this.hasDocument = state.document !== null; + } + + push(document: unknown): void { + if (this.closed) return; + this.seen = true; + this.queue = this.queue + .then(async () => { + if (this.closed) return; + if (this.failure) { + this.pending.push(document); + return; + } + if (!this.hasDocument || useProjectStore.getState().projectId !== this.projectId) { + this.failure = "no-live-document"; + return; + } + const applying = applyAgentDocumentIfCurrent(document, this.revision); + // An applied edit bumps once for its optimistic setDocument and once for + // its save. A manual edit/undo during that save remains a conflict for + // the NEXT tool too; don't adopt an unrelated new revision as our own. + const result = await applying; + if (result === "applied") this.revision += 2; + else { + this.failure = result; + this.pending.push(document); + } + }) + .catch(() => { + this.failure = "save-failed"; + }); + } + + /** Explicit user consent to apply the remaining edits over a conflicting revision. */ + async applyRemaining(): Promise { + if (useProjectStore.getState().projectId !== this.projectId) return; + this.revision = useProjectStore.getState().revision; + this.failure = null; + this.closed = false; + for (const document of this.pending.splice(0)) this.push(document); + await this.finish(); + } + + close(): void { + this.closed = true; + } + + async finish(): Promise { + await this.queue; + this.close(); + } +} diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts index 26ed3ca659..d66dbf1abb 100644 --- a/src/native/browserShim.ts +++ b/src/native/browserShim.ts @@ -663,6 +663,8 @@ function createShimBridgeClient() { persistChat(); return Promise.resolve({ success: true, assistantMessage }); }, + chatCancel: (_projectId: string, _sessionId: string, _runId: string) => + Promise.resolve({ accepted: false }), chatUndoLastBatch: () => Promise.resolve({ success: false, diff --git a/src/native/client.ts b/src/native/client.ts index 53d09419be..7790548017 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 521cc993e6..0e72ef45a1 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 b09493e133..a990d5cad0 100644 --- a/technical-documentation/architecture/ai-agent.md +++ b/technical-documentation/architecture/ai-agent.md @@ -76,7 +76,38 @@ 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 stream to `AgentDocumentStream`, which serializes them through +`applyAgentDocumentIfCurrent` and the existing project store save/history boundary. +Each applied tool edit keeps its own undo step. The final aggregate document is not +applied again after streamed edits. + +## 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 closes +its edit queue when abandoned. Window destruction aborts its run in main as well. +Stop stays in a stopping state until the turn replies and already-received completed +edit saves settle. It preserves those edits and their undo entries, 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 70034bc75b..c12c30705a 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -999,4 +999,5 @@ 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 | Partial — Agent Stop passed | 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). | | 2026-10-23 | `fix/mac-mic-silent-with-system-audio` on `main` @ `264975d`, dev build (`npm run dev`), capture helper built locally with Xcode 27 and copied from `out/Products/Release` by hand | macOS 26.6.2 (25G83), MacBook Pro M2 Pro; built-in microphone and a USB Audio CODEC (08bb:2902) | **Partial: fix slice only, pass** | #1173, Apple's picker, system audio on, output device idle (after `sudo killall coreaudiod`, or checked idle). Before the fix: the microphone delivered nothing until another app played a sound, 56 s (built-in) and 14 s (USB) into the take; without system audio it started at 0.17 s. With the tap merely started after the stream, four takes (built-in twice, USB twice) recorded the microphone from 0.17–0.18 s, but only 12–30 ms ahead of the tap. With this branch's version, the tap waiting for the first microphone buffer: one USB take recorded the microphone from 0.18 s and the tap started 27 ms after it; one take with the microphone off. In both, a sound played mid-take was in system audio. Measured with temporary per-source buffer counters in the helper, not shipped. **Not covered:** this branch's version with the built-in microphone, a legacy-picker take, macOS 15, an Intel Mac, a microphone that never delivers (the 1 s fallback), pause before the first microphone buffer, a CI-built artifact. | From 7f46035e48056089669b099f2bcd4ef18b6173af Mon Sep 17 00:00:00 2001 From: wuzixuan06 Date: Fri, 23 Oct 2026 16:58:53 +0800 Subject: [PATCH 2/3] fix: preserve one undo step per agent turn --- electron/ai-edition/chat-service.ts | 5 +- .../ai-edition/LeftPanel.stop.test.tsx | 100 ++++++++++++++++-- src/components/ai-edition/LeftPanel.tsx | 81 +++++++------- .../store/agentDocumentStream.test.ts | 80 -------------- .../ai-edition/store/agentDocumentStream.ts | 72 ------------- 5 files changed, 133 insertions(+), 205 deletions(-) delete mode 100644 src/lib/ai-edition/store/agentDocumentStream.test.ts delete mode 100644 src/lib/ai-edition/store/agentDocumentStream.ts diff --git a/electron/ai-edition/chat-service.ts b/electron/ai-edition/chat-service.ts index 3cd1c53f61..081e254638 100644 --- a/electron/ai-edition/chat-service.ts +++ b/electron/ai-edition/chat-service.ts @@ -523,9 +523,8 @@ export async function runChat( success: true, assistantMessage, cancelled: result.cancelled, - // Non-streaming callers still receive the completed document. Both this - // fallback and the document event above enforce the edit setting; the - // panel never reapplies this aggregate after individual streamed edits. + // 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/src/components/ai-edition/LeftPanel.stop.test.tsx b/src/components/ai-edition/LeftPanel.stop.test.tsx index 60e07823ba..b6dea34b09 100644 --- a/src/components/ai-edition/LeftPanel.stop.test.tsx +++ b/src/components/ai-edition/LeftPanel.stop.test.tsx @@ -6,7 +6,7 @@ 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, undo } from "@/lib/ai-edition/store/undo"; +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"; @@ -19,6 +19,7 @@ const api = vi.hoisted(() => ({ select: vi.fn(), create: vi.fn(), error: vi.fn(), + warning: vi.fn(), })); vi.mock("@/native/client", () => ({ nativeBridgeClient: { @@ -41,7 +42,7 @@ vi.mock("@/contexts/I18nContext", () => ({ useI18n: () => ({ locale: "en" }), useScopedT: () => (key: string) => key, })); -vi.mock("sonner", () => ({ toast: { error: api.error, warning: vi.fn() } })); +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 }); @@ -125,27 +126,39 @@ function emit( } describe("composer Stop", () => { - it("keeps edits and undo steps, retains partial reply, ignores old events and starts the next turn", async () => { + 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 waitFor(() => expect(past).toHaveLength(2)); + 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))); + 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).toHaveBeenCalledTimes(2); + 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("First"); + 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" }); @@ -157,6 +170,70 @@ describe("composer Stop", () => { 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; @@ -184,11 +261,12 @@ describe("composer Stop", () => { 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))); + 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"))); }); @@ -197,14 +275,16 @@ describe("composer Stop", () => { 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))); + 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))); + 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 21e43ab9ba..059abe205d 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -6,8 +6,10 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Tooltip } from "@/components/ui/tooltip"; import { useEditorDialogActions, useEditorDialogSection } from "@/contexts/EditorDialogsContext"; import { useScopedT } from "@/contexts/I18nContext"; -import { applyAgentDocumentIfCurrent } from "@/lib/ai-edition/store/agentDocumentApply"; -import { AgentDocumentStream } from "@/lib/ai-edition/store/agentDocumentStream"; +import { + applyAgentDocumentIfCurrent, + runAgentTurn, +} from "@/lib/ai-edition/store/agentDocumentApply"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus"; import { nativeBridgeClient } from "@/native/client"; @@ -446,7 +448,6 @@ export function ChatStripPanel() { sessionId: string | null; stopping: boolean; thinking: string; - stream: AgentDocumentStream; } | null>(null); const viewRef = useRef({ projectId, sessionId: null as string | null }); const [llmConfig, setLlmConfig] = useState(null); @@ -535,7 +536,6 @@ export function ChatStripPanel() { const run = runRef.current; if (!run) return; runRef.current = null; - run.stream.close(); if (run.sessionId) { void nativeBridgeClient.aiEdition .chatCancel(run.projectId, run.sessionId, run.id) @@ -558,8 +558,7 @@ export function ChatStripPanel() { viewRef.current.sessionId !== run.sessionId ) return; - if (event.kind === "document") run.stream.push(event.document); - else if (event.kind === "thinking") { + if (event.kind === "thinking") { run.thinking += event.delta; setThinkingText(run.thinking); } else if (event.kind === "text") setLiveText((prev) => prev + event.delta); @@ -673,7 +672,6 @@ export function ChatStripPanel() { sessionId: activeSessionId, stopping: false, thinking: "", - stream: new AgentDocumentStream(), }; runRef.current = run; latestRunIdRef.current = run.id; @@ -726,41 +724,45 @@ export function ChatStripPanel() { } if (!isCurrent() || run.stopping) return; run.sessionId = sessionId; - run.stream = new AgentDocumentStream(); - const documentSnapshot = useProjectStore.getState().document ?? undefined; - const result = await nativeBridgeClient.aiEdition.chatRun( - projectId, - sessionId, - text, - documentSnapshot, - run.id, + // 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, run.id), ); if (!isCurrent()) return; - // Non-streaming callers keep their existing result path. Completed tool - // edits already in history are never saved again as one aggregate. - if (!run.stream.seen && result.document && !result.cancelled) - run.stream.push(result.document); - await run.stream.finish(); - if (!isCurrent()) return; - if (run.stream.failure === "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 run.stream.applyRemaining().then(() => { - if (run.stream.failure === "save-failed") toast.error(t("chat.applyEditsFailed")); - }); + 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 }); + }, }, - }, - }); - else if (run.stream.failure === "save-failed") toast.error(t("chat.applyEditsFailed")); + }); + } 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. @@ -801,7 +803,6 @@ export function ChatStripPanel() { description: err instanceof Error ? err.message : String(err), }); } finally { - run.stream.close(); if (isCurrent()) { runRef.current = null; setBusy(false); diff --git a/src/lib/ai-edition/store/agentDocumentStream.test.ts b/src/lib/ai-edition/store/agentDocumentStream.test.ts deleted file mode 100644 index 713c0ce733..0000000000 --- a/src/lib/ai-edition/store/agentDocumentStream.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -// @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createEmptyDocument } from "../schema"; -import { AgentDocumentStream } from "./agentDocumentStream"; -import { useProjectStore } from "./projectStore"; -import { clearHistory, redo, undo } from "./undo"; -import { past } from "./undoStack"; - -const save = vi.hoisted(() => vi.fn()); -vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: { save } } })); -const document = (title: string, projectId = "p") => createEmptyDocument({ projectId, title }); -beforeEach(() => { - useProjectStore.getState().clear(); - clearHistory(); - useProjectStore.setState({ projectId: "p", document: document("Before"), revision: 1 }); - save.mockReset().mockImplementation(async (doc) => ({ success: true, document: doc })); -}); - -describe("completed agent edits", () => { - it("keeps each applied edit as its existing undo step after stop, without an aggregate resave", async () => { - const stream = new AgentDocumentStream(); - stream.push(document("First")); - stream.push(document("Second")); - await stream.finish(); - stream.push(document("Late")); - expect(save).toHaveBeenCalledTimes(2); - expect(past).toHaveLength(2); - expect(useProjectStore.getState().document?.project.title).toBe("Second"); - expect(undo()).toBe(true); - expect(useProjectStore.getState().document?.project.title).toBe("First"); - expect(undo()).toBe(true); - expect(useProjectStore.getState().document?.project.title).toBe("Before"); - expect(redo()).toBe(true); - expect(useProjectStore.getState().document?.project.title).toBe("First"); - }); - - it("drops queued edits after abandonment and never writes into a switched project", async () => { - const stream = new AgentDocumentStream(); - stream.push(document("Old")); - stream.close(); - useProjectStore.setState({ projectId: "other", document: document("Other", "other") }); - await stream.finish(); - expect(save).not.toHaveBeenCalled(); - expect(useProjectStore.getState().document?.project.title).toBe("Other"); - }); - - it("detects an undo between tool edits instead of overwriting it", async () => { - const stream = new AgentDocumentStream(); - stream.push(document("First")); - await vi.waitFor(() => expect(past).toHaveLength(1)); - undo(); - stream.push(document("Second")); - stream.push(document("Third")); - await stream.finish(); - expect(stream.failure).toBe("conflict"); - expect(save).toHaveBeenCalledOnce(); - expect(useProjectStore.getState().document?.project.title).toBe("Before"); - // The existing Apply anyway action resumes only the unapplied steps. - await stream.applyRemaining(); - expect(save).toHaveBeenCalledTimes(3); - expect(useProjectStore.getState().document?.project.title).toBe("Third"); - expect(undo()).toBe(true); - expect(useProjectStore.getState().document?.project.title).toBe("Second"); - expect(undo()).toBe(true); - expect(useProjectStore.getState().document?.project.title).toBe("Before"); - }); - - it("can start another stream on the stopped turn's applied document", async () => { - const first = new AgentDocumentStream(); - first.push(document("First")); - await first.finish(); - const next = new AgentDocumentStream(); - next.push(document("Next")); - await next.finish(); - expect(next.failure).toBeNull(); - expect(past).toHaveLength(2); - expect(undo()).toBe(true); - expect(useProjectStore.getState().document?.project.title).toBe("First"); - }); -}); diff --git a/src/lib/ai-edition/store/agentDocumentStream.ts b/src/lib/ai-edition/store/agentDocumentStream.ts deleted file mode 100644 index 3626b73181..0000000000 --- a/src/lib/ai-edition/store/agentDocumentStream.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { type AgentDocumentApplyResult, applyAgentDocumentIfCurrent } from "./agentDocumentApply"; -import { useProjectStore } from "./projectStore"; - -/** Serialises completed tool edits through the existing save/undo boundary. - * Finishing a turn drains these writes; abandoning its panel discards queued edits. - * Never reapply the final aggregate after individual edits have reached history. */ -export class AgentDocumentStream { - private queue: Promise = Promise.resolve(); - private closed = false; - private readonly pending: unknown[] = []; - private revision: number; - private readonly projectId: string | null; - private readonly hasDocument: boolean; - seen = false; - failure: AgentDocumentApplyResult | null = null; - - constructor() { - const state = useProjectStore.getState(); - this.revision = state.revision; - this.projectId = state.projectId; - this.hasDocument = state.document !== null; - } - - push(document: unknown): void { - if (this.closed) return; - this.seen = true; - this.queue = this.queue - .then(async () => { - if (this.closed) return; - if (this.failure) { - this.pending.push(document); - return; - } - if (!this.hasDocument || useProjectStore.getState().projectId !== this.projectId) { - this.failure = "no-live-document"; - return; - } - const applying = applyAgentDocumentIfCurrent(document, this.revision); - // An applied edit bumps once for its optimistic setDocument and once for - // its save. A manual edit/undo during that save remains a conflict for - // the NEXT tool too; don't adopt an unrelated new revision as our own. - const result = await applying; - if (result === "applied") this.revision += 2; - else { - this.failure = result; - this.pending.push(document); - } - }) - .catch(() => { - this.failure = "save-failed"; - }); - } - - /** Explicit user consent to apply the remaining edits over a conflicting revision. */ - async applyRemaining(): Promise { - if (useProjectStore.getState().projectId !== this.projectId) return; - this.revision = useProjectStore.getState().revision; - this.failure = null; - this.closed = false; - for (const document of this.pending.splice(0)) this.push(document); - await this.finish(); - } - - close(): void { - this.closed = true; - } - - async finish(): Promise { - await this.queue; - this.close(); - } -} From eed3600777207e9f74f7caac62b9277f8acde506 Mon Sep 17 00:00:00 2001 From: wuzixuan06 Date: Fri, 23 Oct 2026 18:49:41 +0800 Subject: [PATCH 3/3] fix: update agent undo docs and keep Stop tooltip accessible --- .../ai-edition/LeftPanel.stop.test.tsx | 16 ++++++++++++++++ src/components/ai-edition/LeftPanel.tsx | 4 ++-- .../architecture/ai-agent.md | 18 ++++++++++-------- .../testing/manual-e2e-checklist.md | 2 +- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/components/ai-edition/LeftPanel.stop.test.tsx b/src/components/ai-edition/LeftPanel.stop.test.tsx index b6dea34b09..bce7ff467a 100644 --- a/src/components/ai-edition/LeftPanel.stop.test.tsx +++ b/src/components/ai-edition/LeftPanel.stop.test.tsx @@ -126,6 +126,22 @@ function emit( } 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(); diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 059abe205d..af893cb7ef 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -1696,8 +1696,8 @@ export function ChatStripPanel() { type="button" className={styles.sendBtn} aria-label={t("chat.stop")} - disabled={stopping} - onClick={() => void stop()} + aria-disabled={stopping || undefined} + onClick={stopping ? undefined : () => void stop()} > diff --git a/technical-documentation/architecture/ai-agent.md b/technical-documentation/architecture/ai-agent.md index a990d5cad0..17fddf1048 100644 --- a/technical-documentation/architecture/ai-agent.md +++ b/technical-documentation/architecture/ai-agent.md @@ -79,10 +79,12 @@ Clips and trims use source time. Zoom, speed, annotation, camera-fullscreen, and 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 stream to `AgentDocumentStream`, which serializes them through -`applyAgentDocumentIfCurrent` and the existing project store save/history boundary. -Each applied tool edit keeps its own undo step. The final aggregate document is not -applied again after streamed edits. +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 @@ -100,10 +102,10 @@ in the background; their results cannot commit edits or emit tool success after 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 closes -its edit queue when abandoned. Window destruction aborts its run in main as well. -Stop stays in a stopping state until the turn replies and already-received completed -edit saves settle. It preserves those edits and their undo entries, partial answer, +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; diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index c12c30705a..62d3ca8555 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -999,5 +999,5 @@ 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 | Partial — Agent Stop passed | 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). | +| 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). | | 2026-10-23 | `fix/mac-mic-silent-with-system-audio` on `main` @ `264975d`, dev build (`npm run dev`), capture helper built locally with Xcode 27 and copied from `out/Products/Release` by hand | macOS 26.6.2 (25G83), MacBook Pro M2 Pro; built-in microphone and a USB Audio CODEC (08bb:2902) | **Partial: fix slice only, pass** | #1173, Apple's picker, system audio on, output device idle (after `sudo killall coreaudiod`, or checked idle). Before the fix: the microphone delivered nothing until another app played a sound, 56 s (built-in) and 14 s (USB) into the take; without system audio it started at 0.17 s. With the tap merely started after the stream, four takes (built-in twice, USB twice) recorded the microphone from 0.17–0.18 s, but only 12–30 ms ahead of the tap. With this branch's version, the tap waiting for the first microphone buffer: one USB take recorded the microphone from 0.18 s and the tap started 27 ms after it; one take with the microphone off. In both, a sound played mid-take was in system audio. Measured with temporary per-source buffer counters in the helper, not shipped. **Not covered:** this branch's version with the built-in microphone, a legacy-picker take, macOS 15, an Intel Mac, a microphone that never delivers (the 1 s fallback), pause before the first microphone buffer, a CI-built artifact. |