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
6 changes: 6 additions & 0 deletions .changeset/subagent-turn-prompts.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/agent/loop/turnEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
liruifengv marked this conversation as resolved.
return (
(origin.kind === 'skill_activation' || origin.kind === 'plugin_command') &&
origin.trigger === 'user-slash'
Expand Down
26 changes: 26 additions & 0 deletions packages/agent-core-v2/test/agent/loop/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> = [];
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<readonly { kind: string; fileId: string }[] | undefined> = [];
const subscription = ctx.get(IEventBus).subscribe(TurnStarted, (event) => {
Expand Down
8 changes: 8 additions & 0 deletions packages/kap-server/test/search/wireExtract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
25 changes: 25 additions & 0 deletions packages/kap-server/test/services/transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion packages/kap-server/test/transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
66 changes: 65 additions & 1 deletion packages/node-sdk/test/session-prompt-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, unknown>, 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();
Expand Down
6 changes: 5 additions & 1 deletion packages/transcript/src/history/groupTurns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/transcript/test/layers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading