* fix(ai): merge tool call status cards
* fix(ai): constrain markdown table overflow
* fix(ai): restore streaming of answer text via TextDelta events
The on_chunk closure was missing live AgentEvent::TextDelta emission for text content, causing the AI answer text to appear all at once after the turn completed. Reasoning deltas were unaffected.
Root cause: commit c30033628 rewrote the on_chunk closure and accidentally dropped the TextDelta emit line while keeping ReasoningDelta intact.
Fix: restore TextDelta emit in on_chunk for incremental streaming, and remove the post-turn full-TextDelta workaround which would duplicate content.
* test(ai): extract chunk_to_events and add streaming event tests
Extract the event-generation logic from the on_chunk closure into a pure function chunk_to_events() to make it testable, and add 6 unit tests covering all chunk content combinations: text-only, reasoning-only, mixed, empty, and edge cases.
This ensures a regression like the one fixed in the previous commit (where TextDelta emission was dropped while ReasoningDelta was kept) would be caught by tests.
202 lines
6.8 KiB
TypeScript
202 lines
6.8 KiB
TypeScript
import { strict as assert } from "node:assert";
|
|
import { test } from "vitest";
|
|
import { buildAiAgentStepItems, toolCallStepKey, upsertAgentStep, type AiAgentStepItem } from "../../apps/desktop/src/lib/aiAgentStepPresentation.ts";
|
|
import type { AiAgentPlan } from "../../apps/desktop/src/lib/aiAgentPlan.ts";
|
|
|
|
test("presents auto-execute agent plans as completed generation, safety, and execution steps", () => {
|
|
const plan: AiAgentPlan = {
|
|
executableSql: "SELECT count(*) FROM users",
|
|
handoffSql: "SELECT count(*) FROM users",
|
|
steps: [
|
|
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
|
{
|
|
kind: "risk_check",
|
|
status: "done",
|
|
action: "auto_execute",
|
|
environment: "non_production",
|
|
category: "read",
|
|
reasons: [],
|
|
},
|
|
{ kind: "execute_sql", status: "pending", sql: "SELECT count(*) FROM users" },
|
|
],
|
|
};
|
|
|
|
assert.deepEqual(buildAiAgentStepItems(plan), [
|
|
{ key: "generated", labelKey: "ai.agentSteps.generated", tone: "success" },
|
|
{
|
|
key: "safe",
|
|
labelKey: "ai.agentSteps.safe",
|
|
titleKey: "ai.agentStepTitles.riskCheck",
|
|
titleParams: { action: "auto_execute", category: "read", environment: "non_production", reasons: "-" },
|
|
tone: "success",
|
|
},
|
|
{ key: "autoExecute", labelKey: "ai.agentSteps.autoExecute", tone: "active" },
|
|
]);
|
|
});
|
|
|
|
test("presents blocked and confirmation plans with warning tones", () => {
|
|
const blocked: AiAgentPlan = {
|
|
handoffSql: "DROP TABLE users",
|
|
steps: [
|
|
{ kind: "generate_sql", status: "done", sql: "DROP TABLE users" },
|
|
{
|
|
kind: "risk_check",
|
|
status: "done",
|
|
action: "block",
|
|
environment: "non_production",
|
|
category: "dangerous",
|
|
reasons: [],
|
|
},
|
|
{ kind: "execute_sql", status: "skipped", reason: "blocked_by_policy" },
|
|
],
|
|
};
|
|
const confirm: AiAgentPlan = {
|
|
handoffSql: "INSERT INTO users(name) VALUES ('a')",
|
|
steps: [
|
|
{ kind: "generate_sql", status: "done", sql: "INSERT INTO users(name) VALUES ('a')" },
|
|
{
|
|
kind: "risk_check",
|
|
status: "done",
|
|
action: "confirm",
|
|
environment: "production",
|
|
category: "low_risk_write",
|
|
reasons: [],
|
|
},
|
|
{ kind: "execute_sql", status: "skipped", reason: "requires_confirmation" },
|
|
],
|
|
};
|
|
|
|
assert.deepEqual(
|
|
buildAiAgentStepItems(blocked).map((item) => [item.labelKey, item.tone, item.titleKey, item.titleParams]),
|
|
[
|
|
["ai.agentSteps.generated", "success", undefined, undefined],
|
|
["ai.agentSteps.blocked", "danger", "ai.agentStepTitles.riskCheck", { action: "block", category: "dangerous", environment: "non_production", reasons: "-" }],
|
|
["ai.agentSteps.skipped", "muted", "ai.agentStepTitles.blocked", undefined],
|
|
],
|
|
);
|
|
assert.deepEqual(
|
|
buildAiAgentStepItems(confirm).map((item) => [item.labelKey, item.tone, item.titleKey, item.titleParams]),
|
|
[
|
|
["ai.agentSteps.generated", "success", undefined, undefined],
|
|
["ai.agentSteps.needsConfirm", "warning", "ai.agentStepTitles.riskCheck", { action: "confirm", category: "low_risk_write", environment: "production", reasons: "-" }],
|
|
["ai.agentSteps.skipped", "muted", "ai.agentStepTitles.requiresConfirmation", undefined],
|
|
],
|
|
);
|
|
});
|
|
|
|
test("includes risk reasons in risk check titles", () => {
|
|
const plan: AiAgentPlan = {
|
|
handoffSql: "INSERT INTO users(name) VALUES ('a'); UPDATE users SET name='b' WHERE id=1",
|
|
steps: [
|
|
{
|
|
kind: "risk_check",
|
|
status: "done",
|
|
action: "confirm",
|
|
environment: "non_production",
|
|
category: "write",
|
|
reasons: ["multi_statement"],
|
|
},
|
|
],
|
|
};
|
|
|
|
assert.deepEqual(buildAiAgentStepItems(plan)[0], {
|
|
key: "needsConfirm",
|
|
labelKey: "ai.agentSteps.needsConfirm",
|
|
titleKey: "ai.agentStepTitles.riskCheck",
|
|
titleParams: {
|
|
action: "confirm",
|
|
category: "write",
|
|
environment: "non_production",
|
|
reasons: "multi_statement",
|
|
},
|
|
tone: "warning",
|
|
});
|
|
});
|
|
|
|
test("presents no-sql and no-intent plans as muted skipped states", () => {
|
|
const noSql: AiAgentPlan = {
|
|
steps: [
|
|
{ kind: "generate_sql", status: "skipped", reason: "no_sql" },
|
|
{ kind: "execute_sql", status: "skipped", reason: "no_sql" },
|
|
],
|
|
};
|
|
const noIntent: AiAgentPlan = {
|
|
steps: [
|
|
{ kind: "generate_sql", status: "done", sql: "SELECT count(*) FROM users" },
|
|
{ kind: "execute_sql", status: "skipped", reason: "no_execution_intent" },
|
|
],
|
|
};
|
|
|
|
assert.deepEqual(
|
|
buildAiAgentStepItems(noSql).map((item) => item.labelKey),
|
|
["ai.agentSteps.noSql", "ai.agentSteps.skipped"],
|
|
);
|
|
assert.deepEqual(
|
|
buildAiAgentStepItems(noIntent).map((item) => item.labelKey),
|
|
["ai.agentSteps.generated", "ai.agentSteps.notRequested"],
|
|
);
|
|
});
|
|
|
|
test("uses stable tool step keys only for trustworthy tool call ids", () => {
|
|
assert.equal(toolCallStepKey("call-1", 1, "tool_call_start"), "tool-call-1");
|
|
assert.equal(toolCallStepKey("call-1", 2, "tool_call_end"), "tool-call-1");
|
|
assert.equal(toolCallStepKey("", 1, "tool_call_start"), "tool-tool_call_start-1");
|
|
assert.equal(toolCallStepKey("cli-tool-call", 2, "tool_call_end"), "tool-tool_call_end-2");
|
|
});
|
|
|
|
test("upserts tool call steps and preserves start args when final result arrives", () => {
|
|
const steps: AiAgentStepItem[] = [
|
|
{ key: "context", labelKey: "ai.agentSteps.contextCompacted", tone: "active" },
|
|
{
|
|
key: "tool-call-1",
|
|
labelKey: "ai.agentSteps.callingTool",
|
|
tone: "active",
|
|
toolName: "list_tables",
|
|
toolArgs: { schema: "public" },
|
|
},
|
|
];
|
|
|
|
upsertAgentStep(steps, {
|
|
key: "tool-call-1",
|
|
labelKey: "ai.agentSteps.toolDone",
|
|
tone: "success",
|
|
toolName: "list_tables",
|
|
toolResult: "users\norders",
|
|
isError: false,
|
|
});
|
|
|
|
assert.deepEqual(steps, [
|
|
{ key: "context", labelKey: "ai.agentSteps.contextCompacted", tone: "active" },
|
|
{
|
|
key: "tool-call-1",
|
|
labelKey: "ai.agentSteps.toolDone",
|
|
tone: "success",
|
|
toolName: "list_tables",
|
|
toolArgs: { schema: "public" },
|
|
toolResult: "users\norders",
|
|
isError: false,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("upserts with splice so array identity and ordering are preserved", () => {
|
|
const steps: AiAgentStepItem[] = [
|
|
{ key: "before", labelKey: "before", tone: "muted" },
|
|
{ key: "tool-call-1", labelKey: "ai.agentSteps.callingTool", tone: "active" },
|
|
{ key: "after", labelKey: "after", tone: "muted" },
|
|
];
|
|
const sameArray = steps;
|
|
|
|
upsertAgentStep(steps, { key: "tool-call-1", labelKey: "ai.agentSteps.toolError", tone: "danger", isError: true });
|
|
|
|
assert.equal(steps, sameArray);
|
|
assert.deepEqual(
|
|
steps.map((step) => [step.key, step.labelKey, step.tone]),
|
|
[
|
|
["before", "before", "muted"],
|
|
["tool-call-1", "ai.agentSteps.toolError", "danger"],
|
|
["after", "after", "muted"],
|
|
],
|
|
);
|
|
});
|