feat(canvas): 云端 Agent 交互 - 展示错误动作并支持连接器与提示词预填

This commit is contained in:
ddcat666
2026-10-04 02:22:42 +08:00
parent e70430e3e2
commit 8a08663d99
19 changed files with 779 additions and 94 deletions

View File

@@ -4,38 +4,50 @@ import { contextInputTokens, contextPressureRatio, emptyAgentContextUsage, prese
const event = (runId: string, type: string, payload: Record<string, unknown>, seq = 1) => ({ runId, type, payload, seq });
describe("Agent context usage events", () => {
it("keeps provider's previous measurement separate from projected next request", () => {
it("uses only Pi SDK readings and never reads projected/provider fields", () => {
const state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", {
modelLimitConfigured: true,
usableInputTokens: 80_000,
contextWindowTokens: 128_000,
pressureTokens: 40_000,
projectedNextInputTokens: 48_000,
projectedPressureRatio: 0.6,
estimatedInputTokens: 48_000,
pressureRatio: 0.6,
tokenSource: "provider",
}));
expect(contextInputTokens(state.reading)).toBe(48_000);
expect(contextPressureRatio(state.reading)).toBe(0.6);
expect(state.reading).toBeNull();
expect(contextInputTokens({ tokenSource: "provider", estimatedInputTokens: 48_000, projectedNextInputTokens: 99_000 })).toBeUndefined();
expect(contextPressureRatio({ tokenSource: "provider", contextWindowTokens: 128_000, projectedPressureRatio: 0.6 })).toBeUndefined();
const piReading = {
modelLimitConfigured: true,
contextWindowTokens: 128_000,
estimatedInputTokens: 48_000,
pressureRatio: 0.6,
tokenSource: "pi",
projectedNextInputTokens: 99_000,
projectedPressureRatio: 0.99,
};
expect(contextInputTokens(piReading)).toBe(48_000);
expect(contextPressureRatio(piReading)).toBe(0.6);
});
it("does not invent percentages when the model window is unknown", () => {
const state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", {
estimatedInputTokens: 12_000,
modelLimitConfigured: false,
tokenSource: "pi",
}));
expect(contextInputTokens(state.reading)).toBe(12_000);
expect(contextPressureRatio(state.reading)).toBeUndefined();
});
it("marks pre-compaction readings stale until a fresh pressure event arrives", () => {
let state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { pressureRatio: 0.8, modelLimitConfigured: true, usableInputTokens: 100, contextWindowTokens: 100 }, 2));
let state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", { tokenSource: "pi", pressureRatio: 0.8, modelLimitConfigured: true, usableInputTokens: 100, contextWindowTokens: 100 }, 2));
state = reduceAgentContextUsage(state, event("run-1", "context_compaction_requested", { basis: "tokens" }, 3));
expect(state.compactionPending).toEqual({ basis: "tokens" });
state = reduceAgentContextUsage(state, event("run-1", "context_compacted", { mode: "checkpoint", resume: true }, 4));
expect(state.readingStale).toBe(true);
expect(state.compactionPending).toBeNull();
expect(state.lastCompaction).toEqual({ mode: "checkpoint", resume: true });
state = reduceAgentContextUsage(state, event("run-1", "context_pressure", { pressureRatio: 0.25, modelLimitConfigured: true, usableInputTokens: 100, contextWindowTokens: 100 }, 5));
state = reduceAgentContextUsage(state, event("run-1", "context_pressure", { tokenSource: "pi", pressureRatio: 0.25, modelLimitConfigured: true, usableInputTokens: 100, contextWindowTokens: 100 }, 5));
expect(state.readingStale).toBe(false);
expect(contextPressureRatio(state.reading)).toBe(0.25);
});
@@ -43,12 +55,12 @@ describe("Agent context usage events", () => {
it("fills the ring against the model context window and keeps the compaction line as a marker", () => {
const state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", {
modelLimitConfigured: true,
tokenSource: "provider",
tokenSource: "pi",
usableInputTokens: 100_000,
compactAtTokens: 85_000,
contextWindowTokens: 128_000,
projectedNextInputTokens: 42_500,
projectedPressureRatio: 0.425,
estimatedInputTokens: 42_500,
pressureRatio: 0.425,
breakdown: { buckets: [
{ key: "system", label: "系统提示(含画布摘要)", tokens: 8_000, scaledTokens: 9_000, bytes: 12_000 },
{ key: "tools", tokens: 4_000, scaledTokens: 4_500, bytes: 18_000 },
@@ -75,6 +87,7 @@ describe("Agent context usage events", () => {
compactAtTokens: 85,
estimatedInputTokens: 90,
pressureRatio: 0.9,
tokenSource: "pi",
}));
expect(presentAgentContextUsage(state).phase).toBe("compress");
expect(presentAgentContextUsage(state).ring).toBe(0.9);
@@ -89,6 +102,7 @@ describe("Agent context usage events", () => {
const state = reduceAgentContextUsage(emptyAgentContextUsage("run-1"), event("run-1", "context_pressure", {
estimatedInputTokens: 12_000,
modelLimitConfigured: false,
tokenSource: "pi",
}));
const view = presentAgentContextUsage(state);
expect(view.phase).toBe("unknown");

View File

@@ -0,0 +1,97 @@
import { expect, test } from "bun:test";
import type { CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
import { applyAgentEvent } from "@/components/canvas/canvas-cloud-agent-events";
import { buildAgentFeedSegments } from "@/lib/canvas/agent-operation-feed";
import type { AgentEvent } from "@/services/api/agent";
const failureMessage = "Agent 执行中断,请重新发送消息;如反复出现,请把诊断号 ag-test-run 反馈给管理员";
const event = (type: string, payload: AgentEvent["payload"] = {}, runId = "ag-test-run"): AgentEvent => ({
eventId: `${runId}:${type}`,
runId,
seq: 1,
type,
payload,
createdAt: "2026-10-03T00:00:00Z",
});
const failed = event("run_failed", { error: "internal runner error" });
const status = event("run_status", { status: "failed", failureMessage });
function reduceEvents(events: AgentEvent[], initial: CloudAgentChatMessage[] = []) {
let messages = initial;
for (const item of events) {
applyAgentEvent(
item,
(update) => {
messages = typeof update === "function" ? update(messages) : update;
},
() => {},
() => {},
);
}
return messages;
}
test("failure event and terminal snapshot update a single error with the public reason", () => {
const messages = reduceEvents([failed, status]);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({ id: "terminal-ag-test-run", role: "error", text: failureMessage, errorSeverity: "error" });
expect(JSON.stringify(messages)).not.toContain("internal runner error");
});
test("reversed delivery, interleaved messages and replay retain the detailed failure", () => {
const assistant = event("assistant_message", { messageId: "answer", text: "已有执行结果" });
const messages = reduceEvents([status, assistant, failed, status, failed]);
expect(messages.map((item) => item.role)).toEqual(["error", "assistant"]);
expect(messages[0].text).toBe(failureMessage);
});
test("separate failed runs are not deduplicated by identical text", () => {
const messages = reduceEvents([event("run_failed", { text: "配额不足" }, "run-1"), event("run_failed", { text: "配额不足" }, "run-2")]);
expect(messages).toHaveLength(2);
expect(messages[0].id).not.toBe(messages[1].id);
});
test("a failed snapshot without a reason still appears but never replaces a specific reason", () => {
const emptyStatus = event("run_status", { status: "failed" });
expect(reduceEvents([emptyStatus])).toHaveLength(1);
const specific = event("run_failed", { text: "模型额度不足" });
expect(reduceEvents([emptyStatus, specific, emptyStatus])[0].text).toBe("模型额度不足");
});
test("replaying a cached failure replaces its event row instead of preserving duplicates", () => {
const initial: CloudAgentChatMessage[] = [
{ id: failed.eventId, role: "error", title: "Agent 执行失败", text: "Agent 执行失败" },
{ id: "terminal-ag-test-run", role: "error", title: "Agent 执行失败", text: failureMessage },
{ id: "sync-error", role: "error", title: "画布同步冲突", text: "请重新打开画布" },
];
const messages = reduceEvents([failed, status], initial);
expect(messages).toHaveLength(2);
expect(messages[0].text).toBe(failureMessage);
expect(messages[1]).toBe(initial[2]);
expect(initial).toHaveLength(3);
});
test("error events share the run failure identity without hiding tool failures", () => {
const tool = event("tool_failed", { toolName: "canvas_get_state", text: "读取失败" });
const messages = reduceEvents([event("error", { message: "暂时无法执行" }), tool, failed, status]);
expect(messages.filter((item) => item.role === "error")).toHaveLength(1);
expect(messages.filter((item) => item.role === "tool")).toHaveLength(1);
});
test("completed, cancelled and rejected runs do not create a failure card", () => {
expect(reduceEvents(["completed", "cancelled", "rejected"].map((state) => event("run_status", { status: state })))).toEqual([]);
});
test("cached conversations fold only the event rows with a matching terminal run", () => {
const initial: CloudAgentChatMessage[] = [
{ id: "ag-first:9", role: "error", text: "Agent 执行失败" },
{ id: "tool", role: "tool", text: "已有执行记录" },
{ id: "terminal-ag-first", role: "error", text: "模型调用失败" },
{ id: "ag-second:2", role: "error", text: "模型调用失败" },
{ id: "terminal-ag-third", role: "error", text: "模型调用失败" },
{ id: "stream-error-ag-first", role: "error", text: "事件流已断开" },
];
const segments = buildAgentFeedSegments(initial);
expect(segments.map((item) => item.key)).toEqual(["tool", "terminal-ag-first", "ag-second:2", "terminal-ag-third", "stream-error-ag-first"]);
expect(initial).toHaveLength(6);
});

View File

@@ -0,0 +1,38 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { AgentChatMessage, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
import { canvasThemes } from "@/lib/canvas-theme";
const error: CloudAgentChatMessage = { id: "error", role: "error", title: "Agent 执行失败", text: "Agent 执行失败" };
test("generic failure renders its title only once in both canvas themes", () => {
for (const theme of [canvasThemes.light, canvasThemes.dark]) {
const html = renderToStaticMarkup(<AgentChatMessage item={error} theme={theme} />);
expect(html.match(/Agent 执行失败/g)).toHaveLength(1);
expect(html).not.toContain("agent-error-detail");
expect(html).toContain('role="status"');
}
});
test("diagnostic identifiers are preserved in a closed disclosure, not the main description", () => {
const html = renderToStaticMarkup(<AgentChatMessage item={{ ...error, text: "Agent 执行中断,请重新发送消息;如反复出现,请把诊断号 ag-test-run 反馈给管理员" }} theme={canvasThemes.dark} />);
expect(html).toContain("Agent 执行中断,请重新发送消息</p>");
expect(html).toContain("诊断信息");
expect(html).toContain("<details");
expect(html).not.toContain("open=");
expect(html).toContain("<code>ag-test-run</code>");
});
test("specific business failures and recovery guidance remain visible and are escaped", () => {
const html = renderToStaticMarkup(<AgentChatMessage item={{ ...error, text: "余额不足 <script>bad()</script>", meta: "请联系管理员检查配额" }} theme={canvasThemes.light} />);
expect(html).toContain("余额不足 &lt;script&gt;bad()&lt;/script&gt;");
expect(html).toContain("请联系管理员检查配额");
expect(html).not.toContain("<details");
});
test("retry stays disabled while retrying and warnings retain a distinct tone", () => {
const html = renderToStaticMarkup(<AgentChatMessage item={{ ...error, errorSeverity: "warning" }} theme={canvasThemes.dark} onRetry={() => {}} retrying />);
expect(html).toContain("agent-status-message--warning");
expect(html).toContain("disabled=");
expect(html).toContain("重试中");
});

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "bun:test";
import { appendAgentPromptPrefill } from "@/lib/canvas/agent-prompt-prefill";
describe("Agent prompt node prefill", () => {
it("preserves a long composer draft and appends the node reference with space separator", () => {
const draft = "请继续按下面的拍摄方案调整画面。".repeat(20).trimEnd();
const reference = "@[node:image-reference-123] ";
expect(appendAgentPromptPrefill(draft, reference)).toBe(`${draft} @[node:image-reference-123]`);
});
it("uses the prefill directly when the composer is empty", () => {
expect(appendAgentPromptPrefill("", "@[node:text-reference-123] ")).toBe("@[node:text-reference-123] ");
});
it("appends with single space separator, no forced line breaks", () => {
expect(appendAgentPromptPrefill("已有要求", "@[node:text-reference-123] ")).toBe("已有要求 @[node:text-reference-123]");
expect(appendAgentPromptPrefill("已有要求 ", "@[node:text-reference-123] ")).toBe("已有要求 @[node:text-reference-123]");
});
});