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
165 changes: 165 additions & 0 deletions electron/ai-edition/chat-execution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { once } from "node:events";
import type { StructuredToolInterface } from "@langchain/core/tools";
import { describe, expect, it, vi } from "vitest";
import { createEmptyDocument } from "../../src/lib/ai-edition/schema";
import {
createExecutionTracker,
executionSummary,
executionText,
toolExecutionStep,
} from "./chat-execution";
import { buildTools } from "./deep-agent/service";

describe("execution metadata", () => {
it("keeps reasoning and repeated/concurrent invocation identities in observed order", () => {
const tracker = createExecutionTracker(vi.fn());
tracker.thinking("**Read** the transcript");
const first = toolExecutionStep("getTranscript", {});
const second = toolExecutionStep("getTranscript", {});
tracker.tool(first);
tracker.tool(second);
tracker.tool({ ...second, status: "failed", summary: "No transcript" });
tracker.tool({ ...first, status: "succeeded" });
tracker.thinking("Try another source");
const steps = tracker.finish(false);
expect(steps.map((step) => step.kind)).toEqual(["reasoning", "tool", "tool", "reasoning"]);
expect(steps[1]).toMatchObject({ id: first.id, status: "succeeded" });
expect(steps[2]).toMatchObject({ id: second.id, status: "failed" });
expect(first.id).not.toBe(second.id);
});
it("keeps sensitive arbitrary strings out of argument and result metadata", () => {
const step = toolExecutionStep("addAnnotation", {
apiKey: "private",
password: 12345,
text: "private",
assetId: "asset_1",
startSec: 2,
words: ["private"],
});
expect(step.arguments).toBe("assetId: asset_1, startSec: 2, words: 1");
expect(executionText("Bearer private apiKey=private sk-abcdefghijklmnop")).not.toContain(
"private",
);
expect(
executionSummary({
ok: true,
resultJson: JSON.stringify({ raw: "private", words: ["private"], count: 1 }),
}),
).toBe("words: 1, count: 1");
expect(executionSummary({ ok: false, resultJson: '{"error":"No transcript"}' })).toBe(
"No transcript",
);
expect(executionText("x".repeat(2000))).toHaveLength(1000);
});
it("uses the real executor verdict and pairs each repeated call by id", async () => {
const events = vi.fn();
const holder = { current: createEmptyDocument({ projectId: "steps", title: "Test" }) };
const tools = buildTools(
holder,
{
executionStep: events,
toolStart: vi.fn(),
toolEnd: vi.fn(),
text: vi.fn(),
thinking: vi.fn(),
error: vi.fn(),
},
true,
);
const tool = tools.find((entry) => entry.name === "getTranscript")! as StructuredToolInterface;
await tool.invoke({ assetId: "missing" });
await tool.invoke({ assetId: "missing" });
const steps = events.mock.calls.map(([step]) => step);
expect(steps).toHaveLength(4);
expect(steps[1]).toMatchObject({
id: steps[0].id,
status: "failed",
summary: expect.stringMatching(/asset|transcript/i),
durationMs: expect.any(Number),
});
expect(steps[3]).toMatchObject({ id: steps[2].id, status: "failed" });
expect(steps[0].id).not.toBe(steps[2].id);
});
});

it("reports repeated real SDK calls and schema/unknown-tool failures once per invocation", async () => {
const { createServer } = await import("node:http");

const { invokeOpenScreenAgent } = await import("./deep-agent/service");
const steps = new Map<
string,
import("../../src/lib/ai-edition/chatExecution").ToolExecutionStep
>();
let requests = 0;
const server = createServer(async (req, res) => {
for await (const _ of req) {
/* consume local fixture request */
}
res.writeHead(200, { "Content-Type": "text/event-stream" });
const first = ++requests === 1;
const delta = first
? {
role: "assistant",
tool_calls: [
"getCurrentDocument",
"getCurrentDocument",
"getTranscript",
"nonexistentTool",
].map((name, index) => ({
index,
id: `call_${index}`,
type: "function",
function: {
name,
arguments: JSON.stringify(name === "getTranscript" ? { assetId: 123 } : {}),
},
})),
}
: { role: "assistant", content: "Done" };
for (const [value, finish] of [
[delta, null],
[{}, first ? "tool_calls" : "stop"],
])
res.write(
`data: ${JSON.stringify({ id: "chat_fixture", object: "chat.completion.chunk", created: 1, model: "fixture", choices: [{ index: 0, delta: value, finish_reason: finish }] })}\n\n`,
);
res.end("data: [DONE]\n\n");
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing local port");
try {
const result = await invokeOpenScreenAgent({
document: createEmptyDocument({ projectId: "sdk_steps", title: "Test" }),
model: {
provider: "openai-compatible",
model: "fixture",
apiKey: "local-only",
baseUrl: `http://127.0.0.1:${address.port}/v1`,
maxRetries: 0,
},
history: [],
userMessage: "Inspect",
sink: {
executionStep: (step) => steps.set(step.id, step),
text: vi.fn(),
thinking: vi.fn(),
toolStart: vi.fn(),
toolEnd: vi.fn(),
error: vi.fn(),
},
});
expect(result.text).toBe("Done");
expect([...steps.values()].map((step) => [step.name, step.status])).toEqual([
["getCurrentDocument", "succeeded"],
["getCurrentDocument", "succeeded"],
["getTranscript", "failed"],
["nonexistentTool", "failed"],
]);
expect([...steps.values()][2].summary).toMatch(/schema|input|assetId/i);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
150 changes: 150 additions & 0 deletions electron/ai-edition/chat-execution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { randomUUID } from "node:crypto";
import {
type ChatExecutionStep,
type ToolExecutionStep,
updateExecutionStep,
} from "../../src/lib/ai-edition/chatExecution";

/** Keep display metadata bounded; raw provider payloads and tool results never enter the sidecar. */
export function executionText(value: string, limit = 1000): string {
return value
.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
.replace(/\b(?:sk-|gh[pousr]_)[A-Za-z0-9_-]{8,}/g, "[redacted]")
.replace(
/\b(?:api[_-]?key|authorization|password|secret|token)\s*[=:]\s*[^\s,;]+/gi,
"[redacted]",
)
.slice(0, limit);
}

export function toolExecutionStep(name: string, args: unknown): ToolExecutionStep {
const summaries: string[] = [];
if (args && typeof args === "object") {
// Describe location and scale, not arbitrary text, headers, paths or credentials.
for (const [key, value] of Object.entries(args)) {
if (/api.?key|authorization|password|secret|token/i.test(key)) continue;
if (Array.isArray(value)) summaries.push(`${key}: ${value.length}`);
else if (typeof value === "number" || typeof value === "boolean")
summaries.push(`${key}: ${value}`);
else if (["assetId", "clipId", "id"].includes(key) && typeof value === "string")
summaries.push(`${key}: ${value}`);
}
}
return {
id: randomUUID(),
kind: "tool",
name: executionText(name, 100),
arguments: executionText(summaries.join(", ")),
status: "running",
startedAt: Date.now(),
};
}

export function createExecutionTracker(emit: (step: ChatExecutionStep) => void) {
let steps: ChatExecutionStep[] = [];
let reasoningId: string | undefined;
const update = (step: ChatExecutionStep) => {
steps = updateExecutionStep(steps, step);
emit(step);
};
return {
tool(step: ToolExecutionStep) {
reasoningId = undefined;
update(step);
},
thinking(delta: string) {
const previous = steps.find((step) => step.id === reasoningId);
const text = previous?.kind === "reasoning" ? previous.text : "";
const step: ChatExecutionStep = {
id: reasoningId ?? randomUUID(),
kind: "reasoning",
startedAt: previous?.startedAt ?? Date.now(),
text: executionText(text + delta, 32768),
};
reasoningId = step.id;
update(step);
},
finish(stopped: boolean) {
for (const step of steps) {
if (step.kind === "tool" && step.status === "running")
update({
...step,
status: stopped ? "stopped" : "failed",
durationMs: Math.max(0, Date.now() - step.startedAt),
});
}
return steps;
},
snapshot: () => steps,
};
}

export function executionSummary(result: {
ok: boolean;
summary?: string;
resultJson: string;
}): string {
if (result.summary) return executionText(result.summary);
try {
const data: unknown = JSON.parse(result.resultJson);
if (data && typeof data === "object") {
if (!result.ok && "error" in data && typeof data.error === "string")
return executionText(data.error);
const counts = Object.entries(data)
.filter(
([key, value]) =>
/^[a-zA-Z][a-zA-Z0-9]*$/.test(key) &&
!/api.?key|authorization|password|secret|token/i.test(key) &&
(Array.isArray(value) || typeof value === "number" || typeof value === "boolean"),
)
.slice(0, 8)
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.length : value}`);
return executionText(counts.join(", "));
}
} catch {
/* Raw tool results are intentionally never displayed or persisted. */
}
return "";
}

/** Reconcile announced calls with executor verdicts, including calls rejected before execution. */
export function createToolExecutionObserver(emit: (step: ToolExecutionStep) => void) {
const calls = new Map<string, ToolExecutionStep>();
const begin = (callId: string, name: string, args: unknown) => {
const existing = calls.get(callId);
if (existing) return existing;
const step = toolExecutionStep(name, args);
calls.set(callId, step);
emit(step);
return step;
};
return {
reset() {
calls.clear();
},
begin,
publish(step: ToolExecutionStep) {
const previous = calls.get(step.id) ?? begin(step.id, step.name, {});
const next = {
...step,
id: previous.id,
startedAt: previous.startedAt,
arguments: step.arguments || previous.arguments,
};
calls.set(step.id, next);
emit(next);
},
result(callId: string, content: string) {
const previous = calls.get(callId);
if (!previous || previous.status !== "running") return;
const step: ToolExecutionStep = {
...previous,
status: "failed",
summary: executionText(content),
durationMs: Math.max(0, Date.now() - previous.startedAt),
};
calls.set(callId, step);
emit(step);
},
};
}
7 changes: 7 additions & 0 deletions electron/ai-edition/chat-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ import { existsSync, readFileSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import {
executionStepSchema,
restoredExecutionSteps,
} from "../../src/lib/ai-edition/chatExecution";
import { renameWithRetry } from "../atomic-rename";

const toolCallSchema = z.object({ name: z.string(), summary: z.string() });
const messageSchema = z.object({
executionSteps: z.array(executionStepSchema).optional().catch(undefined),
id: z.string().min(1),
role: z.enum(["user", "assistant"]),
content: z.string(),
Expand Down Expand Up @@ -66,6 +71,8 @@ export class ChatPersistence {
if (session.compaction && session.compaction.coveredCount > session.messages.length)
continue;
seen.add(session.id);
for (const message of session.messages)
message.executionSteps = restoredExecutionSteps(message.executionSteps);
sessions.push(session);
}
return sessions;
Expand Down
Loading
Loading