import { describe, expect, test } from "bun:test"; import type { AgentRun, Conversation, ConversationRepository, InteractionRepository, ModelPort, ProjectService, RunEvent, RunRepository, UserInteraction, } from ".."; import { AgentRunService, ConversationService, InteractionService } from ".."; class Conversations implements ConversationRepository { values = new Map(); async listRecent() { return [...this.values.values()]; } async listByProject(projectId: string) { return [...this.values.values()].filter( (value) => value.projectId === projectId, ); } async getById(id: string) { return this.values.get(id) ?? null; } async create(value: Conversation) { this.values.set(value.id, value); } 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; } async deleteByProject() {} } class Runs implements RunRepository { values = new Map(); events: RunEvent[] = []; async create(value: AgentRun) { this.values.set(value.id, value); } async update(value: AgentRun) { this.values.set(value.id, value); } async getById(id: string) { return this.values.get(id) ?? null; } async appendEvent(event: RunEvent) { this.events.push(event); } async listEvents(id: string) { return this.events.filter((event) => event.runId === id); } async hasActiveForConversations() { return false; } async deleteByConversations() {} } class Interactions implements InteractionRepository { values = new Map(); async create(value: UserInteraction) { this.values.set(value.id, value); } async update(value: UserInteraction) { this.values.set(value.id, value); } async getById(id: string) { return this.values.get(id) ?? null; } async listByConversation(id: string) { return [...this.values.values()].filter( (value) => value.conversationId === id, ); } async findPendingByRun(id: string) { return ( [...this.values.values()].find( (value) => value.runId === id && value.status === "pending", ) ?? null ); } } describe("等待用户的 Run", () => { test("回答交互后使用同一个 Run 和 toolCallId 继续生成", async () => { const fixture = createFixture(); const started = await fixture.service.start( { kind: "ordinary", message: "请让我选择" }, new AbortController().signal, ); const firstEvents = await collect(started.events); expect(firstEvents.at(-1)?.type).toBe("interaction.requested"); expect(fixture.runs.values.get(started.run.id)?.status).toBe( "waiting_user", ); const interaction = [...fixture.interactions.values.values()][0]; expect(interaction?.status).toBe("pending"); if (!interaction) throw new Error("interaction missing"); const resolved = await fixture.interactionService.resolve(interaction.id, { kind: "choice", selectedOptionIds: ["simple"], }); const resumed = await fixture.service.resume( resolved.interaction, new AbortController().signal, ); const nextEvents = await collect(resumed.events); expect(resumed.run.id).toBe(started.run.id); expect(nextEvents[0]?.type).toBe("interaction.resolved"); expect(nextEvents.at(-1)?.type).toBe("run.completed"); expect( new Set(fixture.runs.events.map((event) => event.sequence)).size, ).toBe(fixture.runs.events.length); expect( ( await fixture.conversationService.getConversation( started.run.conversationId, ) ).messages.at(-1)?.content, ).toBe("已按你的选择继续完成"); }); test("等待回答时可以取消,卡片和 Run 都进入取消终态", async () => { const fixture = createFixture(); const started = await fixture.service.start( { kind: "ordinary", message: "请让我选择" }, new AbortController().signal, ); await collect(started.events); const events = await fixture.service.cancelWaiting(started.run.id); expect(events.map((event) => event.type)).toEqual([ "interaction.cancelled", "run.cancelled", ]); expect(fixture.runs.values.get(started.run.id)?.status).toBe("cancelled"); expect([...fixture.interactions.values.values()][0]?.status).toBe( "cancelled", ); }); }); function createFixture() { let id = 0; const clock = { now: () => new Date("2026-08-14T00:00:00Z") }; const ids = { create: () => `id_${++id}` }; const conversations = new Conversations(); const runs = new Runs(); const interactions = new Interactions(); const conversationService = new ConversationService({ conversations, clock, ids, }); const interactionService = new InteractionService({ interactions, clock, ids, }); const model: ModelPort = { async *stream(request) { if (!request.continuation) { yield { type: "tool.requested", toolCallId: "tool_choice", name: "request_user_interaction", arguments: JSON.stringify({ kind: "single_choice", question: "选择实现方式", options: [ { id: "simple", label: "简化" }, { id: "full", label: "完整" }, ], }), }; } else { expect(request.continuation.toolCallId).toBe("tool_choice"); expect(request.continuation.result).toContain("simple"); yield { type: "text.delta", delta: "已按你的选择继续完成" }; yield { type: "response.completed" }; } }, }; const service = new AgentRunService( conversationService, model, runs, clock, ids, {} as ProjectService, interactionService, ); return { service, runs, interactions, conversationService, interactionService, }; } async function collect(events: AsyncIterable) { const values: RunEvent[] = []; for await (const event of events) values.push(event); return values; }