Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions electron/ai-edition/chat-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
75 changes: 75 additions & 0 deletions electron/ai-edition/chat-service.toolloop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
37 changes: 27 additions & 10 deletions electron/ai-edition/chat-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -334,6 +336,7 @@ const NOOP_SINK: Required<ChatEventSink> = {
toolStart: noop,
toolEnd: noop,
error: noop,
document: noop,
};

export async function runChat(
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
};
Expand All @@ -503,11 +522,9 @@ export async function runChat(
return {
success: true,
assistantMessage,
// ponytail: belt and braces on `editsAllowed`. This returned document is
// the ONLY path to disk (ChatStripPanel → applyAgentDocument → saveDocument),
// so it is where a write that somehow escaped the executor's guard would
// still land. Cheap, and it makes the setting's guarantee structural
// rather than dependent on one predicate holding everywhere.
cancelled: result.cancelled,
// Return the accumulated edits even on Stop. The panel applies this once
// at the end of the turn, preserving one save and one undo step per turn.
document: result.mutated && editsAllowed ? result.document : undefined,
toolCalls: appliedToolCalls.length ? appliedToolCalls : undefined,
userMessageCheckpointId: userMessage.checkpointId ?? undefined,
Expand Down
Loading
Loading