From cbe0a77f3d771a97b8e03f6048e14bd53d2f0258 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:41:23 +0800 Subject: [PATCH] fix(transcript): preserve steer provenance (#3374) * fix(transcript): preserve bundled prompt origin * refactor(transcript): narrow steer origin projection --- .../src/services/transcript/coreEventMap.ts | 96 ++++++++---- .../test/services/transcript.test.ts | 141 +++++++++++++++++- packages/transcript/src/contract/origin.ts | 20 +++ packages/transcript/src/contract/schema.ts | 20 ++- packages/transcript/src/history/groupTurns.ts | 23 ++- packages/transcript/src/index.ts | 1 + packages/transcript/src/model/frame.ts | 25 +++- packages/transcript/test/layers.test.ts | 28 ++++ 8 files changed, 313 insertions(+), 41 deletions(-) create mode 100644 packages/transcript/src/contract/origin.ts diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index a46e602d1..68f93a0ad 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -56,27 +56,29 @@ import type { SubagentSpawned, SubagentStarted, } from '@moonshot-ai/agent-core-v2/session/subagent/mirrorAgentRun'; -import type { - AgentRef, - AgentUsageMeta, - StepHeader, - StepUsage, - TextFrame, - ToolCallFrame, - ToolFrameProgress, - TranscriptAttachment, - TranscriptFrame, - TranscriptInteraction, - TranscriptItem, - TranscriptMarker, - TranscriptOperation, - TranscriptPrompt, - TranscriptTask, - TranscriptTodo, - TranscriptUsage, - TurnHeader, - TurnOrigin, - TurnState, +import { + projectTranscriptUserOrigin, + type AgentRef, + type AgentUsageMeta, + type StepHeader, + type StepUsage, + type TextFrame, + type ToolCallFrame, + type ToolFrameProgress, + type TranscriptAttachment, + type TranscriptFrame, + type TranscriptInteraction, + type TranscriptItem, + type TranscriptMarker, + type TranscriptOperation, + type TranscriptPrompt, + type TranscriptTask, + type TranscriptTodo, + type TranscriptUsage, + type TranscriptUserOrigin, + type TurnHeader, + type TurnOrigin, + type TurnState, } from '@moonshot-ai/transcript'; import { toLegacyPhase } from '../legacyStatus/legacyStatus'; @@ -189,7 +191,11 @@ export class AgentTranscriptProjector { private currentTurn: TurnHeader | undefined; private currentStep: StepHeader | undefined; private pendingTaskNotifications: { text: string; taskId: string | undefined }[] = []; - private pendingSteers: { input: readonly ContentPart[]; promptIds: readonly string[] | undefined }[] = []; + private pendingSteers: { + input: readonly ContentPart[]; + promptIds: readonly string[] | undefined; + origin: TranscriptUserOrigin; + }[] = []; private unpairedSteerPromptIds: string[][] = []; private readonly stepOrdinals = new Map(); private frameOrdinal = 0; @@ -401,9 +407,30 @@ export class AgentTranscriptProjector { this.currentStep = step; ops.push({ op: 'step.upsert', turnId: step.turnId, step }); } + if (this.currentStep === undefined && this.pendingSteers.length > 0) { + const ordinal = (this.stepOrdinals.get(turnId) ?? this.lookups?.stepOrdinal?.(turnId) ?? 0) + 1; + const step: StepHeader = { + kind: 'step', + stepId: `${turnId}.${ordinal}`, + turnId, + ordinal, + state: 'interrupted', + endedAt: nowIso(), + }; + this.stepOrdinals.set(turnId, ordinal); + this.currentStep = step; + ops.push({ op: 'step.upsert', turnId, step }); + } if (this.currentStep !== undefined) { for (const pending of this.pendingSteers) { - this.steerUserFrame(ops, turnId, this.currentStep.stepId, pending.input, pending.promptIds); + this.steerUserFrame( + ops, + turnId, + this.currentStep.stepId, + pending.input, + pending.promptIds, + pending.origin, + ); } } this.pendingSteers = []; @@ -490,7 +517,7 @@ export class AgentTranscriptProjector { } this.pendingTaskNotifications = []; for (const pending of this.pendingSteers) { - this.steerUserFrame(ops, turnId, stepId, pending.input, pending.promptIds); + this.steerUserFrame(ops, turnId, stepId, pending.input, pending.promptIds, pending.origin); } this.pendingSteers = []; return ops; @@ -1386,7 +1413,9 @@ export class AgentTranscriptProjector { private onTurnSteered(event: TurnSteerEvent): TranscriptOperation[] { const origin = event.origin; - if (origin?.kind !== 'user') return []; + if (origin.kind !== 'user') return []; + const frameOrigin = projectTranscriptUserOrigin(origin); + if (frameOrigin === undefined) return []; const turn = this.currentTurn; if (turn !== undefined && turn.state !== 'running') return []; const skip = origin.skillActivations?.length ?? 0; @@ -1394,10 +1423,21 @@ export class AgentTranscriptProjector { const step = this.currentStep; if (step !== undefined && step.state === 'running') { const ops: TranscriptOperation[] = []; - this.steerUserFrame(ops, step.turnId, step.stepId, input, this.unpairedSteerPromptIds.shift()); + this.steerUserFrame( + ops, + step.turnId, + step.stepId, + input, + this.unpairedSteerPromptIds.shift(), + frameOrigin, + ); return ops; } - this.pendingSteers.push({ input, promptIds: this.unpairedSteerPromptIds.shift() }); + this.pendingSteers.push({ + input, + promptIds: this.unpairedSteerPromptIds.shift(), + origin: frameOrigin, + }); return []; } @@ -1407,6 +1447,7 @@ export class AgentTranscriptProjector { stepId: string, input: readonly ContentPart[], promptIds: readonly string[] | undefined, + origin: TranscriptUserOrigin, ): void { const texts: string[] = []; const attachmentIds: string[] = []; @@ -1436,6 +1477,7 @@ export class AgentTranscriptProjector { text: texts.join(''), attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, promptIds, + origin, }, }); } diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 9fb9acc22..be8b7f13c 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -2093,6 +2093,7 @@ describe('AgentTranscriptProjector', () => { role: 'user', text: 'steered in', promptIds: ['p2'], + origin: { kind: 'user' }, }); }); @@ -2127,13 +2128,27 @@ describe('AgentTranscriptProjector', () => { ev({ type: 'turn.steer', input: [ + { type: 'text', text: 'private instructions' }, + { type: 'text', text: 'private instructions' }, { type: 'text', text: 'look at this' }, { type: 'image_url', imageUrl: { url: 'kimi-file://f_img9?path=%2Fabs%2Fsession%2Fmedia%2Ff_img9.png' }, }, ], - origin: { kind: 'user' }, + origin: { + kind: 'user', + skillActivations: [ + { activationId: 'a1', skillName: 'deploy', skillPath: '/private/deploy/SKILL.md' }, + { activationId: 'a2', skillName: 'review', skillArgs: 'strict', skillPath: '/private/review/SKILL.md' }, + ], + attachments: [{ + name: 'secret.txt', + mediaType: 'text/plain', + size: 12, + path: '/private/secret.txt', + }], + }, }), ); @@ -2142,7 +2157,20 @@ describe('AgentTranscriptProjector', () => { attachment: { mediaType: 'image/*', source: { kind: 'session_media', fileId: 'f_img9' } }, }); const frame = turnOps('t4', tx.getItems()).steps[0]?.frames[0]; - expect(frame).toMatchObject({ kind: 'text', role: 'user', text: 'look at this', promptIds: ['p2', 'p3'] }); + expect(frame).toMatchObject({ + kind: 'text', + role: 'user', + text: 'look at this', + promptIds: ['p2', 'p3'], + origin: { + kind: 'user', + skillActivations: [ + { skillName: 'deploy' }, + { skillName: 'review', skillArgs: 'strict' }, + ], + }, + }); + expect(JSON.stringify(frame)).not.toContain('/private/'); expect(frame?.kind === 'text' ? frame.attachmentIds : undefined).toEqual([ attachmentOp?.op === 'attachment.upsert' ? attachmentOp.attachment.attachmentId : undefined, ]); @@ -2211,6 +2239,52 @@ describe('AgentTranscriptProjector', () => { role: 'user', text: 'last word', promptIds: ['p2'], + origin: { kind: 'user' }, + }); + }); + + it('flushes a pending steer into a user-only step when the turn ends before its first step', () => { + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); + const tx = new AgentTranscript('main'); + const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + + feed(ev({ type: 'turn.started', turnId: 7, origin: { kind: 'user' }, prompt: 'active' })); + feed( + ev({ + type: 'prompt.steered', + activePromptId: 'p1', + promptIds: ['p2'], + content: [{ type: 'text', text: 'last word' }], + steeredAt: '2026-01-01T00:00:02.000Z', + }), + ); + feed( + ev({ + type: 'turn.steer', + input: [ + { type: 'text', text: 'private instructions' }, + { type: 'text', text: 'last word' }, + ], + origin: { + kind: 'user', + skillActivations: [{ activationId: 'a1', skillName: 'review', skillArgs: 'strict' }], + }, + }), + ); + feed(ev({ type: 'turn.ended', turnId: 7, reason: 'cancelled', interruptReason: 'user_cancelled' })); + + const turn = turnOps('t7', tx.getItems()); + expect(turn.steps).toHaveLength(1); + expect(turn.steps[0]).toMatchObject({ state: 'interrupted' }); + expect(turn.steps[0]?.frames[0]).toMatchObject({ + kind: 'text', + role: 'user', + text: 'last word', + promptIds: ['p2'], + origin: { + kind: 'user', + skillActivations: [{ skillName: 'review', skillArgs: 'strict' }], + }, }); }); @@ -2246,7 +2320,13 @@ describe('AgentTranscriptProjector', () => { expect(frameOp).toMatchObject({ turnId: 't3', stepId: 't3.2', - frame: { kind: 'text', role: 'user', text: 'steered mid-attach', promptIds: ['p2'] }, + frame: { + kind: 'text', + role: 'user', + text: 'steered mid-attach', + promptIds: ['p2'], + origin: { kind: 'user' }, + }, }); }); @@ -2583,12 +2663,67 @@ describe('AgentTranscriptProjector', () => { kind: 'text', role: 'user', text: 'steered in', + origin: { kind: 'user' }, }); } finally { await rm(home, { recursive: true, force: true }); } }); + it('readColdSnapshot preserves safe bundled skill provenance before the first step', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-cold-bundled-steer-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + const origin = { + kind: 'user', + skillActivations: [ + { activationId: 'a1', skillName: 'deploy', skillPath: '/private/deploy/SKILL.md' }, + { activationId: 'a2', skillName: 'review', skillArgs: 'strict', skillPath: '/private/review/SKILL.md' }, + ], + attachments: [{ + name: 'secret.txt', + mediaType: 'text/plain', + size: 12, + path: '/private/secret.txt', + }], + }; + const content = [ + { type: 'text', text: 'private instructions' }, + { type: 'text', text: 'private instructions' }, + { type: 'text', text: 'steered in' }, + ]; + const records = [ + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }, + { type: 'turn.steer', input: content, origin, time: 3000 }, + { type: 'context.append_message', message: { role: 'user', content, toolCalls: [], origin }, time: 3001 }, + ]; + await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); + + const snapshot = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); + const turn = snapshot?.items.find((item) => item.kind === 'turn'); + if (turn?.kind !== 'turn') throw new Error('expected turn'); + const frame = turn.steps.flatMap((step) => step.frames).find( + (candidate) => candidate.kind === 'text' && candidate.role === 'user', + ); + expect(frame).toMatchObject({ + kind: 'text', + role: 'user', + text: 'steered in', + origin: { + kind: 'user', + skillActivations: [ + { skillName: 'deploy' }, + { skillName: 'review', skillArgs: 'strict' }, + ], + }, + }); + expect(JSON.stringify(frame)).not.toContain('/private/'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('readColdSnapshot keeps skill-activation steers as skill markers instead of user frames', async () => { const home = await mkdtemp(join(tmpdir(), 'transcript-cold-skillsteer-')); try { diff --git a/packages/transcript/src/contract/origin.ts b/packages/transcript/src/contract/origin.ts new file mode 100644 index 000000000..926aa3502 --- /dev/null +++ b/packages/transcript/src/contract/origin.ts @@ -0,0 +1,20 @@ +import type { TranscriptSkillActivation, TranscriptUserOrigin } from '../model/frame'; + +export function projectTranscriptUserOrigin(origin: unknown): TranscriptUserOrigin | undefined { + const candidate = origin as { readonly kind?: unknown; readonly skillActivations?: unknown } | undefined; + if (candidate?.kind !== 'user') return undefined; + if (!Array.isArray(candidate.skillActivations)) return { kind: 'user' }; + const skillActivations = candidate.skillActivations.flatMap((activation): TranscriptSkillActivation[] => { + if (typeof activation !== 'object' || activation === null) return []; + const value = activation as { readonly skillName?: unknown; readonly skillArgs?: unknown }; + if (typeof value.skillName !== 'string') return []; + return [{ + skillName: value.skillName, + skillArgs: typeof value.skillArgs === 'string' ? value.skillArgs : undefined, + }]; + }); + return { + kind: 'user', + skillActivations: skillActivations.length > 0 ? skillActivations : undefined, + }; +} diff --git a/packages/transcript/src/contract/schema.ts b/packages/transcript/src/contract/schema.ts index d5110cb22..2d3e2ad3c 100644 --- a/packages/transcript/src/contract/schema.ts +++ b/packages/transcript/src/contract/schema.ts @@ -62,15 +62,29 @@ export const stepRetrySchema = z.object({ export const turnStateSchema = z.enum(['queued', 'running', 'completed', 'failed', 'cancelled']); export const stepStateSchema = z.enum(['running', 'completed', 'interrupted', 'failed']); -export const textFrameSchema = z.object({ +export const transcriptSkillActivationSchema = z.object({ + skillName: z.string(), + skillArgs: z.string().optional(), +}); + +export const transcriptUserOriginSchema = z.object({ + kind: z.literal('user'), + skillActivations: z.array(transcriptSkillActivationSchema).optional(), +}); + +const textFrameShape = { kind: z.literal('text'), frameId: frameIdSchema, - role: z.enum(['assistant', 'user']), text: z.string(), attachmentIds: z.array(z.string()).optional(), taskId: taskIdSchema.optional(), promptIds: z.array(z.string()).optional(), -}); +}; + +export const textFrameSchema = z.discriminatedUnion('role', [ + z.object({ ...textFrameShape, role: z.literal('assistant'), origin: z.never().optional() }), + z.object({ ...textFrameShape, role: z.literal('user'), origin: transcriptUserOriginSchema.optional() }), +]); export const thinkingFrameSchema = z.object({ kind: z.literal('thinking'), diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 6e41bb279..ed8dc6fda 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -1,9 +1,10 @@ import type { AgentTranscriptSnapshot } from '../ops/operation'; import type { TranscriptAttachment } from '../model/attachment'; -import type { TranscriptFrame } from '../model/frame'; +import type { TranscriptFrame, TranscriptUserOrigin } from '../model/frame'; import type { TranscriptItem, TranscriptMarker } from '../model/item'; import type { TurnOrigin } from '../model/turn'; import { daemonFileRefFromPairingPart } from '../contract/mediaRef'; +import { projectTranscriptUserOrigin } from '../contract/origin'; export type HistoryMediaSource = | { readonly kind: 'url'; readonly url: string } @@ -81,6 +82,7 @@ export function groupMessagesIntoSnapshot( taskId: string | undefined; attachmentIds?: string[]; promptIds?: readonly string[]; + origin?: TranscriptUserOrigin; steered?: boolean; }[] = []; let nextOrdinal = 0; @@ -166,10 +168,16 @@ export function groupMessagesIntoSnapshot( if (leftovers.length === 0) return; pendingNotificationFrames = pendingNotificationFrames.filter((pending) => !pending.steered); for (const pending of leftovers) { - const lastStep = turn?.steps.at(-1); - if (turn === undefined || lastStep === undefined) { - startTurn({ kind: 'user' }, pending.text, pending.attachmentIds); - continue; + const targetTurn = turn ?? startTurn({ kind: 'user' }); + let lastStep = targetTurn.steps.at(-1); + if (lastStep === undefined) { + const ordinal = targetTurn.steps.length + 1; + lastStep = { + stepId: `${targetTurn.turnId}.${ordinal}`, + ordinal, + frames: [], + }; + targetTurn.steps.push(lastStep); } lastStep.frames.push({ kind: 'text', @@ -178,8 +186,9 @@ export function groupMessagesIntoSnapshot( text: pending.text, attachmentIds: pending.attachmentIds, promptIds: pending.promptIds, + origin: pending.origin, }); - syncTurnItem(items, turn); + syncTurnItem(items, targetTurn); } }; @@ -244,6 +253,7 @@ export function groupMessagesIntoSnapshot( text: opening.text, taskId: undefined, attachmentIds: opening.attachmentIds, + origin: projectTranscriptUserOrigin(message.origin), steered: true, }); continue; @@ -315,6 +325,7 @@ export function groupMessagesIntoSnapshot( taskId: pending.taskId, attachmentIds: pending.attachmentIds, promptIds: pending.promptIds, + origin: pending.origin, }); } pendingNotificationFrames = []; diff --git a/packages/transcript/src/index.ts b/packages/transcript/src/index.ts index 1ae1ae011..affc4316c 100644 --- a/packages/transcript/src/index.ts +++ b/packages/transcript/src/index.ts @@ -22,3 +22,4 @@ export * from './history/foldFacts'; export * from './contract/schema'; export * from './contract/events'; export * from './contract/mediaRef'; +export * from './contract/origin'; diff --git a/packages/transcript/src/model/frame.ts b/packages/transcript/src/model/frame.ts index 035bbdb03..d57410914 100644 --- a/packages/transcript/src/model/frame.ts +++ b/packages/transcript/src/model/frame.ts @@ -7,16 +7,37 @@ export type FrameRef = { readonly frameId: FrameId; }; -export interface TextFrame { +export interface TranscriptSkillActivation { + readonly skillName: string; + readonly skillArgs?: string; +} + +export interface TranscriptUserOrigin { + readonly kind: 'user'; + readonly skillActivations?: readonly TranscriptSkillActivation[]; +} + +interface TextFrameBase { readonly kind: 'text'; readonly frameId: FrameId; - readonly role: 'assistant' | 'user'; readonly text: string; readonly attachmentIds?: readonly AttachmentId[]; readonly taskId?: TaskId; readonly promptIds?: readonly string[]; } +export interface AssistantTextFrame extends TextFrameBase { + readonly role: 'assistant'; + readonly origin?: never; +} + +export interface UserTextFrame extends TextFrameBase { + readonly role: 'user'; + readonly origin?: TranscriptUserOrigin; +} + +export type TextFrame = AssistantTextFrame | UserTextFrame; + export interface ThinkingFrame { readonly kind: 'thinking'; readonly frameId: FrameId; diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index 8fb5a5013..f887de0f9 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -372,6 +372,34 @@ describe('contract schemas', () => { } }); + it('accepts provenance only on user text frames', () => { + const base = { + op: 'frame.upsert', + turnId: 't1', + stepId: 't1.1', + } as const; + expect(transcriptOperationSchema.safeParse({ + ...base, + frame: { + kind: 'text', + frameId: 't1.1.f1', + role: 'user', + text: 'steered in', + origin: { kind: 'user', skillActivations: [{ skillName: 'review', skillArgs: 'strict' }] }, + }, + }).success).toBe(true); + expect(transcriptOperationSchema.safeParse({ + ...base, + frame: { + kind: 'text', + frameId: 't1.1.f2', + role: 'assistant', + text: 'reply', + origin: { kind: 'user' }, + }, + }).success).toBe(false); + }); + it('rejects mutually exclusive cursors and bad grades', () => { expect(() => transcriptGradeSpecSchema.parse({ '*': 'stream' })).toThrow(); const ok = transcriptResponseSchema.safeParse({