Skip to content

Commit e01b9fb

Browse files
GabrielDraporclaude
andcommitted
feat(agent): per-turn attribution columns on ai_messages (turn_kind, execution_id, initiator_user_id)
DB unreachable in this worktree (no .env) — migration.sql was hand-written to match the plan verbatim and validated only via `prisma generate`/`tsc`, not applied; appendMessage's return type was widened to return the created row (Promise<AIMessage> in place of Promise<void>) a release early, since Task 13 needs the row id for turnTraceId and the wider return type is a superset any existing caller can ignore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DopjNH21gLaDtWeeMDEHX
1 parent 255c576 commit e01b9fb

8 files changed

Lines changed: 188 additions & 30 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const create = vi.fn();
4+
const findUnique = vi.fn();
5+
const count = vi.fn();
6+
vi.mock("@traceroot/core", () => ({
7+
prisma: {
8+
aIMessage: {
9+
create: (...a: unknown[]) => create(...a),
10+
count: (...a: unknown[]) => count(...a),
11+
},
12+
aISession: { findUnique: (...a: unknown[]) => findUnique(...a) },
13+
},
14+
}));
15+
16+
import { SessionManager } from "../session.js";
17+
18+
beforeEach(() => {
19+
create.mockReset();
20+
findUnique.mockReset();
21+
count.mockReset();
22+
});
23+
24+
describe("appendMessage attribution", () => {
25+
it("system session + executionId + first assistant turn → rca_execution", async () => {
26+
findUnique.mockResolvedValue({ workspaceId: "w", userId: null, executionId: "exec-1" });
27+
count.mockResolvedValue(0);
28+
await new SessionManager("s1").appendMessage("assistant", "root cause…");
29+
expect(create.mock.calls[0][0].data).toMatchObject({
30+
turnKind: "rca_execution",
31+
executionId: "exec-1",
32+
initiatorUserId: null,
33+
kind: "rca",
34+
});
35+
});
36+
it("system session, later turn with a user → rca_followup", async () => {
37+
findUnique.mockResolvedValue({ workspaceId: "w", userId: null, executionId: "exec-1" });
38+
count.mockResolvedValue(1);
39+
await new SessionManager("s1").appendMessage("user", "why?", undefined, undefined, {
40+
turnKind: "rca_followup",
41+
initiatorUserId: "u9",
42+
});
43+
expect(create.mock.calls[0][0].data).toMatchObject({
44+
turnKind: "rca_followup",
45+
initiatorUserId: "u9",
46+
kind: "rca",
47+
});
48+
});
49+
it("user session → chat with the session owner as initiator", async () => {
50+
findUnique.mockResolvedValue({ workspaceId: "w", userId: "u1", executionId: null });
51+
await new SessionManager("s2").appendMessage("user", "hi");
52+
expect(create.mock.calls[0][0].data).toMatchObject({
53+
turnKind: "chat",
54+
initiatorUserId: "u1",
55+
kind: "chat",
56+
});
57+
});
58+
});

frontend/ee/agent/src/index.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
listSessions,
1010
deleteSession,
1111
updateSessionTitle,
12+
type TurnAttribution,
1213
} from "./session.js";
1314
import { getOrCreateAgent, runAgent, removeAgent, invalidateProviderCache } from "./agent.js";
1415
import { StreamPersister } from "./stream-persister.js";
@@ -52,13 +53,14 @@ app.post("/api/v1/projects/:projectId/sessions", async (c) => {
5253
const projectId = c.req.param("projectId");
5354
const userId = c.req.header("x-user-id") || undefined;
5455
const workspaceId = c.req.header("x-workspace-id") || "";
55-
const body = await c.req.json<{ title?: string }>();
56+
const body = await c.req.json<{ title?: string; executionId?: string }>();
5657

5758
const session = await createSession({
5859
projectId,
5960
workspaceId,
6061
userId, // undefined → stored as null for system/RCA sessions
6162
title: body.title,
63+
executionId: body.executionId,
6264
});
6365
return c.json(session, 201);
6466
});
@@ -171,8 +173,26 @@ app.post("/api/v1/projects/:projectId/sessions/:sessionId/messages", async (c) =
171173

172174
console.log(`[Agent] Agent ready, running prompt: "${body.message.slice(0, 50)}"`);
173175

176+
// Attribution is computed once per turn and applied to every row it
177+
// produces (the user message, and every assistant/tool_step row the
178+
// persister writes below) so a turn reads as one attributed unit.
179+
const attribution: TurnAttribution =
180+
ownedSession.userId === null
181+
? userId
182+
? {
183+
turnKind: "rca_followup",
184+
executionId: ownedSession.executionId,
185+
initiatorUserId: userId,
186+
}
187+
: {
188+
turnKind: "rca_execution",
189+
executionId: ownedSession.executionId,
190+
initiatorUserId: null,
191+
}
192+
: { turnKind: "chat", initiatorUserId: userId || null };
193+
174194
// Persist user message to DB via SessionManager
175-
await sessionManager.appendMessage("user", body.message);
195+
await sessionManager.appendMessage("user", body.message, undefined, undefined, attribution);
176196

177197
// Auto-generate session title from first user message (we already have
178198
// the session loaded above for the auth check — reuse it).
@@ -185,7 +205,7 @@ app.post("/api/v1/projects/:projectId/sessions/:sessionId/messages", async (c) =
185205
// Mirrors the run into AIMessage rows (text segments, tool steps) so
186206
// reloaded history matches what the live stream rendered.
187207
const persister = new StreamPersister((role, content, metadata, tokenUsage) =>
188-
sessionManager.appendMessage(role, content, metadata, tokenUsage),
208+
sessionManager.appendMessage(role, content, metadata, tokenUsage, attribution),
189209
);
190210
// Accumulates token usage across all message_end events (tool-use loops)
191211
const usageAccumulator = new UsageAccumulator();

frontend/ee/agent/src/session.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ export interface TokenUsageData {
2020
totalTokens?: number;
2121
}
2222

23+
export type TurnKind = "rca_execution" | "rca_followup" | "chat" | "detector" | "digest";
24+
25+
export interface TurnAttribution {
26+
turnKind: TurnKind;
27+
executionId?: string | null;
28+
initiatorUserId?: string | null;
29+
}
30+
31+
/** `kind` is kept one release for old readers; derived from turnKind at write time. */
32+
const LEGACY_KIND: Record<TurnKind, string> = {
33+
rca_execution: "rca",
34+
rca_followup: "rca",
35+
chat: "chat",
36+
detector: "detector",
37+
digest: "digest-summary",
38+
};
39+
2340
export class SessionManager {
2441
constructor(private sessionId: string) {}
2542

@@ -87,30 +104,35 @@ export class SessionManager {
87104
* Like Mom's sessionManager.appendMessage() — persists to DB.
88105
*
89106
* `workspaceId` and `kind` are required on every AIMessage row (see schema).
90-
* We derive both from the parent AISession: `kind = "chat"` for user sessions
91-
* (userId set), `kind = "rca"` for system sessions (userId null). This
92-
* mirrors the existing convention in createSession.
107+
* `kind` is derived from the turn's attribution (see LEGACY_KIND) and kept
108+
* one release for old readers. Returns the created row so callers (e.g. the
109+
* turn-trace wrapper) can key off its id.
93110
*/
94111
async appendMessage(
95112
role: string,
96113
content: string,
97114
metadata?: Record<string, unknown>,
98115
tokenUsage?: TokenUsageData,
99-
): Promise<void> {
116+
attribution?: TurnAttribution,
117+
): Promise<Awaited<ReturnType<typeof prisma.aIMessage.create>>> {
100118
const session = await prisma.aISession.findUnique({
101119
where: { id: this.sessionId },
102-
select: { workspaceId: true, userId: true },
120+
select: { workspaceId: true, userId: true, executionId: true },
103121
});
104122
if (!session) {
105123
throw new Error(`AISession not found: ${this.sessionId}`);
106124
}
107-
const kind = session.userId === null ? "rca" : "chat";
108125

109-
await prisma.aIMessage.create({
126+
const attr = attribution ?? (await this.deriveAttribution(session));
127+
128+
return prisma.aIMessage.create({
110129
data: {
111130
sessionId: this.sessionId,
112131
workspaceId: session.workspaceId,
113-
kind,
132+
kind: LEGACY_KIND[attr.turnKind],
133+
turnKind: attr.turnKind,
134+
executionId: attr.executionId ?? null,
135+
initiatorUserId: attr.initiatorUserId ?? null,
114136
role,
115137
content,
116138
metadata: metadata as any,
@@ -125,6 +147,21 @@ export class SessionManager {
125147
},
126148
});
127149
}
150+
151+
/** Default attribution when the caller did not say: user sessions are chat; a system
152+
* session's turns are the execution until an assistant turn exists, then follow-ups. */
153+
private async deriveAttribution(session: {
154+
userId: string | null;
155+
executionId: string | null;
156+
}): Promise<TurnAttribution> {
157+
if (session.userId !== null) return { turnKind: "chat", initiatorUserId: session.userId };
158+
const priorAssistant = await prisma.aIMessage.count({
159+
where: { sessionId: this.sessionId, role: "assistant", content: { not: "" } },
160+
});
161+
return priorAssistant === 0 && session.executionId
162+
? { turnKind: "rca_execution", executionId: session.executionId, initiatorUserId: null }
163+
: { turnKind: "rca_followup", executionId: session.executionId, initiatorUserId: null };
164+
}
128165
}
129166

130167
// ============================================================
@@ -136,13 +173,15 @@ export async function createSession(params: {
136173
workspaceId: string;
137174
userId?: string; // optional — null for system/RCA sessions
138175
title?: string;
176+
executionId?: string; // the execution that opened this system session
139177
}) {
140178
return prisma.aISession.create({
141179
data: {
142180
projectId: params.projectId,
143181
workspaceId: params.workspaceId,
144182
userId: params.userId ?? null,
145183
title: params.title,
184+
executionId: params.executionId ?? null,
146185
},
147186
});
148187
}

frontend/ee/agent/src/stream-persister.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { AgentEvent } from "@earendil-works/pi-agent-core";
2-
import type { TokenUsageData } from "./session.js";
2+
import type { TokenUsageData, TurnAttribution } from "./session.js";
33

44
/** Signature of SessionManager.appendMessage — injected so the persister is testable. */
55
export type AppendMessageFn = (
66
role: string,
77
content: string,
88
metadata?: Record<string, unknown>,
99
tokenUsage?: TokenUsageData,
10-
) => Promise<void>;
10+
attribution?: TurnAttribution,
11+
) => Promise<unknown>;
1112

1213
/**
1314
* Mirrors a run's agent events into durable AIMessage rows so reloaded
@@ -95,7 +96,9 @@ export class StreamPersister {
9596
tokenUsage?: TokenUsageData,
9697
): void {
9798
this.chain = this.chain
98-
.then(() => this.append(role, content, metadata, tokenUsage))
99+
.then(async () => {
100+
await this.append(role, content, metadata, tokenUsage);
101+
})
99102
.catch((error) => {
100103
console.error(`[Agent] Failed to persist ${role} message:`, error);
101104
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
CREATE TYPE "TurnKind" AS ENUM ('rca_execution', 'rca_followup', 'chat', 'detector', 'digest');
2+
ALTER TABLE "ai_messages"
3+
ADD COLUMN "turn_kind" "TurnKind" NOT NULL DEFAULT 'chat',
4+
ADD COLUMN "execution_id" VARCHAR,
5+
ADD COLUMN "initiator_user_id" VARCHAR;
6+
ALTER TABLE "ai_messages" ADD CONSTRAINT "ai_messages_execution_id_fkey"
7+
FOREIGN KEY ("execution_id") REFERENCES "detector_rca_executions"("id") ON DELETE SET NULL;
8+
ALTER TABLE "ai_sessions" ADD COLUMN "execution_id" VARCHAR;
9+
10+
-- Backfill from the legacy kind. Historical system-session turns cannot be split into
11+
-- execution vs follow-up after the fact; all are attributed to the execution.
12+
UPDATE "ai_messages" SET "turn_kind" = 'rca_execution' WHERE "kind" = 'rca';
13+
UPDATE "ai_messages" SET "turn_kind" = 'detector' WHERE "kind" = 'detector';
14+
UPDATE "ai_messages" SET "turn_kind" = 'digest' WHERE "kind" = 'digest-summary';
15+
UPDATE "ai_messages" m SET "initiator_user_id" = s."user_id"
16+
FROM "ai_sessions" s WHERE m."session_id" = s."id" AND s."user_id" IS NOT NULL;
17+
CREATE INDEX "ix_ai_message_workspace_turnkind_time" ON "ai_messages"("workspace_id", "turn_kind", "create_time");

frontend/packages/core/prisma/schema.prisma

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,9 @@ model AISession {
344344
title String? @db.VarChar
345345
status String @default("active") @db.VarChar // active, completed, failed
346346
metadata Json? @db.JsonB
347+
// The execution that opened this system session (null for user sessions and
348+
// for system sessions predating executions — no backfill by design).
349+
executionId String? @map("execution_id") @db.VarChar
347350
createTime DateTime @default(now()) @map("create_time") @db.Timestamp(6)
348351
updateTime DateTime @default(now()) @updatedAt @map("update_time") @db.Timestamp(6)
349352
messages AIMessage[]
@@ -356,27 +359,42 @@ model AISession {
356359
@@map("ai_sessions")
357360
}
358361

362+
enum TurnKind {
363+
rca_execution
364+
rca_followup
365+
chat
366+
detector
367+
digest
368+
}
369+
359370
// AI Messages - Individual messages within an AI session
360371
model AIMessage {
361-
id String @id @default(cuid()) @db.VarChar
362-
workspaceId String @map("workspace_id") @db.VarChar // direct workspace pointer for per-kind aggregation (detector scans have no session)
363-
sessionId String? @map("session_id") @db.VarChar // nullable: detector scans have no chat session
364-
kind String @default("chat") @db.VarChar // "chat" | "rca" | "detector" | "digest-summary" — categorical tag; usage metering aggregates only chat|rca|detector (digest-summary is recorded, not metered)
365-
role String @db.VarChar // user, assistant, tool
366-
content String @db.Text
367-
metadata Json? @db.JsonB // tool name, tool result, etc.
368-
model String? @db.VarChar
369-
provider String? @db.VarChar
370-
isByok Boolean? @map("is_byok")
371-
inputTokens Int? @map("input_tokens")
372-
outputTokens Int? @map("output_tokens")
373-
cost Decimal? @map("cost_usd") @db.Decimal(10, 6)
374-
createTime DateTime @default(now()) @map("create_time") @db.Timestamp(6)
375-
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
376-
session AISession? @relation(fields: [sessionId], references: [id], onDelete: Cascade, onUpdate: NoAction)
372+
id String @id @default(cuid()) @db.VarChar
373+
workspaceId String @map("workspace_id") @db.VarChar // direct workspace pointer for per-kind aggregation (detector scans have no session)
374+
sessionId String? @map("session_id") @db.VarChar // nullable: detector scans have no chat session
375+
kind String @default("chat") @db.VarChar // "chat" | "rca" | "detector" | "digest-summary" — categorical tag; usage metering aggregates only chat|rca|detector (digest-summary is recorded, not metered)
376+
// Per-turn attribution. `kind` is kept one release for old readers and is
377+
// derived from turnKind at write time; metering will move to turnKind (E-09 follow-up).
378+
turnKind TurnKind @default(chat) @map("turn_kind")
379+
executionId String? @map("execution_id") @db.VarChar
380+
initiatorUserId String? @map("initiator_user_id") @db.VarChar
381+
role String @db.VarChar // user, assistant, tool
382+
content String @db.Text
383+
metadata Json? @db.JsonB // tool name, tool result, etc.
384+
model String? @db.VarChar
385+
provider String? @db.VarChar
386+
isByok Boolean? @map("is_byok")
387+
inputTokens Int? @map("input_tokens")
388+
outputTokens Int? @map("output_tokens")
389+
cost Decimal? @map("cost_usd") @db.Decimal(10, 6)
390+
createTime DateTime @default(now()) @map("create_time") @db.Timestamp(6)
391+
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
392+
session AISession? @relation(fields: [sessionId], references: [id], onDelete: Cascade, onUpdate: NoAction)
393+
execution DetectorRcaExecution? @relation(fields: [executionId], references: [id], onDelete: SetNull)
377394
378395
@@index([sessionId], map: "ix_ai_message_session_id")
379396
@@index([workspaceId, kind, createTime], map: "ix_ai_message_workspace_kind_time")
397+
@@index([workspaceId, turnKind, createTime], map: "ix_ai_message_workspace_turnkind_time")
380398
@@map("ai_messages")
381399
}
382400

@@ -516,6 +534,7 @@ model DetectorRcaExecution {
516534
rca DetectorRca @relation("rcaExecutions", fields: [findingId], references: [findingId], onDelete: Cascade)
517535
session AISession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
518536
latestOf DetectorRca? @relation("latestExecution")
537+
messages AIMessage[]
519538
520539
@@unique([findingId, attempt], map: "uq_rca_execution_finding_attempt")
521540
@@index([projectId], map: "ix_rca_execution_project_id")

frontend/worker/src/processors/detector-digest-processor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export async function flushDigest(job: DigestFlushJob): Promise<void> {
111111
workspaceId: recipients.workspaceId,
112112
sessionId: null,
113113
kind: "digest-summary",
114+
turnKind: "digest",
114115
role: "assistant",
115116
content: "",
116117
model: result.usage.model,

frontend/worker/src/processors/detector-run-processor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@ async function evaluateTrace(
429429
workspaceId,
430430
sessionId: null,
431431
kind: "detector",
432+
turnKind: "detector" as const,
432433
role: "assistant",
433434
content: "", // detector scans don't have a chat-like content payload
434435
model: u.inferenceModel,

0 commit comments

Comments
 (0)