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
5 changes: 5 additions & 0 deletions .changeset/fix-hook-prompt-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix shifted turns and lost replies when a UserPromptSubmit hook injects text; hook JSON output without a "message" field is no longer added to the conversation.
5 changes: 5 additions & 0 deletions .changeset/hook-result-prompt-part.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix messages rendering out of place in sessions with a UserPromptSubmit hook.
47 changes: 35 additions & 12 deletions apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
createReplayRenderContext,
formatHookResultMessageForTranscript,
isTerminalBackgroundTask,
isUserPromptSubmitHookPart,
limitReplayRecordsByTurn,
REPLAY_TURN_LIMIT,
replayBackgroundProjection,
Expand All @@ -46,6 +47,7 @@ import {
pluginCommandFromOrigin,
toolCallFromReplayMessage,
toolResultOutput,
withoutUserPromptSubmitHookParts,
type BackgroundTaskNotificationOrigin,
type ReplayRenderContext,
type SkillActivationProjection,
Expand Down Expand Up @@ -410,6 +412,7 @@ export class SessionReplayRenderer {
if (message.origin?.kind === 'skill_activation' && message.origin.trigger === 'user-slash') {
this.advanceTurn(context);
}
this.renderHookParts(context, message);
return;
}
const pluginCommand = pluginCommandFromOrigin(message.origin);
Expand All @@ -418,6 +421,7 @@ export class SessionReplayRenderer {
if (message.origin?.kind === 'plugin_command' && message.origin.trigger === 'user-slash') {
this.advanceTurn(context);
}
this.renderHookParts(context, message);
return;
}

Expand All @@ -426,11 +430,26 @@ export class SessionReplayRenderer {
return;
}
this.advanceTurn(context);
this.renderHookParts(context, message);
this.host.appendTranscriptEntry(
replayEntry(context, 'user', contentPartsToText(message.content), 'plain'),
replayEntry(
context,
'user',
contentPartsToText(withoutUserPromptSubmitHookParts(message.content)),
'plain',
),
);
}

private renderHookParts(context: ReplayRenderContext, message: ContextMessage): void {
for (const part of message.content.filter(isUserPromptSubmitHookPart)) {
this.renderHookResultEntry(
context,
formatHookResultMessageForTranscript(part.text, 'UserPromptSubmit', false),
);
}
}

private renderBundledPrompt(
context: ReplayRenderContext,
message: ContextMessage,
Expand All @@ -444,8 +463,10 @@ export class SessionReplayRenderer {
for (const hookResult of hookResults) {
this.renderHookResult(context, hookResult);
}
this.renderHookParts(context, message);
const callerMessage = { ...message, content: withoutUserPromptSubmitHookParts(message.content) };
this.host.appendTranscriptEntry(
replayEntry(context, 'user', contentPartsToText(stripBundledSkillParts(message)), 'plain'),
replayEntry(context, 'user', contentPartsToText(stripBundledSkillParts(callerMessage)), 'plain'),
);
}

Expand Down Expand Up @@ -636,18 +657,20 @@ export class SessionReplayRenderer {

private renderHookResult(context: ReplayRenderContext, message: ContextMessage): void {
if (message.origin?.kind !== 'hook_result') return;
this.renderHookResultEntry(
context,
formatHookResultMessageForTranscript(
contentPartsToText(message.content),
message.origin.event,
message.origin.blocked === true,
),
);
}

private renderHookResultEntry(context: ReplayRenderContext, formatted: string): void {
this.flushAssistant(context);
this.host.appendTranscriptEntry({
...replayEntry(
context,
'assistant',
formatHookResultMessageForTranscript(
contentPartsToText(message.content),
message.origin.event,
message.origin.blocked === true,
),
'markdown',
),
...replayEntry(context, 'assistant', formatted, 'markdown'),
hookResult: true,
});
}
Expand Down
9 changes: 7 additions & 2 deletions apps/kimi-code/src/tui/utils/export-markdown.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ContentPart, ContextMessage, PromptOrigin, ToolCall } from '@moonshot-ai/kimi-code-sdk';

import { isUserPromptSubmitHookPart, withoutUserPromptSubmitHookParts } from './message-replay';

const HINT_KEYS = ['path', 'file_path', 'command', 'query', 'url', 'name', 'pattern'] as const;

const MAX_HINT_WIDTH = 60;
Expand Down Expand Up @@ -142,7 +144,7 @@ function formatTurnMd(messages: readonly ContextMessage[], turnNumber: number):
// A daemon-ref media part is self-contained and renders as
// `[image]`/`[video]` below; a standalone `<media path>` tag is user
// text and exports verbatim.
for (const part of msg.content) {
for (const part of withoutUserPromptSubmitHookParts(msg.content)) {
const text = formatContentPartMd(part);
if (text.trim()) {
lines.push(text, '');
Expand Down Expand Up @@ -192,7 +194,10 @@ function buildOverview(
for (const msg of history) {
if (msg.role === 'user' && !isInternalMessage(msg)) {
const textParts = msg.content
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.filter(
(p): p is { type: 'text'; text: string } =>
p.type === 'text' && !isUserPromptSubmitHookPart(p),
)
.map((p) => p.text);
topic = shorten(textParts.join(' '), 80);
break;
Expand Down
65 changes: 60 additions & 5 deletions apps/kimi-code/src/tui/utils/message-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,59 @@ export function contentPartsToText(content: readonly ContentPart[]): string {
return content.map(contentPartToText).join('');
}

export function isUserPromptSubmitHookPart(
part: ContentPart,
): part is Extract<ContentPart, { type: 'text' }> {
return (
part.type === 'text' &&
(part as { meta?: { source?: unknown } }).meta?.source === 'user prompt submit hook'
);
}

const SKILL_ACTIVATION_PART_SOURCE = 'skill activation';

export function isSkillActivationPart(part: ContentPart): boolean {
return (
part.type === 'text' &&
(part as { meta?: { source?: unknown } }).meta?.source === SKILL_ACTIVATION_PART_SOURCE
);
}

function annotateBundledSkillParts(
content: readonly ContentPart[],
bundledActivations: readonly BundledSkillActivationRef[],
): ContentPart[] {
if (bundledActivations.length === 0 || content.some(isSkillActivationPart)) {
return [...content];
}
let index = 0;
return content.map((part) => {
const activation = bundledActivations[index];
if (
activation !== undefined &&
part.type === 'text' &&
(part as { meta?: { source?: unknown } }).meta?.source === undefined
) {
index += 1;
return {
...part,
meta: { source: SKILL_ACTIVATION_PART_SOURCE, activationId: activation.activationId },
};
}
return part;
});
}

interface BundledSkillActivationRef {
readonly activationId: string;
}

export function withoutUserPromptSubmitHookParts(
content: readonly ContentPart[],
): ContentPart[] {
return content.filter((part) => !isUserPromptSubmitHookPart(part));
}

/**
* agent-core-v2's task domain persists the terminal notification under the
* 'task' spelling (v1 used 'background_task'); both reach replay verbatim.
Expand Down Expand Up @@ -306,13 +359,15 @@ export function bundledSkillsFromOrigin(
}

/**
* Content parts the caller actually typed: the engine prepends one rendered
* text part per bundled skill, so the caller's own parts start right after
* them.
* Content parts the caller actually typed: skill blocks are meta-marked at
* construction; legacy wires carry no marks, so the leading unmarked parts
* (one per bundled activation) are annotated first, then filtered out.
*/
export function stripBundledSkillParts(message: ContextMessage): readonly ContentPart[] {
const bundledCount = bundledSkillsFromOrigin(message.origin).length;
return bundledCount === 0 ? message.content : message.content.slice(bundledCount);
return annotateBundledSkillParts(
message.content,
bundledSkillsFromOrigin(message.origin),
).filter((part) => !isSkillActivationPart(part));
}

export function pluginCommandFromOrigin(
Expand Down
16 changes: 16 additions & 0 deletions apps/kimi-code/test/tui/export-markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,19 @@ describe('buildExportMarkdown', () => {

it('filters out internal messages', () => {
const msgs: ContextMessage[] = [
{
role: 'user',
content: [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nhook note\n</hook_result>',
meta: { contentType: 'text/xml', source: 'user prompt submit hook' },
} as ContentPart,
{ type: 'text', text: 'clean prompt' },
],
toolCalls: [],
origin: { kind: 'user' },
},
userMsg('hello', { kind: 'user' }),
userMsg('injected stuff', { kind: 'injection', variant: 'system-reminder' }),
assistantMsg('response'),
Expand All @@ -399,6 +412,9 @@ describe('buildExportMarkdown', () => {
now,
});
expect(md).not.toContain('injected stuff');
expect(md).not.toContain('hook_result');
expect(md).not.toContain('hook note');
expect(md).toContain('- **Topic**: clean prompt');
expect(md).toContain('hello');
expect(md).toContain('response');
});
Expand Down
6 changes: 5 additions & 1 deletion apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,7 +1015,11 @@ describe('KimiTUI message flow', () => {
message: {
role: 'user',
content: [
{ type: 'text', text: 'skill card C body' },
{
type: 'text',
text: 'skill card C body',
meta: { source: 'skill activation', activationId: 'act-3' },
},
{ type: 'text', text: 'please /commit' },
],
toolCalls: [],
Expand Down
35 changes: 35 additions & 0 deletions apps/kimi-code/test/tui/message-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1200,13 +1200,48 @@ describe('KimiTUI resume message replay', () => {
message('user', [{ type: 'text', text: hookResult }], {
origin: { kind: 'hook_result', event: 'UserPromptSubmit' },
}),
message('user', [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nmerged hook note\n</hook_result>',
meta: { contentType: 'text/xml', source: 'user prompt submit hook' },
} as ContentPart,
{ type: 'text', text: 'merged prompt' },
]),
message(
'user',
[
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nskill hook note\n</hook_result>',
meta: { contentType: 'text/xml', source: 'user prompt submit hook' },
} as ContentPart,
{ type: 'text', text: 'Review the requested file.' },
],
{
origin: {
kind: 'skill_activation',
activationId: 'act-review',
skillName: 'review',
skillArgs: 'src/app.ts',
trigger: 'user-slash',
},
},
),
]);

const transcript = driver.state.transcriptContainer.render(120).join('\n');

expect(transcript).toContain('UserPromptSubmit hook');
expect(transcript).toContain('hook response 1');
expect(transcript).toContain('hook response 2');
expect(transcript).toContain('merged hook note');
expect(transcript).toContain('skill hook note');
expect(
driver.state.transcriptEntries
.filter((entry) => entry.kind === 'user')
.map((entry) => entry.content),
).toEqual(['prompt', 'merged prompt']);
});

it('renders replayed compaction records as completed compaction blocks', async () => {
Expand Down
18 changes: 16 additions & 2 deletions apps/vscode/src/runtime/replay-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
ToolCall,
} from "../../shared/legacy-sdk";
import type { UIStreamEvent } from "../../shared/types";
import { hookResultBody, isUserPromptSubmitHookPart, withoutUserPromptSubmitHookParts } from "../utils/hook-parts";
import { toLegacyToolName } from "./event-adapter";
import { toLegacyDisplay } from "./tool-display";

Expand Down Expand Up @@ -94,7 +95,8 @@ function replayAgentToWebviewEvents(
const message = record.message;
if (message.role === "user") {
if (!isVisibleUserMessage(message.origin)) break;
const imported = importedContextReplay(message.content);
const visibleContent = withoutUserPromptSubmitHookParts(message.content);
const imported = importedContextReplay(visibleContent);
completeTurn();
step = 0;
turnOpen = true;
Expand All @@ -103,12 +105,24 @@ function replayAgentToWebviewEvents(
{
type: "TurnBegin",
payload: {
user_input: imported?.input ?? replayUserInput(message.content, message.origin),
user_input: imported?.input ?? replayUserInput(visibleContent, message.origin),
},
},
sessionId,
),
);
const hookParts = message.content.filter(isUserPromptSubmitHookPart);
if (hookParts.length > 0) {
ensureStep();
for (const part of hookParts) {
events.push(
withSession(
{ type: "ContentPart", payload: { type: "text", text: hookResultBody(part.text) } },
sessionId,
),
);
}
}
if (imported !== undefined) {
ensureStep();
events.push(
Expand Down
21 changes: 21 additions & 0 deletions apps/vscode/src/utils/hook-parts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ContentPart } from "@moonshot-ai/kimi-code-sdk";

export function isUserPromptSubmitHookPart(
part: ContentPart,
): part is Extract<ContentPart, { type: "text" }> {
return (
part.type === "text" &&
(part as { meta?: { source?: unknown } }).meta?.source === "user prompt submit hook"
);
}

export function withoutUserPromptSubmitHookParts(
content: readonly ContentPart[],
): ContentPart[] {
return content.filter((part) => !isUserPromptSubmitHookPart(part));
}

export function hookResultBody(text: string): string {
const match = /^<hook_result hook_event="[^"]*">\n([\s\S]*)\n<\/hook_result>$/.exec(text);
return match?.[1] ?? text;
}
Loading
Loading