fix(transcript): preserve steer provenance (#3374)

* fix(transcript): preserve bundled prompt origin

* refactor(transcript): narrow steer origin projection
This commit is contained in:
Luyu Cheng 2026-08-30 22:41:23 +08:00 committed by GitHub
parent 56b5480ed0
commit cbe0a77f3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 313 additions and 41 deletions

View file

@ -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<string, number>();
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,
},
});
}

View file

@ -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: '<skill-loaded name="deploy">private instructions</skill-loaded>' },
{ type: 'text', text: '<skill-loaded name="review">private instructions</skill-loaded>' },
{ 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: '<skill-loaded name="review">private instructions</skill-loaded>' },
{ 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: '<skill-loaded name="deploy">private instructions</skill-loaded>' },
{ type: 'text', text: '<skill-loaded name="review">private instructions</skill-loaded>' },
{ 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 {

View file

@ -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,
};
}

View file

@ -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'),

View file

@ -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 = [];

View file

@ -22,3 +22,4 @@ export * from './history/foldFacts';
export * from './contract/schema';
export * from './contract/events';
export * from './contract/mediaRef';
export * from './contract/origin';

View file

@ -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;

View file

@ -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({