From c00aad953a2326b3bfd6b57d0dec039da9cfbac9 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 19 Aug 2026 18:17:03 +0800 Subject: [PATCH 1/4] feat(agent-core-v2): carry prompt attachments on turn.started so the live transcript projects them --- .../src/agent/loop/loopService.ts | 2 ++ .../src/agent/loop/turnEvents.ts | 20 +++++++++++++++++++ packages/kap-server/src/lib/promptMedia.ts | 4 ++-- .../kap-server/src/protocol/events-zod.ts | 3 +++ packages/protocol/src/events.ts | 5 +++++ 5 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index f22e9ea17b0..c8fe3bfe9b3 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -59,6 +59,7 @@ import { isDisplayablePromptOrigin, ThinkingDelta, ToolCallDelta, + turnPromptAttachments, turnPromptText, TurnStarted, TurnStepCompleted, @@ -454,6 +455,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { turnId: job.turn.id, origin, prompt: isDisplayablePromptOrigin(origin) ? turnPromptText(job.seed.input, origin) : undefined, + promptAttachments: turnPromptAttachments(job.seed.input), }), ); void this.runTurn(job.turn, job.ready).then(job.result.resolve, job.result.reject); diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index 389a841523d..bd377fdc799 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -19,6 +19,7 @@ export interface TurnStartedPayload { readonly turnId: number; readonly origin: PromptOrigin; readonly prompt?: string; + readonly promptAttachments?: readonly { kind: 'image' | 'video' | 'audio'; fileId: string }[]; } export class TurnStarted extends Event2 { @@ -40,6 +41,25 @@ export function turnPromptText( return text.length > 0 ? text : undefined; } +/** Media parts carrying a session-media id become the turn's transcript + * attachments; parts without one (inline base64, bare URLs) cannot be + * referenced back and are skipped. */ +export function turnPromptAttachments( + input: readonly ContentPart[], +): TurnStartedPayload['promptAttachments'] { + const attachments: { kind: 'image' | 'video' | 'audio'; fileId: string }[] = []; + for (const part of input) { + if (part.type === 'image_url' && part.imageUrl.id !== undefined) { + attachments.push({ kind: 'image', fileId: part.imageUrl.id }); + } else if (part.type === 'video_url' && part.videoUrl.id !== undefined) { + attachments.push({ kind: 'video', fileId: part.videoUrl.id }); + } else if (part.type === 'audio_url' && part.audioUrl.id !== undefined) { + attachments.push({ kind: 'audio', fileId: part.audioUrl.id }); + } + } + return attachments.length > 0 ? attachments : undefined; +} + export function isDisplayablePromptOrigin(origin: PromptOrigin): boolean { if (origin.kind === 'user') return true; return ( diff --git a/packages/kap-server/src/lib/promptMedia.ts b/packages/kap-server/src/lib/promptMedia.ts index 721046d7f6c..dbcf398170f 100644 --- a/packages/kap-server/src/lib/promptMedia.ts +++ b/packages/kap-server/src/lib/promptMedia.ts @@ -74,10 +74,10 @@ export function contentToCoreParts(content: WireContent): ContentPart[] { if (part.type === 'text') parts.push({ type: 'text', text: part.text }); else if (part.type === 'image' && part.source.kind === 'url') parts.push({ type: 'image_url', imageUrl: { url: part.source.url, id: part.source.id } }); else if (part.type === 'image' && part.source.kind === 'base64') parts.push({ type: 'image_url', imageUrl: { url: `data:${part.source.media_type};base64,${part.source.data}` } }); - else if (part.type === 'image' && part.source.kind === 'session_media') parts.push({ type: 'image_url', imageUrl: { url: buildDaemonFileUrl(part.source.file_id) } }); + else if (part.type === 'image' && part.source.kind === 'session_media') parts.push({ type: 'image_url', imageUrl: { url: buildDaemonFileUrl(part.source.file_id), id: part.source.file_id } }); else if (part.type === 'video' && part.source.kind === 'url') parts.push({ type: 'video_url', videoUrl: { url: part.source.url, id: part.source.id } }); else if (part.type === 'video' && part.source.kind === 'base64') parts.push({ type: 'video_url', videoUrl: { url: `data:${part.source.media_type};base64,${part.source.data}` } }); - else if (part.type === 'video' && part.source.kind === 'session_media') parts.push({ type: 'video_url', videoUrl: { url: buildDaemonFileUrl(part.source.file_id) } }); + else if (part.type === 'video' && part.source.kind === 'session_media') parts.push({ type: 'video_url', videoUrl: { url: buildDaemonFileUrl(part.source.file_id), id: part.source.file_id } }); } return parts; } diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 87738513cb1..f1200088b04 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -696,6 +696,9 @@ export const turnStartedEventSchema = z.object({ origin: promptOriginSchema, prompt: z.string().optional(), promptId: z.string().optional(), + promptAttachments: z + .array(z.object({ kind: z.enum(['image', 'video', 'audio']), fileId: z.string() })) + .optional(), }); export const turnEndedEventSchema = z.object({ diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index b0c61884771..2a3608e5e20 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -682,6 +682,8 @@ export interface TurnStartedEvent { readonly prompt?: string; /** The prompt record id when the turn was opened by a prompt submission. */ readonly promptId?: string; + /** Session-media references carried by the prompt (transcript attachments). */ + readonly promptAttachments?: readonly { kind: 'image' | 'video' | 'audio'; fileId: string }[]; } export interface TurnEndedEvent { @@ -1647,6 +1649,9 @@ export const turnStartedEventSchema = z.object({ origin: promptOriginSchema, prompt: z.string().optional(), promptId: z.string().optional(), + promptAttachments: z + .array(z.object({ kind: z.enum(['image', 'video', 'audio']), fileId: z.string() })) + .optional(), }) satisfies z.ZodType; export const turnEndedEventSchema = z.object({ From 43c79349f8a5008bfecf28daf8bf10d466d1ca08 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 19 Aug 2026 18:17:03 +0800 Subject: [PATCH 2/4] fix(transcript): clear the transcript goal when the goal is cleared --- .../transcript-live-attachments-goal-clear.md | 5 +++ .../test/agent/loop/loop.test.ts | 36 +++++++++++++++++++ .../src/services/transcript/coreEventMap.ts | 4 ++- .../test/services/transcript.test.ts | 4 ++- packages/transcript/src/contract/schema.ts | 2 ++ packages/transcript/src/model/meta.ts | 5 +-- packages/transcript/src/ops/apply.ts | 2 +- 7 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 .changeset/transcript-live-attachments-goal-clear.md diff --git a/.changeset/transcript-live-attachments-goal-clear.md b/.changeset/transcript-live-attachments-goal-clear.md new file mode 100644 index 00000000000..aede1c42b16 --- /dev/null +++ b/.changeset/transcript-live-attachments-goal-clear.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Project prompt attachments into the live transcript (turn.started now carries session-media references) and clear the transcript goal when the goal is cleared. diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index feec8518e44..029a6008a98 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -786,6 +786,42 @@ describe('Agent loop', () => { expect(prompts).toEqual([undefined, 'hi']); }); + + it('carries session-media prompt attachments on turn.started', async () => { + const payloads: Array = []; + const subscription = ctx.get(IEventBus).subscribe(TurnStarted, (event) => { + payloads.push(event.promptAttachments); + }); + ctx.mockNextResponse({ type: 'text', text: 'seen' }); + + const turn = ( + await loop.enqueue( + new MessageStepRequest( + { + role: 'user', + content: [ + { type: 'image_url', imageUrl: { url: 'daemon://files/file_1', id: 'file_1' } }, + { type: 'video_url', videoUrl: { url: 'daemon://files/file_2', id: 'file_2' } }, + { type: 'image_url', imageUrl: { url: 'https://example.com/no-id.png' } }, + { type: 'text', text: 'look' }, + ], + toolCalls: [], + origin: { kind: 'user' }, + }, + { admission: 'newTurn' }, + ), + ).assigned + ).turn; + await turn.result; + subscription.dispose(); + + expect(payloads).toEqual([ + [ + { kind: 'image', fileId: 'file_1' }, + { kind: 'video', fileId: 'file_2' }, + ], + ]); + }); }); describe('turn telemetry', () => { diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index ed740453fef..ce400e78e56 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -1081,7 +1081,9 @@ export class AgentTranscriptProjector { }): TranscriptOperation[] { const ops: TranscriptOperation[] = []; const snapshot = event.snapshot; - if (snapshot !== null) { + if (snapshot === null) { + ops.push({ op: 'meta.merge', meta: { goal: null } }); + } else { ops.push({ op: 'meta.merge', meta: { diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 81ed9a8bed9..b4c729edc94 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -997,7 +997,9 @@ describe('AgentTranscriptProjector', () => { expect(marker).toMatchObject({ marker: 'goal', payload: { snapshot } }); const clearedOps = projector.map(ev({ type: 'goal.updated', snapshot: null })); - expect(clearedOps.every((op) => op.op === 'marker.upsert')).toBe(true); + expect(clearedOps[0]).toEqual({ op: 'meta.merge', meta: { goal: null } }); + tx.apply(clearedOps); + expect(tx.getMeta().goal).toBeUndefined(); }); it('mirrors plan / swarm mode slices into meta.modes (only when provided)', () => { diff --git a/packages/transcript/src/contract/schema.ts b/packages/transcript/src/contract/schema.ts index cb0f9201609..a3ff6bba2c3 100644 --- a/packages/transcript/src/contract/schema.ts +++ b/packages/transcript/src/contract/schema.ts @@ -322,7 +322,9 @@ export const transcriptMetaSchema = z.object({ agent: agentStatusMetaSchema.optional(), }); +/** `goal` set to `null` in a merge clears the goal (same convention as mode keys). */ export const transcriptMetaMergeSchema = transcriptMetaSchema.extend({ + goal: goalMetaSchema.nullable().optional(), modes: modesMetaMergeSchema.optional(), }); diff --git a/packages/transcript/src/model/meta.ts b/packages/transcript/src/model/meta.ts index 79e192af27d..38c45b3fe64 100644 --- a/packages/transcript/src/model/meta.ts +++ b/packages/transcript/src/model/meta.ts @@ -130,7 +130,8 @@ export interface TranscriptMeta { readonly agent?: AgentStatusMeta; } -/** Contract shape of a `meta.merge` payload — like {@link TranscriptMeta}, but mode keys may be `null` to clear. */ -export type TranscriptMetaMerge = Omit & { +/** Contract shape of a `meta.merge` payload — like {@link TranscriptMeta}, but mode keys and `goal` may be `null` to clear. */ +export type TranscriptMetaMerge = Omit & { readonly modes?: ModesMetaMerge; + readonly goal?: GoalMeta | null; }; diff --git a/packages/transcript/src/ops/apply.ts b/packages/transcript/src/ops/apply.ts index f46dbfc09bb..801a93ed4de 100644 --- a/packages/transcript/src/ops/apply.ts +++ b/packages/transcript/src/ops/apply.ts @@ -578,7 +578,7 @@ function applyMetaMerge(state: AgentState, meta: TranscriptMetaMerge): ApplyResu const agent = meta.agent !== undefined ? { ...state.meta.agent, ...meta.agent } : state.meta.agent; const next: TranscriptMeta = { - goal: meta.goal ?? state.meta.goal, + goal: meta.goal === null ? undefined : (meta.goal ?? state.meta.goal), activity: meta.activity ?? state.meta.activity, modes: modes !== undefined && modes.plan === undefined && modes.swarm === undefined ? undefined : modes, agent, From dea33adb7575c1d962565e2cc7ef8f06fae4b663 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 19 Aug 2026 18:24:12 +0800 Subject: [PATCH 3/4] fix(agent-core-v2): count a prompt media part as a transcript attachment only when its id matches its daemon file URL --- .../src/agent/loop/turnEvents.ts | 26 ++++++++++++------- .../test/agent/loop/loop.test.ts | 5 ++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index bd377fdc799..cfe340d5253 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -1,5 +1,6 @@ /* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ import type { PromptOrigin } from '#/agent/contextMemory/types'; +import { parseDaemonFileUrl } from '#/agent/media/mediaRef'; import { Event2 } from '#/app/event/event2'; import type { FinishReason } from '#/kosong/contract/provider'; import type { ContentPart, TextPart } from '#/kosong/contract/message'; @@ -41,20 +42,27 @@ export function turnPromptText( return text.length > 0 ? text : undefined; } -/** Media parts carrying a session-media id become the turn's transcript - * attachments; parts without one (inline base64, bare URLs) cannot be - * referenced back and are skipped. */ +/** Media parts become the turn's transcript attachments only when they point + * at a session upload — the id must match the part's daemon file URL (a + * provider-issued id on a remote URL is not a session-media file id). */ export function turnPromptAttachments( input: readonly ContentPart[], ): TurnStartedPayload['promptAttachments'] { const attachments: { kind: 'image' | 'video' | 'audio'; fileId: string }[] = []; + const sessionMediaFileId = (url: string, id: string | undefined): string | undefined => { + if (id === undefined) return undefined; + return parseDaemonFileUrl(url)?.fileId === id ? id : undefined; + }; for (const part of input) { - if (part.type === 'image_url' && part.imageUrl.id !== undefined) { - attachments.push({ kind: 'image', fileId: part.imageUrl.id }); - } else if (part.type === 'video_url' && part.videoUrl.id !== undefined) { - attachments.push({ kind: 'video', fileId: part.videoUrl.id }); - } else if (part.type === 'audio_url' && part.audioUrl.id !== undefined) { - attachments.push({ kind: 'audio', fileId: part.audioUrl.id }); + if (part.type === 'image_url') { + const fileId = sessionMediaFileId(part.imageUrl.url, part.imageUrl.id); + if (fileId !== undefined) attachments.push({ kind: 'image', fileId }); + } else if (part.type === 'video_url') { + const fileId = sessionMediaFileId(part.videoUrl.url, part.videoUrl.id); + if (fileId !== undefined) attachments.push({ kind: 'video', fileId }); + } else if (part.type === 'audio_url') { + const fileId = sessionMediaFileId(part.audioUrl.url, part.audioUrl.id); + if (fileId !== undefined) attachments.push({ kind: 'audio', fileId }); } } return attachments.length > 0 ? attachments : undefined; diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 029a6008a98..37b128a49bd 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -800,9 +800,10 @@ describe('Agent loop', () => { { role: 'user', content: [ - { type: 'image_url', imageUrl: { url: 'daemon://files/file_1', id: 'file_1' } }, - { type: 'video_url', videoUrl: { url: 'daemon://files/file_2', id: 'file_2' } }, + { type: 'image_url', imageUrl: { url: 'kimi-file://file_1', id: 'file_1' } }, + { type: 'video_url', videoUrl: { url: 'kimi-file://file_2', id: 'file_2' } }, { type: 'image_url', imageUrl: { url: 'https://example.com/no-id.png' } }, + { type: 'image_url', imageUrl: { url: 'ms://provider-blob', id: 'prov_1' } }, { type: 'text', text: 'look' }, ], toolCalls: [], From 3555289c2b78fd5d3a1b066939dbc73dc12b940e Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 19 Aug 2026 18:32:47 +0800 Subject: [PATCH 4/4] test(kap-server): expect the session-media file id on converted prompt parts --- packages/kap-server/test/prompts.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index 28391f03bb6..cf9221c777a 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -730,6 +730,7 @@ describe('server-v2 /api/v1 prompts', () => { type: 'image_url', imageUrl: { url: `kimi-file://${uploaded.id}`, + id: uploaded.id, }, }); });