Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 69 additions & 27 deletions packages/kap-server/src/services/transcript/coreEventMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1386,18 +1413,31 @@ 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;
const input = skip > 0 ? event.input.slice(skip) : event.input;
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 [];
}

Expand All @@ -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[] = [];
Expand Down Expand Up @@ -1436,6 +1477,7 @@ export class AgentTranscriptProjector {
text: texts.join(''),
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
promptIds,
origin,
},
});
}
Expand Down
141 changes: 138 additions & 3 deletions packages/kap-server/test/services/transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2093,6 +2093,7 @@ describe('AgentTranscriptProjector', () => {
role: 'user',
text: 'steered in',
promptIds: ['p2'],
origin: { kind: 'user' },
});
});

Expand Down Expand Up @@ -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',
}],
},
}),
);

Expand All @@ -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,
]);
Expand Down Expand Up @@ -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' }],
},
});
});

Expand Down Expand Up @@ -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' },
},
});
});

Expand Down Expand Up @@ -2583,7 +2663,62 @@ 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 });
}
Expand Down
20 changes: 20 additions & 0 deletions packages/transcript/src/contract/origin.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
20 changes: 17 additions & 3 deletions packages/transcript/src/contract/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading
Loading