great-agent/packages/agent-core/src/use-cases/interaction-run.test.ts
2026-08-14 14:54:59 +08:00

213 lines
6.2 KiB
TypeScript

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<string, Conversation>();
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<string, AgentRun>();
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<string, UserInteraction>();
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<RunEvent>) {
const values: RunEvent[] = [];
for await (const event of events) values.push(event);
return values;
}