diff --git a/.changeset/subagent-turn-prompts.md b/.changeset/subagent-turn-prompts.md new file mode 100644 index 00000000000..74d73971ec7 --- /dev/null +++ b/.changeset/subagent-turn-prompts.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/transcript": patch +--- + +Carry the orchestrator's prompt on subagent turns: `isDisplayablePromptOrigin` now accepts `system_trigger/subagent`, so live `turn.started` events include the prompt, and cold rebuild folds the opening input (text and attachments) into turns opened by subagent run messages. Other system triggers (goal_continuation, stop_hook, loadable-tools) remain promptless. diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index aa6ab6630fa..eaca52b92ff 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -69,6 +69,7 @@ export function turnPromptAttachments( export function isDisplayablePromptOrigin(origin: PromptOrigin): boolean { if (origin.kind === 'user') return true; + if (origin.kind === 'system_trigger' && origin.name === 'subagent') return true; return ( (origin.kind === 'skill_activation' || origin.kind === 'plugin_command') && origin.trigger === 'user-slash' 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 ba34b39b3a9..f67ef322616 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -844,6 +844,32 @@ describe('Agent loop', () => { expect(prompts).toEqual([undefined, 'hi']); }); + it('carries the turn.started prompt for subagent system triggers', async () => { + const prompts: Array = []; + const subscription = ctx.get(IEventBus).subscribe(TurnStarted, (event) => { + prompts.push(event.prompt); + }); + ctx.mockNextResponse({ type: 'text', text: 'scanned' }); + + const subagent = ( + await loop.enqueue( + new MessageStepRequest( + { + role: 'user', + content: [{ type: 'text', text: 'scan the repo' }], + toolCalls: [], + origin: { kind: 'system_trigger', name: 'subagent' }, + }, + { admission: 'newTurn' }, + ), + ).assigned + ).turn; + await subagent.result; + subscription.dispose(); + + expect(prompts).toEqual(['scan the repo']); + }); + it('carries kimi-file prompt attachments on turn.started, falling back to the URL file id', async () => { const payloads: Array = []; const subscription = ctx.get(IEventBus).subscribe(TurnStarted, (event) => { diff --git a/packages/kap-server/test/search/wireExtract.test.ts b/packages/kap-server/test/search/wireExtract.test.ts index 0405163d505..926fa961f6b 100644 --- a/packages/kap-server/test/search/wireExtract.test.ts +++ b/packages/kap-server/test/search/wireExtract.test.ts @@ -56,6 +56,14 @@ describe('extractFromWireLine', () => { }, ); + it('filters out subagent system triggers even though they open transcript turns', () => { + expect( + extractFromWireLine( + userRecord('scan the repo', 1_700_000_000_000, { kind: 'system_trigger', name: 'subagent' }), + ), + ).toEqual([]); + }); + it.each(['skill_activation', 'plugin_command'])( 'keeps %s messages the user typed as a slash command', (kind) => { diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 932377b0718..56450b6febf 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -175,6 +175,31 @@ describe('AgentTranscriptProjector', () => { expect(turn.state).toBe('completed'); }); + it('projects the live prompt for subagent system triggers and keeps it through turn.ended', () => { + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); + const tx = new AgentTranscript('main'); + const feed = (event: ProjectorBusEvent): void => { + tx.apply(projector.map(event)); + }; + + feed( + ev({ + type: 'turn.started', + turnId: 0, + origin: { kind: 'system_trigger', name: 'subagent' }, + prompt: 'scan the repo', + promptAttachments: [{ kind: 'image', fileId: 'file_1' }], + }), + ); + feed(ev({ type: 'assistant.delta', turnId: 0, delta: 'scanning' })); + feed(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); + + const turn = turnOps('t0', tx.getItems()); + expect(turn.prompt).toBe('scan the repo'); + expect(turn.attachmentIds).toEqual(['t0.att1']); + expect(turn.state).toBe('completed'); + }); + it('projects turn.started promptAttachments into attachment entities and turn.attachmentIds', () => { const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); diff --git a/packages/kap-server/test/transcript.test.ts b/packages/kap-server/test/transcript.test.ts index 8899d1879d1..631f6018310 100644 --- a/packages/kap-server/test/transcript.test.ts +++ b/packages/kap-server/test/transcript.test.ts @@ -1055,9 +1055,10 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { const main = byAgent.get('main')!; expect(main.messages.map((m) => [m.turn_id, m.prompt])).toEqual([ ['t0', 'hi'], + ['t1', 'subagent run prompt'], ['t2', 'second question'], ]); - expect(main.messages[1]!.attachment_ids).toEqual(['att_1']); + expect(main.messages[2]!.attachment_ids).toEqual(['att_1']); expect(main.attachments).toEqual([ expect.objectContaining({ attachmentId: 'att_1', diff --git a/packages/node-sdk/test/session-prompt-events.test.ts b/packages/node-sdk/test/session-prompt-events.test.ts index da1241431fe..8738327847d 100644 --- a/packages/node-sdk/test/session-prompt-events.test.ts +++ b/packages/node-sdk/test/session-prompt-events.test.ts @@ -14,7 +14,7 @@ import { KIMI_CODE_PLATFORM } from '@moonshot-ai/kimi-code-oauth'; import type * as KosongModule from '@moonshot-ai/kosong'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createKimiHarness, type Event, type KimiHarness } from '#/index'; +import { createKimiHarness, createKimiHarnessV2, type Event, type KimiHarness } from '#/index'; import { TEST_IDENTITY } from './test-identity'; @@ -347,6 +347,70 @@ describe('Session.prompt events', () => { } }); + it('carries the prompt on the public turn.started event for subagent system triggers (v2 engine)', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarnessV2({ + identity: TEST_IDENTITY, + homeDir, + }); + const sseChunk = (delta: Record, finishReason: string | null = null): string => + `data: ${JSON.stringify({ + id: 'chatcmpl-stub', + object: 'chat.completion.chunk', + created: 0, + model: 'fake-model', + choices: [{ index: 0, delta, finish_reason: finishReason }], + })}`; + const fetchStub = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === 'https://model.example.test/v1/chat/completions') { + const body = [sseChunk({ role: 'assistant', content: 'init done' }), sseChunk({}, 'stop'), 'data: [DONE]'] + .map((line) => line + '\n\n') + .join(''); + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + try { + await harness.setConfig({ + providers: { + local: { type: 'openai', baseUrl: 'https://model.example.test/v1', apiKey: 'sk-test' }, + }, + models: { + 'fake-model': { provider: 'local', model: 'fake-model', maxContextSize: 262144 }, + }, + defaultModel: 'fake-model', + }); + const session = await harness.createSession({ id: 'ses_init_rpc_v2', workDir }); + const events: Event[] = []; + const unsubscribe = session.onEvent((event) => { + events.push(event); + }); + + await session.init(); + unsubscribe(); + + const spawned = events.find((event) => event.type === 'subagent.spawned'); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.started', + sessionId: session.id, + agentId: spawned?.type === 'subagent.spawned' ? spawned.subagentId : undefined, + origin: { kind: 'system_trigger', name: 'subagent' }, + prompt: expect.stringContaining('Task requirements:'), + }), + ); + } finally { + fetchStub.mockRestore(); + await harness.close(); + } + }); + it('includes persisted subagent replay only when resume explicitly requests it', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index aaff109951e..0d90039cbca 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -199,7 +199,11 @@ export function groupMessagesIntoSnapshot( if (message.role === 'user') { if (originKind !== undefined && HIDDEN_USER_ORIGINS.has(originKind)) { if (opensOwnTurn(message)) { - startTurn(mapOrigin(message)); + const opening = + (message.origin as { name?: unknown }).name === 'subagent' + ? foldTurnOpeningInput(message) + : undefined; + startTurn(mapOrigin(message), opening?.text || undefined, opening?.attachmentIds); } continue; } diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index c457f9c3eb9..ce99d9d0eb9 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -991,7 +991,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { if (subTurn?.kind !== 'turn') throw new Error('expected turn'); expect(subTurn.ordinal).toBe(1); expect(subTurn.origin.kind).toBe('other'); - expect(subTurn.prompt).toBeUndefined(); + expect(subTurn.prompt).toBe('scan the repo'); expect(subTurn.steps).toHaveLength(1); });