370 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|