feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
import type { ChatTransport, UIMessageChunk } from "ai";
import type { AgentApiClient } from "@/features/agent";
import { WorkbenchSessionResources } from "./workbench-session-resources";
import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime";
import type { AgentUiMessage } from "./agent-session-message-state";
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
describe("independent conversation runtimes", () => {
it("keeps the original response streaming after selecting another conversation", async () => {
const resources = new WorkbenchSessionResources();
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
const transport: ChatTransport<AgentUiMessage> = {
sendMessages: async () => new ReadableStream({ start(stream) { controller = stream; } }),
reconnectToStream: async () => null,
};
const client = { refreshCredential: vi.fn(async () => {}), submitFrontendActionResult: vi.fn(async () => {}) } as unknown as AgentApiClient;
const first = createAgentRuntime(resources, "project:a", client, transport);
const second = createAgentRuntime(resources, "project:a", client, transport);
resources.activeAgent = first;
first.sessionId = "server-a";
const response = first.chat.sendMessage({ text: "核对城南压力" });
await vi.waitFor(() => expect(controller).toBeDefined());
controller.enqueue({ type: "start", messageId: "answer-a" });
controller.enqueue({ type: "text-start", id: "text-a" });
controller.enqueue({ type: "text-delta", id: "text-a", delta: "第一段" });
await vi.waitFor(() => expect(first.chat.messages.at(-1)?.parts).toEqual(expect.arrayContaining([expect.objectContaining({ text: "第一段" })])));
resources.activeAgent = second;
controller.enqueue({ type: "data-progress", data: { session_id: "another-session", title: "迟到的其他会话事件" } });
expect(runtimeRunning(first)).toBe(true);
controller.enqueue({ type: "data-credential_refresh_required", data: { request_id: "refresh-a", session_id: "server-a" } });
controller.enqueue({ type: "text-delta", id: "text-a", delta: ",后台完成" });
controller.enqueue({ type: "text-end", id: "text-a" });
controller.enqueue({ type: "finish", finishReason: "stop" });
controller.close();
await response;
expect(first.chat.messages.at(-1)?.parts).toEqual(expect.arrayContaining([expect.objectContaining({ text: "第一段,后台完成" })]));
expect(second.chat.messages).toEqual([]);
expect(first.sessionId).toBe("server-a");
expect(client.refreshCredential).toHaveBeenCalledWith("server-a", "refresh-a");
resources.dispose();
});
it("merges overlapping pages and a newer authoritative tail without duplicates", () => {
expect(mergeHistoryMessages([message("a"), message("b", "old")], [message("b", "new"), message("c")]))
.toEqual([message("a"), message("b", "new"), message("c")]);
expect(mergeHistoryMessages([message("c"), message("d")], [message("a"), message("b"), message("c", "new")]))
.toEqual([message("a"), message("b"), message("c", "new"), message("d")]);
});
it("routes background browser actions to a scoped refusal and keeps old message pages on resume", async () => {
const resources = new WorkbenchSessionResources();
const submit = vi.fn(async () => {});
const client = { submitFrontendActionResult: submit,
streamSession: vi.fn(async (_id, options) => {
options.onEvent({ type: "state", sessionId: "a", messages: [message("b", "new"), message("c")], isStreaming: true });
options.onEvent({ type: "frontend_action", sessionId: "a", data: {
version: "frontend-action@1", sessionId: "a", actionId: "action-a", toolCallId: "call-a", name: "locate_features",
params: {}, issuedAt: Date.now(), expiresAt: Date.now() + 15_000,
} });
options.onEvent({ type: "done", sessionId: "a", data: {} });
}),
} as unknown as AgentApiClient;
const transport = {} as ChatTransport<AgentUiMessage>;
const runtime = createAgentRuntime(resources, "project:a", client, transport, {
id: "a", sessionId: "a", title: "压力复核", createdAt: 1, updatedAt: 1, purpose: "ordinary_chat",
isTitleManuallyEdited: false, messages: [message("a"), message("b", "old")],
});
resumeAgentRuntime(resources, runtime);
await vi.waitFor(() => expect(runtime.controller).toBeUndefined());
expect(runtime.chat.messages).toEqual([message("a"), message("b", "new"), message("c")]);
expect(submit).toHaveBeenCalledWith("a", "action-a", expect.objectContaining({
status: "failed", error: expect.objectContaining({ code: "SESSION_NOT_ACTIVE" }),
}));
resources.dispose();
});
});