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
48 changes: 48 additions & 0 deletions apps/desktop/src/main/__tests__/conversation-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Export must prefer the human-facing user message view so skill-injection
* envelopes do not leak into clipboard / file exports.
*/

import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import type { StoredMessage } from '@maka/core';
import { renderConversationMarkdown } from '../../renderer/conversation-markdown.js';

describe('renderConversationMarkdown', () => {
it('uses displayText for user turns when the model text is a skill envelope', () => {
const typed = '/skill:alpha 帮我整理';
const envelope = [
'The user explicitly invoked the following local skill(s) for this request.',
'<invoked-skill id="alpha" name="Alpha">',
'# Alpha',
'Secret skill body that must not export.',
'</invoked-skill>',
'<user-message>',
'帮我整理',
'</user-message>',
].join('\n');
const messages: StoredMessage[] = [
{
type: 'user',
id: 'u1',
turnId: 't1',
ts: 1,
text: envelope,
displayText: typed,
},
{
type: 'assistant',
id: 'a1',
turnId: 't1',
ts: 2,
text: 'done',
modelId: 'fake',
},
];
const md = renderConversationMarkdown('skill session', messages);
assert.match(md, /## 你/);
assert.ok(md.includes(typed), 'export shows the typed prompt');
assert.ok(!md.includes('<invoked-skill'), 'export must not include the skill envelope');
assert.ok(!md.includes('Secret skill body'), 'export must not include skill body');
});
});
4 changes: 3 additions & 1 deletion apps/desktop/src/main/search/thread-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,9 @@ export function formatSearchResultSummary(message: StoredMessage): string {
export function collectSearchableText(message: StoredMessage): string | undefined {
switch (message.type) {
case 'user':
return message.text;
// Prefer the human-facing view so skill-invocation envelopes do not
// dominate local search hits for what the user actually typed.
return message.displayText ?? message.text;
case 'assistant':
// Search result snippets are a transcript surface. Assistant
// reasoning/thinking may be rendered separately in the live chat,
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/renderer/conversation-markdown.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { StoredMessage } from '@maka/core';
import { userFacingText } from '@maka/core/session';
import { redactSecrets } from '@maka/ui';

/**
Expand Down Expand Up @@ -50,12 +51,14 @@ export function renderConversationMarkdown(sessionName: string, messages: Stored
.join('\n\n');
const toolCalls = turnMessages.filter((m) => m.type === 'tool_call');

if (user) {
if (user && user.type === 'user') {
lines.push('---');
lines.push('');
lines.push('## 你');
lines.push('');
lines.push((user as { text: string }).text);
// Prefer displayText when the model text is a composed envelope (skill
// invocation) so exports match what the user typed, not the injected body.
lines.push(userFacingText(user));
lines.push('');
}

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/__tests__/pi-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3610,8 +3610,8 @@ class RecordingDriver {

constructor(private readonly events: SessionEvent[]) {}

async *sendPrompt(prompt: string): AsyncIterable<SessionEvent> {
this.prompts.push(prompt);
async *sendPrompt(prompt: string, options?: { modelText?: string }): AsyncIterable<SessionEvent> {
this.prompts.push(options?.modelText ?? prompt);
for (const event of this.events) yield event;
}

Expand Down
164 changes: 158 additions & 6 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { before, describe, test } from 'node:test';
import { visibleWidth } from '@earendil-works/pi-tui';
Expand Down Expand Up @@ -1859,10 +1862,13 @@ describe('Maka Pi TUI runner', () => {
const afterLines = plainTerminalOutput(terminal.screenOutput()).split(/\r?\n/);
const afterRows = inputSurfaceRows(afterLines);
const afterSessionRow = afterLines.findIndex((line) => line.includes('/session'));
const afterSkillRow = afterLines.findIndex((line) => line.includes('/skill'));

assert.ok(beforeSessionRow >= 0);
assert.deepEqual(afterRows, beforeRows);
assert.equal(afterSessionRow, afterRows[0] - 1);
// The 's' filter matches two commands — /session then /skill — bottom-aligned.
assert.equal(afterSkillRow, afterRows[0] - 1);
assert.equal(afterSessionRow, afterRows[0] - 2);

exitMaka(terminal);
await Promise.race([
Expand Down Expand Up @@ -1908,10 +1914,12 @@ describe('Maka Pi TUI runner', () => {
const afterLines = plainTerminalOutput(terminal.screenOutput()).split(/\r?\n/);
const afterRows = inputSurfaceRows(afterLines);
const afterSessionRow = afterLines.findIndex((line) => line.includes('/session'));
const afterSkillRow = afterLines.findIndex((line) => line.includes('/skill'));

assert.deepEqual(afterRows, beforeRows);
assert.equal(afterRows[1], terminal.rows - 2);
assert.equal(afterSessionRow, afterRows[0] - 1);
assert.equal(afterSkillRow, afterRows[0] - 1);
assert.equal(afterSessionRow, afterRows[0] - 2);

exitMaka(terminal);
await Promise.race([
Expand Down Expand Up @@ -4101,6 +4109,132 @@ describe('Maka Pi TUI runner', () => {
assert.equal(terminal.titles.at(-1), 'Maka');
});

// #1148: explicit skill invocation — tokens are resolved by the CLI at
// submit, the runtime receives the composed message, and the transcript
// keeps showing what the user actually typed.
test('injects invoked skill instructions at submit while showing the typed prompt', async () => {
await withSkillWorkspace(async (workspaceRoot) => {
const terminal = new FakeTerminal();
const driver = new SlashCommandDriver();
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'claude-sonnet-4-5',
connectionSlug: 'claude-subscription',
permissionMode: 'ask',
terminal,
skills: { source: () => workspaceRoot, host: { toolNames: new Set<string>() } },
});

terminal.input('/skill:alpha 帮我整理');
terminal.input('\r');
await waitFor(() => driver.prompts.length === 1);

assert.equal(driver.displayPrompts[0], '/skill:alpha 帮我整理', 'human-facing prompt keeps the typed tokens');
const sent = driver.prompts[0];
assert.match(sent, /<invoked-skill id="alpha" name="Alpha">/);
assert.match(sent, /# Alpha\nAlpha body\./);
assert.match(sent, /do not call the Skill tool again for these skills/);
assert.ok(
sent.endsWith('<user-message>\n帮我整理\n</user-message>'),
`composed message carries the stripped user text: ${sent}`,
);

// The transcript render trails the send by a tick — wait for it.
await waitFor(() => plainTerminalOutput(terminal.output()).includes('/skill:alpha 帮我整理'));
await waitFor(() => plainTerminalOutput(terminal.output()).includes('已加载技能:Alpha'));

exitMaka(terminal);
await Promise.race([
run,
delay(50).then(() => {
throw new Error('TUI did not close during test cleanup');
}),
]);
});
});

// #1148: a token that fails to resolve must never block the send — the raw
// prompt goes out untouched and a non-blocking notice explains the skip.
test('sends the raw prompt with a notice when a skill token fails to resolve', async () => {
await withSkillWorkspace(async (workspaceRoot) => {
const terminal = new FakeTerminal();
const driver = new SlashCommandDriver();
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'claude-sonnet-4-5',
connectionSlug: 'claude-subscription',
permissionMode: 'ask',
terminal,
skills: { source: () => workspaceRoot, host: { toolNames: new Set<string>() } },
});

terminal.input('/skill:nope hi');
terminal.input('\r');
await waitFor(() => driver.prompts.length === 1);

assert.equal(driver.prompts[0], '/skill:nope hi', 'failed token degrades to the untouched prompt');
await waitFor(
() => plainTerminalOutput(terminal.output()).includes('未能加载技能 /skill:nope(未找到),已按原文发送。'),
);

exitMaka(terminal);
await Promise.race([
run,
delay(50).then(() => {
throw new Error('TUI did not close during test cleanup');
}),
]);
});
});

// #1148: `/skill` opens the picker; picking inserts the token into the
// draft without sending, so the user keeps composing before submitting.
test('/skill picker inserts a token that a later submit injects', async () => {
await withSkillWorkspace(async (workspaceRoot) => {
const terminal = new FakeTerminal();
const driver = new SlashCommandDriver();
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'claude-sonnet-4-5',
connectionSlug: 'claude-subscription',
permissionMode: 'ask',
terminal,
skills: { source: () => workspaceRoot, host: { toolNames: new Set<string>() } },
});

terminal.input('/skill');
terminal.input('\r');
await waitFor(() => terminal.output().includes('Invoke Skill'));
assert.equal(driver.prompts.length, 0, 'the picker command itself sends nothing');

terminal.input('\r');
terminal.input('整理一下');
terminal.input('\r');
await waitFor(() => driver.prompts.length === 1);

const sent = driver.prompts[0];
assert.match(sent, /<invoked-skill id="alpha" name="Alpha">/);
assert.ok(
sent.endsWith('<user-message>\n整理一下\n</user-message>'),
`picker-inserted token composes on submit: ${sent}`,
);

exitMaka(terminal);
await Promise.race([
run,
delay(50).then(() => {
throw new Error('TUI did not close during test cleanup');
}),
]);
});
});

});

/** Count the standalone BEL bytes the attention layer wrote. */
Expand Down Expand Up @@ -5061,7 +5195,10 @@ function pipeOutput(stdout = '', stderr = '') {
}

class SlashCommandDriver implements MakaSessionDriver {
/** Model-facing text (options.modelText when set, else the typed prompt). */
readonly prompts: string[] = [];
/** Human-facing typed prompt for every sendPrompt call. */
readonly displayPrompts: string[] = [];
readonly models: string[] = [];
readonly modelConnections: Array<string | undefined> = [];
readonly permissionModes: PermissionMode[] = [];
Expand All @@ -5086,8 +5223,9 @@ class SlashCommandDriver implements MakaSessionDriver {
: { available: false, reason: 'Missing working directory' };
}

async *sendPrompt(prompt: string): AsyncIterable<SessionEvent> {
this.prompts.push(prompt);
async *sendPrompt(prompt: string, options?: { modelText?: string }): AsyncIterable<SessionEvent> {
this.displayPrompts.push(prompt);
this.prompts.push(options?.modelText ?? prompt);
yield {
type: 'complete',
id: 'event-complete',
Expand Down Expand Up @@ -5545,8 +5683,22 @@ class RewindDriver extends SlashCommandDriver {
}
}

function switchResult(summary: SessionSummary, messages: StoredMessage[] = []): MakaSessionSwitchResult {
return { summary, messages };
// #1148: a throwaway workspace seeded with one invocable skill (`alpha`).
// The runner's skill surface points at it in single-root mode, so no real
// user- or project-level skills leak into the tests.
async function withSkillWorkspace(fn: (workspaceRoot: string) => Promise<void>): Promise<void> {
const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-cli-skill-invocation-'));
try {
const skillDir = join(workspaceRoot, 'skills', 'alpha');
await mkdir(skillDir, { recursive: true });
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: Alpha\ndescription: First.\n---\n# Alpha\nAlpha body.', 'utf8');
await fn(workspaceRoot);
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
}
}

function switchResult(summary: SessionSummary, messages: StoredMessage[] = []): MakaSessionSwitchResult { return { summary, messages };
}

function fakeSessionSummary(sessionId: string, cwd = '/repo', name = 'Existing chat'): SessionSummary {
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/__tests__/session-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,28 @@ describe('Maka session driver', () => {
assert.deepEqual(events.map((event) => event.type), ['text_delta', 'complete']);
});

test('titles and displayText use the typed prompt when modelText is a composed envelope', async () => {
const runtime = new RecordingRuntime();
const driver = createMakaSessionDriver({
runtime,
cwd: '/repo',
llmConnectionSlug: 'anthropic',
model: 'claude-sonnet-4-5',
newId: nextId('turn'),
});

const typed = '/skill:alpha 帮我整理';
const composed = 'The user explicitly invoked…\n\n<user-message>\n帮我整理\n</user-message>';
await collect(driver.sendPrompt(typed, { modelText: composed }));

assert.equal(runtime.created[0]?.name, typed);
assert.deepEqual(runtime.sent[0]?.input, {
turnId: 'turn-1',
text: composed,
displayText: typed,
});
});

test('can still create a bypass session when explicitly requested', async () => {
const runtime = new RecordingRuntime();
const driver = createMakaSessionDriver({
Expand Down
Loading
Loading