import { describe, expect, test } from "bun:test"; import type { AgentRun, Conversation, ConversationRepository, ModelPort, RunEvent, RunRepository, } from ".."; import { AgentRunService, ConversationService, type InteractionService, type ProjectService, } from ".."; const projects = { requireAvailable: async () => { throw new Error("unused"); }, } as unknown as ProjectService; const interactions = {} as InteractionService; class MemoryConversationRepository implements ConversationRepository { readonly values = new Map(); async listRecent() { return [...this.values.values()]; } async listByProject(projectId: string) { return [...this.values.values()].filter( (value) => value.projectId === projectId, ); } async deleteByProject(projectId: string) { for (const [id, value] of this.values) if (value.projectId === projectId) this.values.delete(id); } async getById(id: string) { return this.values.get(id) ?? null; } async create(value: Conversation) { this.values.set(value.id, value); } async update(value: Conversation) { this.values.set(value.id, value); } async delete(id: string) { this.values.delete(id); } async appendMessage(id: string, message: Conversation["messages"][number]) { const current = this.values.get(id); if (!current) throw new Error("not found"); const next = { ...current, messages: [...current.messages, message], updatedAt: message.createdAt, }; this.values.set(id, next); return next; } } class MemoryRunRepository implements RunRepository { value: AgentRun | null = null; readonly values = new Map(); readonly events: RunEvent[] = []; async create(run: AgentRun) { this.value = run; this.values.set(run.id, run); } async update(run: AgentRun) { this.value = run; this.values.set(run.id, run); } async getById(id: string) { return this.values.get(id) ?? null; } async findActive() { return ( [...this.values.values()].find( (run) => run.status === "running" || run.status === "waiting_user", ) ?? null ); } async appendEvent(event: RunEvent) { this.events.push(event); } async listEvents(runId: string) { return this.events.filter((event) => event.runId === runId); } async hasActiveForConversations() { return false; } async deleteByConversations() {} } describe("AgentRunService", () => { test("不依赖 HTTP 完成一次流式运行并持久化助手消息", async () => { let nextId = 0; const conversations = new MemoryConversationRepository(); const conversationService = new ConversationService({ conversations, clock: { now: () => new Date("2026-08-12T00:00:00.000Z") }, ids: { create: () => `id_${++nextId}` }, }); const model: ModelPort = { async *stream() { yield { type: "text.delta", delta: "你" }; yield { type: "text.delta", delta: "好" }; yield { type: "response.completed" }; }, }; const runs = new MemoryRunRepository(); const service = new AgentRunService( conversationService, model, runs, { now: () => new Date("2026-08-12T00:00:00.000Z") }, { create: () => `id_${++nextId}` }, projects, interactions, ); const started = await service.start( { kind: "ordinary", message: "开始" }, new AbortController().signal, ); const events: RunEvent[] = []; for await (const event of started.events) events.push(event); expect(events.map((event) => event.type)).toEqual([ "run.started", "message.started", "message.delta", "message.delta", "message.completed", "run.completed", ]); expect(runs.value?.status).toBe("completed"); const conversation = await conversationService.getConversation( started.run.conversationId, ); const assistantMessage = conversation.messages.at(-1); const startedMessageId = events.find( (event) => event.type === "message.started", )?.payload.messageId as string | undefined; expect(conversation.messages[0]?.id).toBe(started.run.triggerMessageId); expect(conversation.messages[0]?.runId).toBe(started.run.id); expect(assistantMessage?.content).toBe("你好"); expect(assistantMessage?.runId).toBe(started.run.id); expect(assistantMessage?.id).toBe(startedMessageId); }); test("模型失败形成稳定失败终态", async () => { const conversations = new MemoryConversationRepository(); let id = 0; const conversationService = new ConversationService({ conversations, clock: { now: () => new Date() }, ids: { create: () => `id_${++id}` }, }); const model: ModelPort = { async *stream() { yield { type: "response.completed" }; throw new Error("secret provider error"); }, }; const runs = new MemoryRunRepository(); const service = new AgentRunService( conversationService, model, runs, { now: () => new Date() }, { create: () => `id_${++id}` }, projects, interactions, ); const started = await service.start( { kind: "ordinary", message: "开始" }, new AbortController().signal, ); const events: RunEvent[] = []; for await (const event of started.events) events.push(event); expect(events.at(-1)?.type).toBe("run.failed"); expect(events.at(-1)?.payload.message).toBe("模型服务暂时不可用"); expect( (await conversationService.getConversation(started.run.conversationId)) .messages[0]?.id, ).toBe(started.run.triggerMessageId); }); test("停止生成会中断模型并保存部分内容和取消终态", async () => { const fixture = createFixture({ async *stream(_request, signal) { yield { type: "text.delta", delta: "已经生成的部分" }; if (!signal.aborted) await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }), ); throw signal.reason; }, }); const started = await fixture.service.start( { kind: "ordinary", message: "开始长任务" }, new AbortController().signal, ); const events: RunEvent[] = []; const consuming = (async () => { for await (const event of started.events) { events.push(event); if (event.type === "message.delta") await fixture.service.cancel(started.run.id); } })(); await consuming; expect(events.at(-1)?.type).toBe("run.cancelled"); expect(fixture.runs.value?.status).toBe("cancelled"); expect( ( await fixture.conversationService.getConversation( started.run.conversationId, ) ).messages.at(-1)?.content, ).toBe("已经生成的部分"); }); test("全局已有活动任务时拒绝创建第二个 Run", async () => { const fixture = createFixture({ async *stream() { yield { type: "text.delta", delta: "完成" }; }, }); const first = await fixture.service.start( { kind: "ordinary", message: "第一个任务" }, new AbortController().signal, ); await expect( fixture.service.start( { kind: "ordinary", message: "第二个任务" }, new AbortController().signal, ), ).rejects.toMatchObject({ code: "RUN_ALREADY_ACTIVE" }); expect(fixture.conversations.values.size).toBe(1); await fixture.service.cancel(first.run.id); for await (const _event of first.events) { // Drain the cancelled execution so it reaches a terminal state. } }); test("失败后重试关联原 Run 且不重复用户消息", async () => { let calls = 0; const fixture = createFixture({ async *stream() { calls += 1; if (calls === 1) throw new Error("temporary"); yield { type: "text.delta", delta: "重试成功" }; }, }); const first = await fixture.service.start( { kind: "ordinary", message: "只保存一次" }, new AbortController().signal, ); for await (const _event of first.events) { // Complete the failed attempt. } const retried = await fixture.service.retry( first.run.id, new AbortController().signal, ); for await (const _event of retried.events) { // Complete the retry. } const conversation = await fixture.conversationService.getConversation( first.run.conversationId, ); expect(retried.run.retryOfRunId).toBe(first.run.id); expect(retried.run.triggerMessageId).toBe(first.run.triggerMessageId); expect( conversation.messages.filter((message) => message.role === "user"), ).toHaveLength(1); expect(conversation.messages.at(-1)?.content).toBe("重试成功"); }); test("执行文件工具后把结果交回模型并继续同一次 Run", async () => { let calls = 0; const fixture = createFixture({ async *stream(request) { calls++; if (calls === 1) { yield { type: "tool.requested", toolCallId: "tool_read", name: "read_text_file", arguments: JSON.stringify({ path: "README.md" }), }; return; } expect(request.continuations?.[0]?.result).toBe("本地文件内容"); yield { type: "text.delta", delta: "已经读取文件" }; yield { type: "response.completed" }; }, }); const workspace = { createAttachments: async () => [], executeTool: async () => "本地文件内容", } as unknown as import("..").WorkspaceService; fixture.service = new AgentRunService( fixture.conversationService, fixture.model, fixture.runs, { now: () => new Date("2026-08-14T00:00:00Z") }, { create: () => `tool_${++fixture.nextId}` }, projects, interactions, workspace, ); const started = await fixture.service.start( { kind: "ordinary", message: "读取说明" }, new AbortController().signal, ); const events: RunEvent[] = []; for await (const event of started.events) events.push(event); expect(events.map((event) => event.type)).toContain("tool.started"); expect(events.map((event) => event.type)).toContain("tool.completed"); expect(events.at(-1)?.type).toBe("run.completed"); }); test("启动恢复把遗留 running 任务转为可重试失败", async () => { const fixture = createFixture({ async *stream() {} }); await fixture.runs.create({ id: "interrupted_run", conversationId: "conversation", triggerMessageId: "message", status: "running", createdAt: "2026-08-14T00:00:00Z", updatedAt: "2026-08-14T00:00:00Z", }); const events = await fixture.service.recoverInterrupted(); expect(events[0]?.type).toBe("run.failed"); expect(events[0]?.payload.code).toBe("RUN_INTERRUPTED"); expect(fixture.runs.value?.status).toBe("failed"); }); }); function createFixture(model: ModelPort) { let id = 0; const conversations = new MemoryConversationRepository(); const runs = new MemoryRunRepository(); const conversationService = new ConversationService({ conversations, clock: { now: () => new Date("2026-08-14T00:00:00Z") }, ids: { create: () => `fixture_${++id}` }, }); const service = new AgentRunService( conversationService, model, runs, { now: () => new Date("2026-08-14T00:00:00Z") }, { create: () => `fixture_${++id}` }, projects, interactions, ); return { service, model, runs, conversations, conversationService, nextId: id, }; }