fix(hub): resume Claude SDK sessions

This commit is contained in:
2026-07-08 11:48:20 +08:00
parent 012c8a7d89
commit c73b41e1da
5 changed files with 108 additions and 50 deletions
@@ -29,6 +29,12 @@ conversion. The SDK's `query()` owns the agent loop (tool dispatch, compaction,
streaming). Built-in tools (Read, Write, Bash, Glob, Grep) cover the entire
tool surface — no custom tools needed. `cph check` / `cph build` run via Bash.
The Hub `AgentSession` remains provider/model-bound, but it must persist the
provider runtime cursor needed to continue a conversation. For Claude Code SDK,
that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
`claudeSessionId` and pass it back to the next `query()` call as
`options.resume`.
Environment variables:
```
ANTHROPIC_BASE_URL=https://openrouter.ai/api
+7 -6
View File
@@ -3,9 +3,9 @@
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
// legacy teaching-material-host-service schema, each deliberate:
//
// - No AgentSession.claudeSessionId. ADR-0017: session is provider/model-
// bound, not Claude-bound; `provider`+`model` replace it. Switching model
// = new session, so the unique constraint is on the session id alone.
// - AgentSession is provider/model-bound. Provider runtime cursors such as
// Claude SDK `session_id` live in `metadata`, so switching model still means
// a new Hub session while same-session runs can resume provider context.
// - PermissionRole enum = read/edit/manage (ADR-0004 capability lattice),
// distinct from platform UserRole (admin/teacher) — legacy conflated them.
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
@@ -110,9 +110,10 @@ model ProjectGroupBinding {
// --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
/// ADR-0017: session is provider/model-bound. No claudeSessionId; `provider`
/// + `model` capture the binding. Same provider+model ⇒ reuse across runs
/// (ADR-0002); a switch ⇒ new session.
/// ADR-0017: session is provider/model-bound. `provider` + `model` capture
/// the binding; provider-specific runtime cursors live in `metadata`
/// (e.g. metadata.claudeSessionId). Same provider+model ⇒ reuse across runs
/// (ADR-0002); a switch ⇒ new Hub session.
model AgentSession {
id String @id @default(cuid())
projectId String
+2 -2
View File
@@ -29,8 +29,8 @@ export interface RoleEntry {
readonly defaultModel: string | undefined;
/**
* System prompt seeding the agent's persona/instructions. Prepended to the
* run's messages only at session start (resume reads it from the transcript,
* see runner.ts). `undefined` ⇒ no system prompt (bare run).
* run's messages only at session start; Claude SDK resume restores later
* turns from the provider session. `undefined` ⇒ no system prompt (bare run).
*/
readonly systemPrompt?: string | undefined;
/**
+10 -1
View File
@@ -17,7 +17,7 @@
* message streaming, and a battle-tested agent loop — capabilities we'd have
* to build ourselves with a generic provider.
*/
import { query, type SDKMessage, type SDKAssistantMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client";
export interface ProjectContext {
@@ -39,6 +39,8 @@ export interface RunRequest {
readonly model: string | undefined;
readonly project: ProjectContext;
readonly systemPrompt: string | undefined;
readonly resumeSessionId?: string | undefined;
readonly mcpServers?: Record<string, McpServerConfig> | undefined;
readonly maxTurns?: number;
readonly runId: string;
readonly sessionId: string;
@@ -53,6 +55,7 @@ export interface RunResult {
readonly text: string;
readonly usage: { inputTokens: number; outputTokens: number };
readonly numTurns: number;
readonly sdkSessionId?: string | undefined;
readonly error?: string;
}
@@ -75,6 +78,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
let fullText = "";
let usage = { inputTokens: 0, outputTokens: 0 };
let numTurns = 0;
let sdkSessionId: string | undefined;
let error: string | undefined;
try {
const options: Parameters<typeof query>[0]["options"] = {
@@ -86,6 +90,8 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
};
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
if (req.model !== undefined) options.model = req.model;
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
const conversation = query({
prompt: req.prompt,
@@ -130,6 +136,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
}
case "result": {
const result = message as SDKResultMessage;
sdkSessionId = result.session_id;
if (result.subtype !== "success") {
error = `result_${result.subtype}`;
}
@@ -144,6 +151,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
text: fullText,
usage,
numTurns,
sdkSessionId,
...(error !== undefined ? { error } : {}),
};
} catch (e) {
@@ -152,6 +160,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
text: fullText,
usage,
numTurns,
sdkSessionId,
error: e instanceof Error ? e.message : String(e),
};
}
+78 -36
View File
@@ -1,47 +1,89 @@
import { describe, expect, it } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { runAgent } from "../../src/agent/runner.js";
import { ToolRegistry } from "../../src/agent/tools.js";
import { readFileTool } from "../../src/agent/workspace.js";
import { createMockModelFactory } from "../integration/helpers.js";
describe("runAgent with AI SDK tool loop", () => {
it("executes SDK tools and returns the final assistant response", async () => {
const ws = await mkdtemp(join(tmpdir(), "hub-runner-"));
try {
await writeFile(join(ws, "lesson.txt"), "hello lesson", "utf8");
const tools = new ToolRegistry();
tools.register("read_file", readFileTool);
const { modelFactory, models } = createMockModelFactory([
{
toolCalls: [{ toolCallId: "call-1", toolName: "read_file", input: { path: "lesson.txt" } }],
},
{ text: "done", finishReason: "stop" },
]);
const queryMock = vi.hoisted(() => vi.fn());
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: queryMock,
}));
const stubPrisma = {
projectAgentLock: { update: async () => ({}) },
agentMessage: { create: async () => ({}) },
agentFileChange: { createMany: async () => ({}) },
} as unknown as import("@prisma/client").PrismaClient;
const result = await runAgent(modelFactory, tools, {
prompt: "read lesson.txt",
model: "mock-model",
project: { projectId: "p", boundChatId: "c", workspaceDir: ws },
systemPrompt: "system",
maxIterations: 5,
runId: "test-run",
sessionId: "test-session",
function assistantMessage(text: string) {
return {
type: "assistant",
message: {
content: [{ type: "text", text }],
usage: { input_tokens: 3, output_tokens: 5 },
},
};
}
function resultMessage(sessionId: string) {
return {
type: "result",
subtype: "success",
session_id: sessionId,
};
}
function messages(...items: unknown[]) {
return (async function* () {
for (const item of items) yield item;
})();
}
describe("runAgent", () => {
beforeEach(() => {
queryMock.mockReset();
});
it("passes the previous Claude SDK session id through resume", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-new")));
const result = await runAgent({
prompt: "再发一次",
model: "anthropic/claude-sonnet-5",
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
resumeSessionId: "sdk-session-old",
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
console.log("DEBUG result:", result.status, result.error, result.messages.length);
expect(result.messages.at(-1)).toMatchObject({ role: "assistant" });
expect(models.get("mock-model")?.calls).toHaveLength(2);
} finally {
await rm(ws, { recursive: true, force: true });
}
expect(queryMock).toHaveBeenCalledWith({
prompt: "再发一次",
options: expect.objectContaining({
cwd: "/tmp/ws",
model: "anthropic/claude-sonnet-5",
resume: "sdk-session-old",
}),
});
expect(result).toMatchObject({
status: "completed",
text: "ok",
sdkSessionId: "sdk-session-new",
usage: { inputTokens: 3, outputTokens: 5 },
});
});
it("does not send resume for a fresh Hub session", async () => {
queryMock.mockReturnValue(messages(assistantMessage("fresh"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "你好",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
const call = queryMock.mock.calls[0]?.[0] as { options?: Record<string, unknown> } | undefined;
expect(call?.options).not.toHaveProperty("resume");
});
});