great-agent/packages/agent-core/src/use-cases/agent-run-service.test.ts
2026-08-14 16:21:09 +08:00

370 lines
12 KiB
TypeScript

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<string, Conversation>();
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<string, AgentRun>();
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<void>((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,
};
}