From c73b41e1da6ff9563ff22804a05f78cc768ce1e7 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 8 Jul 2026 11:48:20 +0800 Subject: [PATCH] fix(hub): resume Claude SDK sessions --- .../0017-agent-session-is-provider-bound.md | 6 + hub/prisma/schema.prisma | 13 +- hub/src/agent/models.ts | 4 +- hub/src/agent/runner.ts | 11 +- hub/test/unit/runner.test.ts | 124 ++++++++++++------ 5 files changed, 108 insertions(+), 50 deletions(-) diff --git a/docs/adr/0017-agent-session-is-provider-bound.md b/docs/adr/0017-agent-session-is-provider-bound.md index 47805b4..03fc921 100644 --- a/docs/adr/0017-agent-session-is-provider-bound.md +++ b/docs/adr/0017-agent-session-is-provider-bound.md @@ -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 diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 3d979b4..50ea89c 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -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 diff --git a/hub/src/agent/models.ts b/hub/src/agent/models.ts index bb2d0a9..700e3a5 100644 --- a/hub/src/agent/models.ts +++ b/hub/src/agent/models.ts @@ -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; /** diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index 7da62ae..f40c1c2 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -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 | 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 { let fullText = ""; let usage = { inputTokens: 0, outputTokens: 0 }; let numTurns = 0; + let sdkSessionId: string | undefined; let error: string | undefined; try { const options: Parameters[0]["options"] = { @@ -86,6 +90,8 @@ export async function runAgent(req: RunRequest): Promise { }; 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 { } 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 { text: fullText, usage, numTurns, + sdkSessionId, ...(error !== undefined ? { error } : {}), }; } catch (e) { @@ -152,6 +160,7 @@ export async function runAgent(req: RunRequest): Promise { text: fullText, usage, numTurns, + sdkSessionId, error: e instanceof Error ? e.message : String(e), }; } diff --git a/hub/test/unit/runner.test.ts b/hub/test/unit/runner.test.ts index a6d9960..6daab89 100644 --- a/hub/test/unit/runner.test.ts +++ b/hub/test/unit/runner.test.ts @@ -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()); - 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", - prisma: stubPrisma, - }); +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: queryMock, +})); - 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 }); - } +const stubPrisma = { + projectAgentLock: { update: async () => ({}) }, +} as unknown as import("@prisma/client").PrismaClient; + +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, + }); + + 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 } | undefined; + expect(call?.options).not.toHaveProperty("resume"); }); });