From 346aee5a68390fde3e2baaabc646fa84c9248748 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Thu, 9 Jul 2026 16:06:06 +0800 Subject: [PATCH] fix: bind agent sessions to role --- .../0017-agent-session-is-provider-bound.md | 18 +++++++----- .../migration.sql | 9 ++++++ hub/prisma/schema.prisma | 12 ++++---- hub/src/feishu/trigger.ts | 11 +++++--- hub/test/integration/trigger.test.ts | 28 +++++++++++++++++++ 5 files changed, 62 insertions(+), 16 deletions(-) create mode 100644 hub/prisma/migrations/20260709120000_role_bound_agent_sessions/migration.sql diff --git a/docs/adr/0017-agent-session-is-provider-bound.md b/docs/adr/0017-agent-session-is-provider-bound.md index 03fc921..5960ef4 100644 --- a/docs/adr/0017-agent-session-is-provider-bound.md +++ b/docs/adr/0017-agent-session-is-provider-bound.md @@ -29,11 +29,13 @@ 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 +The Hub `AgentSession` is provider/role/model-bound, and 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`. +`options.resume`. Role is part of the session binding because role prompts and +tool surfaces can differ even when the underlying model is the same; `/draft` +and `/review` must not resume the same Claude runtime cursor by accident. Environment variables: ``` @@ -43,14 +45,16 @@ ANTHROPIC_API_KEY="" # must be explicitly empty ``` Model routing: `ANTHROPIC_DEFAULT_SONNET_MODEL` etc. accept OpenRouter model -IDs (e.g. `z-ai/glm-4.7`, `anthropic/claude-sonnet-4-20250514`). This means -**provider-agnosticism is preserved** — any OpenRouter model that supports tool -use can be used, not just Anthropic models. +IDs (e.g. `z-ai/glm-4.7`, `anthropic/claude-sonnet-4-20250514`). This gives +limited OpenRouter-level model routing, but the runtime is still Claude Agent +SDK-shaped: non-Claude models are best-effort and may not support every +Claude Code feature. ## Consequences -- Provider-agnosticism preserved via OpenRouter — GLM, Claude, GPT, etc. all - work as long as the model supports tool use. +- Provider-agnosticism is not fully preserved. OpenRouter can route to GLM, + Claude, GPT, etc., but the runtime protocol and agent loop remain Claude + Agent SDK-bound. - The agent loop is Claude Code SDK's (compaction, tool approval, partial message streaming) — not hand-rolled. - Custom tools (read_file, write_file, cph_check, cph_build) are replaced by diff --git a/hub/prisma/migrations/20260709120000_role_bound_agent_sessions/migration.sql b/hub/prisma/migrations/20260709120000_role_bound_agent_sessions/migration.sql new file mode 100644 index 0000000..860edac --- /dev/null +++ b/hub/prisma/migrations/20260709120000_role_bound_agent_sessions/migration.sql @@ -0,0 +1,9 @@ +-- ADR-0017 refinement: Hub sessions are provider/role/model-bound. +-- Existing sessions predate role-aware binding; treat them as draft sessions. +ALTER TABLE "AgentSession" ADD COLUMN "roleId" TEXT NOT NULL DEFAULT 'draft'; +ALTER TABLE "AgentSession" ALTER COLUMN "roleId" DROP DEFAULT; + +CREATE INDEX "AgentSession_provider_roleId_model_idx" ON "AgentSession"("provider", "roleId", "model"); +CREATE INDEX "AgentSession_projectId_provider_roleId_model_archivedAt_idx" ON "AgentSession"("projectId", "provider", "roleId", "model", "archivedAt"); + +DROP INDEX "AgentSession_provider_model_idx"; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 156fee0..fa34433 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -190,14 +190,15 @@ model ProjectGroupBinding { // --- AgentRun, session, lock (ADR-0002, 0017) ----------------------------- -/// 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. +/// ADR-0017: session is provider/role/model-bound. `provider` + `roleId` + +/// `model` capture the binding; provider-specific runtime cursors live in +/// `metadata` (e.g. metadata.claudeSessionId). Same provider+role+model ⇒ +/// reuse across runs (ADR-0002); a switch ⇒ new Hub session. model AgentSession { id String @id @default(cuid()) projectId String provider String + roleId String model String title String? metadata Json @@ -210,7 +211,8 @@ model AgentSession { messages AgentMessage[] @relation("sessionMessages") @@index([projectId, archivedAt]) - @@index([provider, model]) + @@index([provider, roleId, model]) + @@index([projectId, provider, roleId, model, archivedAt]) @@index([updatedAt]) } diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index a9916b5..f7b2cec 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -180,23 +180,26 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { }); const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext); - // ADR-0017: provider+model-bound session. Reuse if one exists for this - // (project, provider, model) triple; otherwise create. + // ADR-0017: provider+role+model-bound session. Reuse if one exists for + // this (project, provider, role, model) tuple; otherwise create. Role is + // part of the key because role prompts/tool surfaces can differ even when + // the underlying model is the same. const existingSession = await deps.prisma.agentSession.findFirst({ - where: { projectId, provider: providerId, model, archivedAt: null }, + where: { projectId, provider: providerId, roleId, model, archivedAt: null }, select: { id: true, metadata: true }, }); const session = existingSession !== null ? await deps.prisma.agentSession.update({ where: { id: existingSession.id }, - data: { provider: providerId, model, updatedAt: new Date() }, + data: { provider: providerId, roleId, model, updatedAt: new Date() }, select: { id: true, metadata: true }, }) : await deps.prisma.agentSession.create({ data: { projectId, provider: providerId, + roleId, model, title: agentPrompt.slice(0, 40) || null, metadata: {}, diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 4302865..028dbfc 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -421,6 +421,34 @@ describe("trigger full lifecycle (integration)", () => { }); }); + it("keeps role sessions separate even when roles share a model", async () => { + await seedProject("proj-12b", "chat-12b"); + await prisma.roleTriggerGrant.create({ + data: { projectId: "proj-12b", roleId: "review", principalType: "USER", principalId: "ou_test_user" }, + }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); + + await trigger(makeEvent("chat-12b", "@_user_1 /draft 写第三单元"), rt); + await vi.waitFor(async () => { + const runs = await prisma.agentRun.findMany(); + expect(runs).toHaveLength(1); + expect(runs[0]?.status).toBe("COMPLETED"); + }); + + await trigger(makeEvent("chat-12b", "@_user_1 /review 看看这节"), rt); + await vi.waitFor(async () => { + const runs = await prisma.agentRun.findMany(); + expect(runs).toHaveLength(2); + expect(runs.every((run) => run.status === "COMPLETED")).toBe(true); + }); + + const sessions = await prisma.agentSession.findMany({ orderBy: { roleId: "asc" } }); + expect(sessions.map((session) => session.roleId)).toEqual(["draft", "review"]); + expect(new Set(sessions.map((session) => session.model))).toEqual(new Set(["mock-model"])); + expect(new Set(sessions.map((session) => session.id)).size).toBe(2); + expect(runAgentCalls[1]?.resumeSessionId).toBeUndefined(); + }); + it("dedups a redelivered event by event_id (no second run)", async () => { await seedProject("proj-13", "chat-13"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });