diff --git a/cli/package.json b/cli/package.json index 7ec740b9..d7b41e22 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,6 +20,7 @@ "./utils/pricing": "./dist/utils/pricing.js", "./utils/telemetry": "./dist/utils/telemetry.js", "./analysis/prompts": "./dist/analysis/prompts.js", + "./analysis/turn-citations": "./dist/analysis/turn-citations.js", "./analysis/prompt-types": "./dist/analysis/prompt-types.js", "./analysis/prompt-constants": "./dist/analysis/prompt-constants.js", "./analysis/message-format": "./dist/analysis/message-format.js", diff --git a/cli/src/analysis/__tests__/analysis-db.test.ts b/cli/src/analysis/__tests__/analysis-db.test.ts index 9f3ea84f..527b46ae 100644 --- a/cli/src/analysis/__tests__/analysis-db.test.ts +++ b/cli/src/analysis/__tests__/analysis-db.test.ts @@ -128,8 +128,8 @@ const PQ_RESPONSE: PromptQualityResponse = { // ── ANALYSIS_VERSION ────────────────────────────────────────────────────────── describe('ANALYSIS_VERSION', () => { - it('is 3.0.0', () => { - expect(ANALYSIS_VERSION).toBe('3.0.0'); + it('is 3.1.0', () => { + expect(ANALYSIS_VERSION).toBe('3.1.0'); }); }); @@ -158,6 +158,26 @@ describe('convertToInsightRows', () => { expect(decision!.confidence).toBeCloseTo(0.85); }); + it('persists user decision agency and explicit intent/branch point', () => { + const response: AnalysisResponse = { ...ANALYSIS_RESPONSE, decisions: [ + { ...ANALYSIS_RESPONSE.decisions[0], decided_by: 'user', intent: 'Stateless authentication', branch_point: 'Session cookies' }, + ] }; + const row = convertToInsightRows(response, SESSION).find(r => r.type === 'decision'); + expect(JSON.parse(row!.metadata!)).toMatchObject({ + decided_by: 'user', intent: 'Stateless authentication', branch_point: 'Session cookies', + }); + }); + + it('marks missing or invalid agency unknown rather than collaborative', () => { + const absent = convertToInsightRows(ANALYSIS_RESPONSE, SESSION).find(r => r.type === 'decision'); + expect(JSON.parse(absent!.metadata!).decided_by).toBe('unknown'); + const bad = { ...ANALYSIS_RESPONSE, decisions: [ + { ...ANALYSIS_RESPONSE.decisions[0], decided_by: 'invalid' }, + ] } as unknown as AnalysisResponse; + const result = convertToInsightRows(bad, SESSION).find(r => r.type === 'decision'); + expect(JSON.parse(result!.metadata!).decided_by).toBe('unknown'); + }); + it('skips decisions with confidence < 70', () => { const response: AnalysisResponse = { ...ANALYSIS_RESPONSE, @@ -271,8 +291,8 @@ describe('saveFacetsToDb analysisVersion parameter', () => { expect(row.analysis_version).toBe('2.5.0'); }); - it('default ANALYSIS_VERSION constant is 3.0.0', () => { + it('default ANALYSIS_VERSION constant is 3.1.0', () => { // Verifies the constant that will be used as the default - expect(ANALYSIS_VERSION).toBe('3.0.0'); + expect(ANALYSIS_VERSION).toBe('3.1.0'); }); }); diff --git a/cli/src/analysis/__tests__/prompts.test.ts b/cli/src/analysis/__tests__/prompts.test.ts index 6ff784bd..27c9db78 100644 --- a/cli/src/analysis/__tests__/prompts.test.ts +++ b/cli/src/analysis/__tests__/prompts.test.ts @@ -407,6 +407,15 @@ describe('buildSessionAnalysisInstructions', () => { expect(result).toContain(''); }); + it('requires agency grounded in explicit zero-based turn evidence', () => { + const prompt = buildSessionAnalysisInstructions('proj', null); + expect(prompt).toContain('decision_attribution name="decided_by"'); + expect(prompt).toContain('"decided_by": "user | agent | collaborative | unknown"'); + expect(prompt).toContain('Role-specific turn numbering starts at 0'); + expect(prompt).toContain('never fabricate citations'); + expect(prompt).toContain('never infer collaboration'); + }); + it('ends with json tags instruction', () => { const result = buildSessionAnalysisInstructions('proj', null); expect(result).toContain('...'); diff --git a/cli/src/analysis/__tests__/turn-citations.test.ts b/cli/src/analysis/__tests__/turn-citations.test.ts new file mode 100644 index 00000000..8cbfa456 --- /dev/null +++ b/cli/src/analysis/__tests__/turn-citations.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { resolveTurnReference } from '../turn-citations.js'; + +const messages = [ + { id: 'u0', type: 'user', content: 'Choose SQLite' }, + { id: 'a0', type: 'assistant', content: 'I propose D1' }, + { id: 'tool', type: 'user', content: '[{"type":"tool_result","content":"ok"}]' }, + { id: 'compact', type: 'user', content: 'Here is a summary of our conversation so far' }, + { id: 'slash', type: 'user', content: '/compact' }, + { id: 'u1', type: 'user', content: 'No, use SQLite' }, + { id: 'a1', type: 'assistant', content: 'Agreed' }, + { id: 'sys', type: 'system', content: 'Runtime event' }, +]; + +describe('resolveTurnReference', () => { + it('matches human role ordinals while excluding tool/system artifacts', () => { + expect(resolveTurnReference(messages, 'User', 0)).toEqual({ messageId: 'u0', offset: 0 }); + expect(resolveTurnReference(messages, 'User', 1)).toEqual({ messageId: 'u1', offset: 5 }); + expect(resolveTurnReference(messages, 'Assistant', 1)).toEqual({ messageId: 'a1', offset: 6 }); + expect(resolveTurnReference(messages, 'User', 2)).toBeNull(); + }); + it('rejects invalid, unsafe and out-of-range indices', () => { + expect(resolveTurnReference(messages, 'User', -1)).toBeNull(); + expect(resolveTurnReference(messages, 'User', 1.5)).toBeNull(); + expect(resolveTurnReference(messages, 'User', Number.MAX_SAFE_INTEGER + 1)).toBeNull(); + expect(resolveTurnReference(messages, 'Assistant', 2)).toBeNull(); + }); +}); diff --git a/cli/src/analysis/analysis-db.ts b/cli/src/analysis/analysis-db.ts index c22f8276..44538d40 100644 --- a/cli/src/analysis/analysis-db.ts +++ b/cli/src/analysis/analysis-db.ts @@ -9,7 +9,7 @@ import type { AnalysisResponse, PromptQualityResponse } from './prompt-types.js' import { normalizePatternCategory } from './pattern-normalize.js'; import { normalizePromptQualityCategory } from './prompt-quality-normalize.js'; -export const ANALYSIS_VERSION = '3.0.0'; +export const ANALYSIS_VERSION = '3.1.0'; // Shape of a saved insight row (matches the SQLite schema) export interface InsightRow { @@ -109,6 +109,12 @@ export function convertToInsightRows(response: AnalysisResponse, session: Sessio trade_offs: decision.trade_offs, revisit_when: decision.revisit_when, evidence: decision.evidence, + // Do not claim collaboration when older analyses lack explicit provenance. + decided_by: ['user', 'agent', 'collaborative'].includes(decision.decided_by ?? '') + ? decision.decided_by + : 'unknown', + intent: typeof decision.intent === 'string' ? decision.intent : undefined, + branch_point: typeof decision.branch_point === 'string' ? decision.branch_point : undefined, }), timestamp: session.ended_at, created_at: now, diff --git a/cli/src/analysis/prompt-types.ts b/cli/src/analysis/prompt-types.ts index 7cb97685..a1d669c5 100644 --- a/cli/src/analysis/prompt-types.ts +++ b/cli/src/analysis/prompt-types.ts @@ -78,6 +78,10 @@ export interface AnalysisResponse { revisit_when?: string; confidence?: number; evidence?: string[]; + /** Agency evidenced by actual turn references. */ + decided_by?: 'user' | 'agent' | 'collaborative' | 'unknown'; + intent?: string; + branch_point?: string; }>; learnings: Array<{ title: string; diff --git a/cli/src/analysis/prompts.ts b/cli/src/analysis/prompts.ts index 6df8cbe8..78041535 100644 --- a/cli/src/analysis/prompts.ts +++ b/cli/src/analysis/prompts.ts @@ -125,15 +125,17 @@ ${sessionSummary ? ` ${sessionSummary}\n` : 1. Enforce strict JSON output schema. 2. Extract insights containing ONLY concrete references (file paths, endpoints, variables, errors). Filter out generic findings automatically. - 3. Include 1-3 literal quote citations per insight referencing turn labels (e.g., "User#5"). + 3. Include 1-3 literal quote citations per insight referencing actual transcript turns (e.g., "User#0" or "Assistant#2"). Role-specific turn numbering starts at 0; never fabricate citations. 4. Require a minimum confidence score of 70 for any decision or learning. Drop insights below this threshold. 5. Return empty arrays for categories yielding no valid findings. 6. Fill every field in the schema. Use null for unavailable data where permitted. + 7. Attribute each decision to who actually selected the choice: user, agent, collaborative, or unknown. Ground decided_by in cited turn evidence; never infer collaboration merely because both parties spoke. high | medium | low | abandoned plan-then-implement | iterative-refinement | debug-fix-verify | explore-then-build | direct-execution | null + user: explicit user choice | agent: autonomous AI choice | collaborative: jointly explored and agreed choice | unknown: insufficient or contradictory turn evidence ${FRICTION_CLASSIFICATION_GUIDANCE} @@ -181,6 +183,9 @@ ${EFFECTIVE_PATTERN_CLASSIFICATION_GUIDANCE} "situation": "Problem context", "choice": "Chosen implementation", "reasoning": "Decision factors", + "decided_by": "user | agent | collaborative | unknown", + "intent": "Why the chosen approach was needed", + "branch_point": "Alternative path or design explicitly rejected", "alternatives": [{"option": "Name", "rejected_because": "Reason"}], "trade_offs": "Accepted downsides", "revisit_when": "Reconsideration conditions", diff --git a/cli/src/analysis/schemas/session-analysis.json b/cli/src/analysis/schemas/session-analysis.json index 74bebf83..ea4b153f 100644 --- a/cli/src/analysis/schemas/session-analysis.json +++ b/cli/src/analysis/schemas/session-analysis.json @@ -145,6 +145,9 @@ "situation": { "type": "string" }, "choice": { "type": "string" }, "reasoning": { "type": "string" }, + "decided_by": { "type": "string", "enum": ["user", "agent", "collaborative", "unknown"] }, + "intent": { "type": "string" }, + "branch_point": { "type": "string" }, "alternatives": { "type": "array", "items": { diff --git a/cli/src/analysis/turn-citations.ts b/cli/src/analysis/turn-citations.ts new file mode 100644 index 00000000..59d63980 --- /dev/null +++ b/cli/src/analysis/turn-citations.ts @@ -0,0 +1,31 @@ +import { classifyStoredUserMessage } from './message-format.js'; + +export type TurnRole = 'User' | 'Assistant'; +export interface CitableMessage { + id: string; + type: string; + content: string; +} +export interface ResolvedTurn { + messageId: string; + offset: number; +} + +/** Match formatMessagesForAnalysis's zero-based role ordinals and human-only User turns. */ +export function resolveTurnReference( + messages: CitableMessage[], + role: TurnRole, + ordinal: number, +): ResolvedTurn | null { + if (!Number.isSafeInteger(ordinal) || ordinal < 0 || (role !== 'User' && role !== 'Assistant')) return null; + let seen = 0; + for (let offset = 0; offset < messages.length; offset++) { + const message = messages[offset]; + const matches = role === 'User' + ? message.type === 'user' && classifyStoredUserMessage(message.content) === 'human' + : message.type === 'assistant'; + if (!matches) continue; + if (seen++ === ordinal) return { messageId: message.id, offset }; + } + return null; +} diff --git a/cli/src/types.ts b/cli/src/types.ts index bd201b6a..d3d4fd09 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -189,6 +189,9 @@ export interface InsightMetadata { trade_offs?: string; revisit_when?: string; evidence?: string[]; + decided_by?: 'user' | 'agent' | 'collaborative' | 'unknown'; + intent?: string; + branch_point?: string; // Learning-specific (v3.0.0 decomposed schema) symptom?: string; root_cause?: string; diff --git a/dashboard/src/components/insights/insight-metadata.tsx b/dashboard/src/components/insights/insight-metadata.tsx index 258912da..0af8fadd 100644 --- a/dashboard/src/components/insights/insight-metadata.tsx +++ b/dashboard/src/components/insights/insight-metadata.tsx @@ -83,17 +83,44 @@ export function formatAlternatives(alternatives: InsightMetadata['alternatives'] }).join('; '); } +/** Render legacy evidence as text and canonical role refs as optional jump links. */ +export function EvidenceCitations({ evidence, onTurnClick }: { + evidence: string[]; + onTurnClick?: (ref: string) => void; +}) { + return ( + + {evidence.map((item, i) => { + const match = typeof item === 'string' + ? /^(User|Assistant)#(0|[1-9][0-9]*)(?::\s+.*)?$/.exec(item.trim()) + : null; + const ref = match ? `${match[1]}#${match[2]}` : null; + return ref && onTurnClick ? ( + + ) : {item}; + })} + + ); +} + // --- Type-specific content components --- -export function DecisionContent({ metadata }: { metadata: InsightMetadata }) { +export function DecisionContent({ metadata, onTurnClick }: { metadata: InsightMetadata; onTurnClick?: (ref: string) => void }) { const hasStructured = metadata.situation || metadata.choice || metadata.reasoning; if (!hasStructured) return null; + const agencyLabels = { user: 'User decision', agent: 'Agent decision', collaborative: 'Collaborative decision', unknown: 'Agency unverified' } as const; + const agency = metadata.decided_by && agencyLabels[metadata.decided_by]; return (
+ {agency && {agency}} {metadata.situation && {metadata.situation}} {metadata.choice && {metadata.choice}} {metadata.reasoning && {metadata.reasoning}} + {metadata.intent && {metadata.intent}} + {metadata.branch_point && {metadata.branch_point}} {metadata.alternatives && metadata.alternatives.length > 0 && (
@@ -119,13 +146,13 @@ export function DecisionContent({ metadata }: { metadata: InsightMetadata }) { {metadata.revisit_when} )} {metadata.evidence && metadata.evidence.length > 0 && ( - {metadata.evidence.join(', ')} + )}
); } -export function LearningContent({ metadata }: { metadata: InsightMetadata }) { +export function LearningContent({ metadata, onTurnClick }: { metadata: InsightMetadata; onTurnClick?: (ref: string) => void }) { const hasStructured = metadata.symptom || metadata.root_cause || metadata.takeaway; if (!hasStructured) return null; @@ -135,6 +162,9 @@ export function LearningContent({ metadata }: { metadata: InsightMetadata }) { {metadata.root_cause && {metadata.root_cause}} {metadata.takeaway && {metadata.takeaway}} {metadata.applies_when && {metadata.applies_when}} + {metadata.evidence && metadata.evidence.length > 0 && ( + + )}
); } diff --git a/dashboard/src/components/sessions/CollapsibleInsightItem.tsx b/dashboard/src/components/sessions/CollapsibleInsightItem.tsx index 69703f50..8a60aad4 100644 --- a/dashboard/src/components/sessions/CollapsibleInsightItem.tsx +++ b/dashboard/src/components/sessions/CollapsibleInsightItem.tsx @@ -7,7 +7,7 @@ import { ChevronRight, ChevronDown } from 'lucide-react'; /** Per-item collapsible for learnings and decisions. Compact row with * expand toggle to reveal full structured metadata. */ -export function CollapsibleInsightItem({ insight }: { insight: Insight }) { +export function CollapsibleInsightItem({ insight, onTurnClick }: { insight: Insight; onTurnClick?: (ref: string) => void }) { const [expanded, setExpanded] = useState(false); const metadata = parseJsonField(insight.metadata, {}); @@ -44,9 +44,9 @@ export function CollapsibleInsightItem({ insight }: { insight: Insight }) { insight.type === 'decision' ? 'border-blue-500/40' : 'border-green-500/40' )}> {insight.type === 'decision' ? ( - + ) : ( - + )} )} diff --git a/dashboard/src/components/sessions/SessionDetailPanel.tsx b/dashboard/src/components/sessions/SessionDetailPanel.tsx index 57c3bb1e..ea204c2a 100644 --- a/dashboard/src/components/sessions/SessionDetailPanel.tsx +++ b/dashboard/src/components/sessions/SessionDetailPanel.tsx @@ -10,7 +10,7 @@ import { import { SESSION_CHARACTER_COLORS, SESSION_CHARACTER_LABELS, SOURCE_TOOL_COLORS, OUTCOME_DOT } from '@/lib/constants/colors'; import { parseJsonField } from '@/lib/types'; import { getScoreTier, extractPQScore } from '@/lib/score-utils'; -import type { Insight, InsightMetadata, Session } from '@/lib/types'; +import type { Insight, InsightMetadata, Session, Message } from '@/lib/types'; import { Badge } from '@/components/ui/badge'; import { ErrorCard } from '@/components/ErrorCard'; import { Button } from '@/components/ui/button'; @@ -39,7 +39,7 @@ import { AnalyzeDropdown } from '@/components/analysis/AnalyzeDropdown'; import { AnalyzeButton } from '@/components/analysis/AnalyzeButton'; import { useAnalysis } from '@/components/analysis/AnalysisContext'; import { useMissingFacets, useBackfillFacets } from '@/hooks/useFacets'; -import { ExportGenerateRequest, exportGenerateStream } from '@/lib/api'; +import { ExportGenerateRequest, exportGenerateStream, fetchMessages, fetchTurnCitation } from '@/lib/api'; import { exportSession } from '@/lib/export-session'; import { CollapsibleInsightItem } from '@/components/sessions/CollapsibleInsightItem'; import { PromptQualityAnalyzeButton } from '@/components/sessions/PromptQualityAnalyzeButton'; @@ -81,6 +81,9 @@ export function SessionDetailPanel({ sessionId, onDelete }: SessionDetailPanelPr const [searchHighlightId, setSearchHighlightId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [loadingAllMessages, setLoadingAllMessages] = useState(false); + const [activeTab, setActiveTab] = useState('insights'); + const [citationWindow, setCitationWindow] = useState(null); + const [citationHighlightId, setCitationHighlightId] = useState(null); const { getAnalysisState } = useAnalysis(); // Show cost indicator when either analysis type is actively running const sessionAnalysisState = getAnalysisState(sessionId, 'session'); @@ -128,6 +131,26 @@ export function SessionDetailPanel({ sessionId, onDelete }: SessionDetailPanelPr setLoadingAllMessages(false); }, [messagesQuery, loadingAllMessages]); + const openTurnCitation = useCallback(async (ref: string) => { + const parsed = /^(User|Assistant)#(0|[1-9][0-9]*)$/.exec(ref); + if (!parsed) return; + try { + const role = parsed[1] as 'User' | 'Assistant'; + const resolved = await fetchTurnCitation(sessionId, role, Number(parsed[2])); + // Jump directly to a single page near the cited message, not all history. + const offset = Math.floor(resolved.offset / 50) * 50; + const page = await fetchMessages(sessionId, { limit: 50, offset }); + if (!page.messages.some(m => m.id === resolved.messageId)) { + throw new Error('The cited message changed during loading.'); + } + setCitationWindow(page.messages); + setCitationHighlightId(resolved.messageId); + setActiveTab('conversation'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Unable to resolve citation'); + } + }, [sessionId]); + const prLinks = useMemo(() => { const linkSet = new Set(); const prUrlPattern = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g; @@ -397,7 +420,7 @@ export function SessionDetailPanel({ sessionId, onDelete }: SessionDetailPanelPr {/* Tabs: Insights | Prompt Quality | Conversation */} - + Insights{nonPromptInsights.length > 0 && ` (${nonPromptInsights.length})`} @@ -567,7 +590,7 @@ export function SessionDetailPanel({ sessionId, onDelete }: SessionDetailPanelPr
{learningInsights.map((insight) => ( - + ))}
@@ -588,7 +611,7 @@ export function SessionDetailPanel({ sessionId, onDelete }: SessionDetailPanelPr
{decisionInsights.map((insight) => ( - + ))}
@@ -623,23 +646,32 @@ export function SessionDetailPanel({ sessionId, onDelete }: SessionDetailPanelPr value="conversation" className="flex flex-col flex-1 overflow-hidden mt-0 bg-muted/40 dark:bg-muted/20" > - + Showing the cited conversation page + + + )} + {!citationWindow && + />}
messagesQuery.fetchNextPage()} + messages={citationWindow ?? messages} + loading={citationWindow ? false : loadingMessages} + loadingMore={citationWindow ? false : loadingMore} + hasMore={citationWindow ? false : hasMore} + onLoadMore={citationWindow ? undefined : () => messagesQuery.fetchNextPage()} sourceTool={session.source_tool ?? undefined} - highlightMessageId={searchHighlightId} - searchQuery={searchQuery} + highlightMessageId={citationWindow ? citationHighlightId : searchHighlightId} + searchQuery={citationWindow ? undefined : searchQuery} />
diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 5893be62..43e7da52 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -122,6 +122,13 @@ export function fetchMessages(sessionId: string, params?: { limit?: number; offs return request<{ messages: Message[] }>(`/messages/${sessionId}${qs}`); } +/** Backend resolver uses the same zero-based User/Assistant numbering as the LLM prompt. */ +export function fetchTurnCitation(sessionId: string, role: 'User' | 'Assistant', index: number) { + return request<{ messageId: string; offset: number; ref: string }>( + `/messages/${encodeURIComponent(sessionId)}/citation/${role}/${index}` + ); +} + // ── Insights ────────────────────────────────────────────────────────────────── export function fetchInsights(params?: { diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index 623516ea..c918968a 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -282,6 +282,9 @@ export interface InsightMetadata { trade_offs?: string; revisit_when?: string; evidence?: string[]; + decided_by?: 'user' | 'agent' | 'collaborative' | 'unknown'; + intent?: string; + branch_point?: string; // Learning fields symptom?: string; root_cause?: string; diff --git a/server/src/routes/messages.ts b/server/src/routes/messages.ts index 3cdaecbe..96fdd2d0 100644 --- a/server/src/routes/messages.ts +++ b/server/src/routes/messages.ts @@ -1,6 +1,7 @@ import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'; import { getDb } from '@code-insights/cli/db/client'; import { parseIntParam } from '../utils.js'; +import { resolveTurnReference } from '@code-insights/cli/analysis/turn-citations'; import { MessageSchema, MessagesResponseSchema, @@ -39,10 +40,48 @@ app.openapi(listRoute, (c) => { tool_calls, tool_results, usage, timestamp, parent_id FROM messages WHERE session_id = ? - ORDER BY timestamp ASC + ORDER BY timestamp ASC, rowid ASC LIMIT ? OFFSET ? `).all(c.req.param('sessionId'), parseIntParam(limit, 100), parseIntParam(offset, 0)) as z.infer[]; return c.json({ messages }); }); +const citationRoute = createRoute({ + method: 'get', + path: '/{sessionId}/citation/{role}/{index}', + request: { params: z.object({ + sessionId: z.string().min(1), + role: z.enum(['User', 'Assistant']), + index: z.string().regex(/^(0|[1-9][0-9]*)$/), + }) }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ + messageId: z.string(), offset: z.number().int().nonnegative(), ref: z.string(), + }) } }, + description: 'Resolved role-ordinal citation', + }, + 404: { + content: { 'application/json': { schema: z.object({ error: z.string() }) } }, + description: 'Session or turn not found', + }, + }, +}); + +app.openapi(citationRoute, (c) => { + const { sessionId, role, index } = c.req.valid('param'); + const db = getDb(); + const exists = db.prepare('SELECT id FROM sessions WHERE id = ? AND deleted_at IS NULL').get(sessionId); + if (!exists) return c.json({ error: 'Session not found' }, 404); + + // Use the same role indexing as formatMessagesForAnalysis; load one session only. + const messages = db.prepare(` + SELECT id, type, content FROM messages + WHERE session_id = ? ORDER BY timestamp ASC, rowid ASC + `).all(sessionId) as Array<{ id: string; type: string; content: string }>; + const result = resolveTurnReference(messages, role, Number(index)); + if (!result) return c.json({ error: 'Turn reference not found' }, 404); + return c.json({ ...result, ref: `${role}#${index}` }, 200); +}); + export default app; diff --git a/server/src/routes/route-helpers.ts b/server/src/routes/route-helpers.ts index b3387062..fbfc4cc1 100644 --- a/server/src/routes/route-helpers.ts +++ b/server/src/routes/route-helpers.ts @@ -33,7 +33,7 @@ export function loadSessionForAnalysis(db: ReturnType, sessionId: export function loadSessionMessages(db: ReturnType, sessionId: string): SQLiteMessageRow[] { return db.prepare(` SELECT id, session_id, type, content, thinking, tool_calls, tool_results, usage, timestamp, parent_id - FROM messages WHERE session_id = ? ORDER BY timestamp ASC + FROM messages WHERE session_id = ? ORDER BY timestamp ASC, rowid ASC `).all(sessionId) as SQLiteMessageRow[]; }