213 lines
6.2 KiB
TypeScript
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;
|
|
}
|