fix: bind agent sessions to role

This commit is contained in:
2026-07-09 16:06:06 +08:00
parent 491fd13ca8
commit 346aee5a68
5 changed files with 62 additions and 16 deletions
@@ -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 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. 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, 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 that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
`claudeSessionId` and pass it back to the next `query()` call 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: Environment variables:
``` ```
@@ -43,14 +45,16 @@ ANTHROPIC_API_KEY="" # must be explicitly empty
``` ```
Model routing: `ANTHROPIC_DEFAULT_SONNET_MODEL` etc. accept OpenRouter model 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 IDs (e.g. `z-ai/glm-4.7`, `anthropic/claude-sonnet-4-20250514`). This gives
**provider-agnosticism is preserved** — any OpenRouter model that supports tool limited OpenRouter-level model routing, but the runtime is still Claude Agent
use can be used, not just Anthropic models. SDK-shaped: non-Claude models are best-effort and may not support every
Claude Code feature.
## Consequences ## Consequences
- Provider-agnosticism preserved via OpenRouter — GLM, Claude, GPT, etc. all - Provider-agnosticism is not fully preserved. OpenRouter can route to GLM,
work as long as the model supports tool use. 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 - The agent loop is Claude Code SDK's (compaction, tool approval, partial
message streaming) — not hand-rolled. message streaming) — not hand-rolled.
- Custom tools (read_file, write_file, cph_check, cph_build) are replaced by - Custom tools (read_file, write_file, cph_check, cph_build) are replaced by
@@ -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";
+7 -5
View File
@@ -190,14 +190,15 @@ model ProjectGroupBinding {
// --- AgentRun, session, lock (ADR-0002, 0017) ----------------------------- // --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
/// ADR-0017: session is provider/model-bound. `provider` + `model` capture /// ADR-0017: session is provider/role/model-bound. `provider` + `roleId` +
/// the binding; provider-specific runtime cursors live in `metadata` /// `model` capture the binding; provider-specific runtime cursors live in
/// (e.g. metadata.claudeSessionId). Same provider+model ⇒ reuse across runs /// `metadata` (e.g. metadata.claudeSessionId). Same provider+role+model ⇒
/// (ADR-0002); a switch ⇒ new Hub session. /// reuse across runs (ADR-0002); a switch ⇒ new Hub session.
model AgentSession { model AgentSession {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String projectId String
provider String provider String
roleId String
model String model String
title String? title String?
metadata Json metadata Json
@@ -210,7 +211,8 @@ model AgentSession {
messages AgentMessage[] @relation("sessionMessages") messages AgentMessage[] @relation("sessionMessages")
@@index([projectId, archivedAt]) @@index([projectId, archivedAt])
@@index([provider, model]) @@index([provider, roleId, model])
@@index([projectId, provider, roleId, model, archivedAt])
@@index([updatedAt]) @@index([updatedAt])
} }
+7 -4
View File
@@ -180,23 +180,26 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}); });
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext); const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
// ADR-0017: provider+model-bound session. Reuse if one exists for this // ADR-0017: provider+role+model-bound session. Reuse if one exists for
// (project, provider, model) triple; otherwise create. // 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({ 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 }, select: { id: true, metadata: true },
}); });
const session = const session =
existingSession !== null existingSession !== null
? await deps.prisma.agentSession.update({ ? await deps.prisma.agentSession.update({
where: { id: existingSession.id }, where: { id: existingSession.id },
data: { provider: providerId, model, updatedAt: new Date() }, data: { provider: providerId, roleId, model, updatedAt: new Date() },
select: { id: true, metadata: true }, select: { id: true, metadata: true },
}) })
: await deps.prisma.agentSession.create({ : await deps.prisma.agentSession.create({
data: { data: {
projectId, projectId,
provider: providerId, provider: providerId,
roleId,
model, model,
title: agentPrompt.slice(0, 40) || null, title: agentPrompt.slice(0, 40) || null,
metadata: {}, metadata: {},
+28
View File
@@ -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 () => { it("dedups a redelivered event by event_id (no second run)", async () => {
await seedProject("proj-13", "chat-13"); await seedProject("proj-13", "chat-13");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });