Skip to content
Open
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/mcp-channel-push-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Render MCP channel push notifications (e.g. from a Discord bridge plugin) as a distinct transcript message instead of only surfacing through the model's own reply. Requires the experimental v2 engine (`KIMI_CODE_EXPERIMENTAL_FLAG=1`).
5 changes: 5 additions & 0 deletions .changeset/mcp-channel-push-web-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Render MCP channel push notifications (e.g. from a Discord bridge plugin) as a distinct transcript message instead of only surfacing through the model's own reply. Requires the experimental v2 engine (`KIMI_CODE_EXPERIMENTAL_FLAG=1`).
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/undo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ function isUndoContextEntry(entry: TranscriptEntry): boolean {
case 'skill_activation':
case 'plugin_command':
case 'cron':
case 'mcp_channel':
return true;
case 'status':
case 'goal':
Expand Down
60 changes: 60 additions & 0 deletions apps/kimi-code/src/tui/components/messages/mcp-channel-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Component } from '@moonshot-ai/pi-tui';
import { Spacer, Text, visibleWidth } from '@moonshot-ai/pi-tui';

import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import type { McpChannelTranscriptData } from '#/tui/types';

export class McpChannelMessageComponent implements Component {
private readonly spacer = new Spacer(1);
private readonly title: string;
private readonly detail: string | undefined;
private readonly textText: Text;
private readonly text: string;

constructor(text: string, data: McpChannelTranscriptData) {
this.title = `Message received via ${data.server}`;
this.detail = data.chatId !== undefined ? `chat ${data.chatId}` : undefined;
this.text = text;
this.textText = new Text(currentTheme.fg('text', text), 0, 0);
}

invalidate(): void {
this.textText.setText(currentTheme.fg('text', this.text));
this.textText.invalidate();
}

render(width: number): string[] {
const safeWidth = Math.max(0, width);
if (safeWidth <= 0) return [''];

const bullet = currentTheme.boldFg('accent', STATUS_BULLET);
const bulletWidth = visibleWidth(bullet);
const contentWidth = Math.max(1, safeWidth - bulletWidth);
const continuationIndent = ' '.repeat(bulletWidth);
const lines: string[] = [];

for (const line of this.spacer.render(safeWidth)) {
lines.push(line);
}

const titleLines = new Text(currentTheme.boldFg('accent', this.title), 0, 0).render(contentWidth);
for (let i = 0; i < titleLines.length; i += 1) {
lines.push(`${i === 0 ? bullet : continuationIndent}${titleLines[i]}`);
}

if (this.detail !== undefined) {
const detailLines = new Text(currentTheme.fg('textDim', this.detail), 0, 0).render(contentWidth);
for (const line of detailLines) {
lines.push(`${continuationIndent}${line}`);
}
}

const textLines = this.textText.render(contentWidth);
for (const line of textLines) {
lines.push(`${continuationIndent}${line}`);
}

return lines;
}
}
17 changes: 17 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
GoalChange,
GoalUpdatedEvent,
HookResultEvent,
McpChannelReceivedEvent,
Session,
SessionMetaUpdatedEvent,
SkillActivatedEvent,
Expand Down Expand Up @@ -294,6 +295,7 @@ export class SessionEventHandler {
case 'background.task.terminated':
this.handleBackgroundTaskEvent(event); break;
case 'cron.fired': this.handleCronFired(event); break;
case 'mcp.channel.received': this.handleMcpChannelReceived(event); break;
case 'mcp.server.status': this.renderMcpServerStatus(event.server); break;
case 'tool.list.updated': break;
default: break;
Expand Down Expand Up @@ -348,6 +350,21 @@ export class SessionEventHandler {
});
}

private handleMcpChannelReceived(event: McpChannelReceivedEvent): void {
this.host.streamingUI.flushNow();
this.host.appendTranscriptEntry({
id: nextTranscriptId(),
kind: 'mcp_channel',
turnId: this.host.streamingUI.getTurnContext().turnId,
renderMode: 'plain',
content: event.text,
mcpChannelData: {
server: event.server,
chatId: event.chatId,
},
});
}

private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void {
this.host.streamingUI.flushNow();
if (event.reason === 'cancelled') {
Expand Down
40 changes: 40 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,10 @@ export class SessionReplayRenderer {
this.renderCronMissed(context, message);
return;
}
if (message.origin?.kind === 'mcp_channel') {
this.renderMcpChannelMessage(context, message);
return;
}
// System-trigger messages (goal continuation prompts, goal outcome
// reminders, stop-hook reasons, …) are model-facing only: the live event
// stream never renders them, so replay must not leak them either.
Expand Down Expand Up @@ -570,6 +574,23 @@ export class SessionReplayRenderer {
});
}

private renderMcpChannelMessage(context: ReplayRenderContext, message: ContextMessage): void {
if (message.origin?.kind !== 'mcp_channel') return;
this.flushAssistant(context);
this.host.appendTranscriptEntry({
...replayEntry(
context,
'mcp_channel',
stripMcpChannelEnvelope(contentPartsToText(message.content)),
'plain',
),
mcpChannelData: {
server: message.origin.server,
chatId: message.origin.chatId,
},
});
}

private renderPermissionUpdate(context: ReplayRenderContext, mode: PermissionMode): void {
if (mode === 'yolo') {
this.host.appendTranscriptEntry(
Expand Down Expand Up @@ -769,3 +790,22 @@ function stripCronEnvelope(text: string): string {
}
return text;
}

function stripMcpChannelEnvelope(text: string): string {
const lines = text.split('\n');
if (
lines.length >= 2 &&
lines[0]?.startsWith('<mcp-channel ') &&
lines.at(-1) === '</mcp-channel>'
) {
return unescapeMcpChannelText(lines.slice(1, -1).join('\n'));
}
return text;
}

// Exact inverse of the plugin bridge's `escapeXmlTags` (which only escapes
// `<`/`>` in the message body, unlike the attribute escaping used for
// `server`/`chatId`) — so live rendering (raw `event.text`) and replay agree.
function unescapeMcpChannelText(text: string): string {
return text.replaceAll('&lt;', '<').replaceAll('&gt;', '>');
}
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
GoalCompletionMessageComponent,
GoalSetMessageComponent,
} from './components/messages/goal-panel';
import { McpChannelMessageComponent } from './components/messages/mcp-channel-message';
import { PluginCommandComponent } from './components/messages/plugin-command';
import { ShellRunComponent } from './components/messages/shell-run';
import { SkillActivationComponent } from './components/messages/skill-activation';
Expand Down Expand Up @@ -1911,6 +1912,9 @@ export class KimiTUI {
}
case 'cron':
return new CronMessageComponent(entry.content, entry.cronData ?? {});
case 'mcp_channel':
if (entry.mcpChannelData === undefined) return null;
return new McpChannelMessageComponent(entry.content, entry.mcpChannelData);
case 'goal':
if (entry.goalData?.kind === 'created') {
return new GoalSetMessageComponent();
Expand Down
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ export interface CronTranscriptData {
readonly missedCount?: number;
}

export interface McpChannelTranscriptData {
readonly server: string;
readonly chatId?: string;
}

export type GoalTranscriptData =
| { readonly kind: 'created' }
| { readonly kind: 'lifecycle'; readonly change: GoalChange };
Expand All @@ -150,6 +155,7 @@ export type TranscriptEntryKind =
| 'skill_activation'
| 'plugin_command'
| 'cron'
| 'mcp_channel'
| 'goal';

export type SkillActivationTrigger = 'user-slash' | 'model-tool' | 'nested-skill';
Expand Down Expand Up @@ -183,6 +189,7 @@ export interface TranscriptEntry {
backgroundAgentStatus?: BackgroundAgentStatusData;
compactionData?: CompactionTranscriptData;
cronData?: CronTranscriptData;
mcpChannelData?: McpChannelTranscriptData;
goalData?: GoalTranscriptData;
imageAttachmentIds?: readonly number[];
skillActivationId?: string;
Expand Down
27 changes: 27 additions & 0 deletions apps/kimi-code/test/tui/message-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,33 @@ describe('KimiTUI resume message replay', () => {
).toEqual(['3 one-shot tasks missed while offline']);
});

it('renders mcp_channel origin records during replay without exposing raw XML, matching the live-rendered (unescaped) text', async () => {
const mcpChannelPush = '<mcp-channel server="discord" chatId="chat-1">\na &lt; b\n</mcp-channel>';
const driver = await replayIntoDriver([
message('user', [{ type: 'text', text: 'real prompt' }]),
message('assistant', [{ type: 'text', text: 'real answer' }]),
message('user', [{ type: 'text', text: mcpChannelPush }], {
origin: { kind: 'mcp_channel', server: 'discord', chatId: 'chat-1' },
}),
]);

const transcript = driver.state.transcriptContainer.render(120).join('\n');
expect(transcript).not.toContain('<mcp-channel');
expect(transcript).not.toContain('&lt;');
expect(transcript).toContain('Message received via discord');
expect(transcript).toContain('a < b');
expect(
driver.state.transcriptEntries
.filter((entry) => entry.kind === 'user')
.map((entry) => entry.content),
).toEqual(['real prompt']);
expect(
driver.state.transcriptEntries
.filter((entry) => entry.kind === 'mcp_channel')
.map((entry) => entry.content),
).toEqual(['a < b']);
});

it('renders user-slash skill activation once without exposing injected prompt text', async () => {
const activation = message(
'user',
Expand Down
29 changes: 29 additions & 0 deletions apps/kimi-web/src/api/daemon/agentEventProjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,34 @@ export function createAgentProjector(): AgentProjector {
break;
}

// -----------------------------------------------------------------------
case 'mcp.channel.received': {
// An MCP server (e.g. a Discord bridge) pushed a message that woke
// the session. Same situation as cron.fired: agent-core persists the
// injected user message (rendered via messagesToTurns on refresh),
// but the steer that delivers it does not broadcast a
// prompt.submitted / message.created — synthesize one here so it
// shows up live too. The event's own `text` is the raw, unescaped
// message (the XML envelope only exists on the persisted copy), so
// no unwrapping is needed here.
const server = stringField(p ?? {}, 'server');
const text = stringField(p ?? {}, 'text');
const chatId = stringField(p ?? {}, 'chatId');
if (server && text) {
const msg: AppMessage = {
id: ulid('mcpchan_'),
sessionId,
role: 'user',
content: [{ type: 'text', text }],
createdAt: new Date().toISOString(),
metadata: { origin: { kind: 'mcp_channel', server, chatId } },
};
s.messages.push(msg);
out.push({ type: 'messageCreated', message: cloneMessage(msg) });
}
break;
}

// -----------------------------------------------------------------------
// Explicitly known but not projected
case 'compaction.blocked':
Expand Down Expand Up @@ -1475,6 +1503,7 @@ const KNOWN_AGENT_CORE_TYPES = new Set([
'background.task.started',
'background.task.terminated',
'cron.fired',
'mcp.channel.received',
]);

/**
Expand Down
4 changes: 3 additions & 1 deletion apps/kimi-web/src/components/chat/ChatPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Markdown from './Markdown.vue';
import ThinkingBlock from './ThinkingBlock.vue';
import ActivityNotice from './ActivityNotice.vue';
import CronNotice from './CronNotice.vue';
import McpChannelNotice from './McpChannelNotice.vue';
import MessageTime from './MessageTime.vue';
import AuthMedia from './AuthMedia.vue';
import AttachmentChip from './AttachmentChip.vue';
Expand Down Expand Up @@ -372,7 +373,7 @@ function copyConversation(): void {
if (props.turns.length === 0) return;
const lines: string[] = [];
for (const turn of props.turns) {
if (turn.role === 'compaction' || turn.role === 'cron') continue; // dividers / cron notices don't copy
if (turn.role === 'compaction' || turn.role === 'cron' || turn.role === 'mcp_channel') continue; // dividers / notices don't copy
const roleLabel = turn.role === 'user' ? 'User' : 'Assistant';
const content = turnToMarkdown(turn);
if (content.trim()) {
Expand Down Expand Up @@ -632,6 +633,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
<!-- Cron notice — a turn triggered by a scheduled reminder, rendered as
a lightweight in-transcript notice rather than a user bubble. -->
<CronNotice v-else-if="turn.role === 'cron'" :text="turn.text" :cron="turn.cron" :turn-id="turn.id" :created-at="turn.createdAt" />
<McpChannelNotice v-else-if="turn.role === 'mcp_channel'" :text="turn.text" :mcp-channel="turn.mcpChannel" :turn-id="turn.id" :created-at="turn.createdAt" />

<!-- Assistant turn → left-aligned, no name/role label. -->
<div v-else class="a-msg turn-anchor" :data-turn-id="turn.id">
Expand Down
Loading