forked from EduCraft/curriculum-project-hub
fix: bind agent sessions to role
This commit is contained in:
@@ -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";
|
||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
Reference in New Issue
Block a user