feat(canvas): 云端 Agent 交互 - 展示错误动作并支持连接器与提示词预填
This commit is contained in:
@@ -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");
|
||||
|
||||
97
web/test/agent-error-events.test.ts
Normal file
97
web/test/agent-error-events.test.ts
Normal 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);
|
||||
});
|
||||
38
web/test/agent-error-message.test.tsx
Normal file
38
web/test/agent-error-message.test.tsx
Normal 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("余额不足 <script>bad()</script>");
|
||||
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("重试中");
|
||||
});
|
||||
20
web/test/agent-prompt-prefill.test.ts
Normal file
20
web/test/agent-prompt-prefill.test.ts
Normal 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]");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user