Merge remote-tracking branch 'origin/main' into kaiyi/karachi

This commit is contained in:
Kaiyi 2026-05-29 16:51:11 +08:00
commit fc5e4bf787
47 changed files with 3362 additions and 3454 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix footer leaking onto the terminal when resuming a non-existent session.

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Fix automatic ripgrep installation when temporary files are on another filesystem.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Expand the footer's rotating tips to surface more commands and shortcuts, featuring newer and important ones more prominently.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Improve the usage information display in the TUI.

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Project persisted hook and blocked prompt messages into model context.

View file

@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---
Support querying sessions by sessionId or workDir in listSessions, and show a helpful cd command when resuming a session from a different working directory.

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kosong": patch
"@moonshot-ai/kimi-code": patch
---
Automatically retry when a model response stream is dropped mid-flight (a `terminated` error) instead of failing the turn.

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Keep blocked prompt hook conversations available to subsequent model turns.

View file

@ -34,9 +34,6 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Install docs dependencies
run: pnpm --ignore-workspace -C docs install
- name: Build docs
run: pnpm -C docs run build
env:

View file

@ -5,6 +5,7 @@ import {
track,
withTelemetryContext,
} from '@moonshot-ai/kimi-telemetry';
import chalk from 'chalk';
import {
KimiHarness,
log,
@ -158,6 +159,22 @@ async function resolvePromptSession(
setRestorePermission: (restorePermission: () => Promise<void>) => void,
): Promise<ResolvedPromptSession> {
if (opts.session !== undefined) {
const sessions = await harness.listSessions({ sessionId: opts.session, workDir });
const target = sessions[0];
if (target === undefined) {
throw new Error(`Session "${opts.session}" not found.`);
}
if (target.workDir !== workDir) {
stderr.write(
`${chalk.yellow(
`Session "${opts.session}" was created under a different directory.\n` +
` cd "${target.workDir}" && kimi -r ${opts.session}`,
)}\n\n`,
);
throw new Error(
`Session "${opts.session}" was created under a different directory.`,
);
}
const session = await harness.resumeSession({ id: opts.session });
const status = await session.getStatus();
const restorePermission = await forcePromptPermission(

View file

@ -23,36 +23,100 @@ import { safeUsageRatio } from '#/utils/usage/usage-format';
const MAX_CWD_SEGMENTS = 3;
// Toolbar tips — rotates every 30s, shows 2 tips joined by " | " when
// space allows, falls back to 1.
const TIP_ROTATE_INTERVAL_MS = 30_000;
// Toolbar tips — rotates every 10s. Most tips are short and pair up (two
// joined by " | ") when space allows; tips flagged `solo` are long or
// important enough to take the whole slot on their own. A `priority` weight
// makes a tip recur more often in the rotation (default 1). Width is always
// the final arbiter (a pair that doesn't fit falls back to its first tip).
//
// This is deliberately code-level configuration: edit the interval and the
// TOOLBAR_TIPS array below to change what the footer advertises.
const TIP_ROTATE_INTERVAL_MS = 10_000;
const TIP_SEPARATOR = ' | ';
const TOOLBAR_TIPS: readonly string[] = [
'shift+tab: plan mode',
'/yolo: toggle yolo',
'ctrl+c: cancel',
'/help: show commands',
'/model: switch model',
'@: mention files',
export interface ToolbarTip {
readonly text: string;
/**
* Long/important tips render on their own. They never pair with a
* neighbour and never appear as the second half of someone else's pair.
*/
readonly solo?: boolean;
/**
* Rotation weight: a higher value makes the tip recur more often. Defaults
* to 1. Used to give newer/important features more airtime.
*/
readonly priority?: number;
}
const TOOLBAR_TIPS: readonly ToolbarTip[] = [
{ text: 'shift+tab: plan mode' },
{ text: '/model: switch model' },
{ text: 'ctrl+s: steer mid-turn', priority: 2 },
{ text: '/compact: compact context', priority: 2 },
{ text: 'ctrl+o: expand tool output' },
{ text: '/tasks: background tasks' },
{ text: 'shift+enter: newline' },
{ text: '/init: generate AGENTS.md', priority: 2 },
{ text: '@: mention files' },
{ text: 'ctrl+c: cancel' },
{ text: '/theme: switch theme' },
{ text: '/auto: auto permission mode' },
{ text: '/yolo: toggle yolo' },
{ text: '/help: show commands' },
{ text: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 },
{ text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 },
];
/**
* Expand tips into a rotation sequence using smooth weighted round-robin
* (the nginx SWRR algorithm). Higher-`priority` tips appear more often while
* staying evenly spread, so a tip generally does not land next to its own
* duplicate. Deterministic and computed once at module load. Exported for
* unit testing.
*/
export function buildWeightedTips(tips: readonly ToolbarTip[]): readonly ToolbarTip[] {
const items = tips.map((t) => ({
tip: t,
weight: Math.max(1, Math.trunc(t.priority ?? 1)),
current: 0,
}));
const total = items.reduce((sum, it) => sum + it.weight, 0);
const seq: ToolbarTip[] = [];
for (let n = 0; n < total; n++) {
let best = items[0]!;
for (const it of items) {
it.current += it.weight;
if (it.current > best.current) best = it;
}
best.current -= total;
seq.push(best.tip);
}
return seq;
}
const ROTATION: readonly ToolbarTip[] = buildWeightedTips(TOOLBAR_TIPS);
function currentTipIndex(): number {
return Math.floor(Date.now() / TIP_ROTATE_INTERVAL_MS);
}
function twoRotatingTips(index: number): string {
const n = TOOLBAR_TIPS.length;
if (n === 0) return '';
if (n === 1) return TOOLBAR_TIPS[0]!;
/**
* Pick the tip(s) for a rotation index over the weighted ROTATION sequence.
* `primary` is always shown when it fits; `pair` (primary + next tip joined
* by the separator) is offered for wide terminals. Pairing is skipped when
* the current/next tip is `solo` or when the neighbour is a duplicate of the
* current tip (which can happen at the wrap boundary), keeping long/important
* tips on their own and avoiding "X | X".
*/
function tipsForIndex(index: number): { primary: string; pair: string | null } {
const n = ROTATION.length;
if (n === 0) return { primary: '', pair: null };
const offset = ((index % n) + n) % n;
return TOOLBAR_TIPS[offset]! + TIP_SEPARATOR + TOOLBAR_TIPS[(offset + 1) % n]!;
}
function oneRotatingTip(index: number): string {
const n = TOOLBAR_TIPS.length;
if (n === 0) return '';
const offset = ((index % n) + n) % n;
return TOOLBAR_TIPS[offset]!;
const current = ROTATION[offset]!;
if (n === 1 || current.solo) return { primary: current.text, pair: null };
const next = ROTATION[(offset + 1) % n]!;
if (next.solo || next.text === current.text) return { primary: current.text, pair: null };
return { primary: current.text, pair: current.text + TIP_SEPARATOR + next.text };
}
function shortenModel(model: string): string {
@ -214,16 +278,14 @@ export class FooterComponent implements Component {
const leftWidth = visibleWidth(leftLine);
// Rotating hint tips, fill remaining space on line 1.
const tipIndex = currentTipIndex();
const tipTwo = twoRotatingTips(tipIndex);
const tipOne = oneRotatingTip(tipIndex);
const { primary, pair } = tipsForIndex(currentTipIndex());
const gap = 2;
const remaining = Math.max(0, width - leftWidth - gap);
let tipText = '';
if (tipTwo && visibleWidth(tipTwo) <= remaining) {
tipText = tipTwo;
} else if (tipOne && visibleWidth(tipOne) <= remaining) {
tipText = tipOne;
if (pair && visibleWidth(pair) <= remaining) {
tipText = pair;
} else if (primary && visibleWidth(primary) <= remaining) {
tipText = primary;
}
let line1: string;

View file

@ -120,17 +120,19 @@ function buildManagedUsageSection(
const rows: ManagedUsageRow[] = [];
if (summary !== null) rows.push(summary);
rows.push(...limits);
const usedRatio = (r: ManagedUsageRow): number =>
r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0;
const labelWidth = Math.max(10, ...rows.map((r) => r.label.length));
const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length));
const out: string[] = [accent('Plan usage')];
for (const row of rows) {
const ratioUsed = row.limit > 0 ? row.used / row.limit : 0;
const leftRatio = 1 - Math.max(0, Math.min(ratioUsed, 1));
const bar = renderProgressBar(Math.max(0, Math.min(ratioUsed, 1)), 20);
const pct = `${Math.round(leftRatio * 100)}% left`;
const ratioUsed = usedRatio(row);
const bar = renderProgressBar(ratioUsed, 20);
const pct = `${Math.round(ratioUsed * 100)}% used`;
const barColoured = chalk.hex(severityHex(ratioSeverity(ratioUsed)))(bar);
const label = row.label.padEnd(labelWidth, ' ');
const resetStr = row.resetHint ? muted(` (${row.resetHint})`) : '';
out.push(` ${muted(label)} ${barColoured} ${value(pct)}${resetStr}`);
const resetStr = row.resetHint ? ` ${muted(row.resetHint)}` : '';
out.push(` ${muted(label)} ${barColoured} ${value(pct.padEnd(pctWidth, ' '))}${resetStr}`);
}
return out;
}

View file

@ -377,6 +377,8 @@ export class KimiTUI {
private async initMainTui(): Promise<boolean> {
const shouldReplayHistory = await this.init();
// Mount only after init() succeeds; see mountFooter().
this.mountFooter();
this.renderWelcome();
this.setupAutocomplete();
void this.loadPersistedInputHistory();
@ -448,11 +450,26 @@ export class KimiTUI {
}
if (startup.sessionFlag !== undefined) {
const sessions = await this.harness.listSessions({ workDir });
const target = sessions.find((candidate) => candidate.id === startup.sessionFlag);
const sessions = await this.harness.listSessions({
sessionId: startup.sessionFlag,
workDir,
});
const target = sessions[0];
if (target === undefined) {
throw new Error(`Session "${startup.sessionFlag}" not found.`);
}
if (target.workDir !== workDir) {
this.state.ui.stop();
process.stderr.write(
`${chalk.yellow(
`Session "${startup.sessionFlag}" was created under a different directory.\n` +
` cd "${target.workDir}" && kimi -r ${startup.sessionFlag}`,
)}\n\n`,
);
throw new Error(
`Session "${startup.sessionFlag}" was created under a different directory.`,
);
}
session = await this.harness.resumeSession({ id: startup.sessionFlag });
shouldReplayHistory = true;
} else {
@ -586,11 +603,18 @@ export class KimiTUI {
ui.addChild(this.state.todoPanelContainer);
ui.addChild(this.state.queueContainer);
ui.addChild(this.state.editorContainer);
// FooterComponent isn't a Container; wrap it so it picks up the same
// outer gutter as the transcript/panels above.
// Footer is mounted later (mountFooter), not here.
}
// Footer is the only chrome with content before a session is ready, so
// mounting it at construction lets a stray pre-start render leak it to the
// terminal — e.g. above the error when resuming a missing session. Mount it
// only once init() succeeds. FooterComponent isn't a Container, so wrap it to
// pick up the same outer gutter as the panels above.
private mountFooter(): void {
const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
footerWrap.addChild(this.state.footer);
ui.addChild(footerWrap);
this.state.ui.addChild(footerWrap);
}
// =========================================================================

View file

@ -56,7 +56,7 @@ const mocks = vi.hoisted(() => {
),
harnessCreateSession: vi.fn(async () => session),
harnessResumeSession: vi.fn(async () => session),
harnessListSessions: vi.fn(async () => [{ id: 'ses_previous' }]),
harnessListSessions: vi.fn(async () => [{ id: 'ses_previous', workDir: process.cwd() }]),
harnessClose: vi.fn(),
harnessTrack: vi.fn(),
harnessGetCachedAccessToken: vi.fn(),

View file

@ -64,7 +64,7 @@ describe('status panel report lines', () => {
expect(output).toContain('25.0%');
expect(output).toContain('(3.0k / 12.0k)');
expect(output).toContain('Plan usage');
expect(output).toContain('92% left');
expect(output).toContain('8% used');
expect(output).not.toContain('Account');
expect(output).not.toContain('AGENTS.md');
expect(output).not.toContain('Runtime');

View file

@ -41,8 +41,8 @@ describe('UsagePanelComponent', () => {
expect(lines).toContain('Context window');
expect(lines.join('\n')).toContain('25.0%');
expect(lines).toContain('Plan usage');
expect(lines.join('\n')).toContain('80% left');
expect(lines.join('\n')).toContain('(resets tomorrow)');
expect(lines.join('\n')).toContain('20% used');
expect(lines.join('\n')).toContain('resets tomorrow');
});
it('wraps preformatted usage lines in a bordered panel', () => {

View file

@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import chalk from 'chalk';
import { FooterComponent, formatFooterGitBadge } from '#/tui/components/chrome/footer';
import { FooterComponent, formatFooterGitBadge, buildWeightedTips } from '#/tui/components/chrome/footer';
import { darkColors } from '#/tui/theme/colors';
import type { AppState } from '#/tui/types';
@ -146,3 +146,47 @@ describe('FooterComponent — context NaN resilience', () => {
}
});
});
describe('buildWeightedTips — weighted rotation', () => {
it('repeats higher-priority tips more often (length = sum of weights)', () => {
const seq = buildWeightedTips([
{ text: 'a' }, // weight 1 (default)
{ text: 'b', priority: 3 },
{ text: 'c', priority: 2 },
]);
const count = (t: string) => seq.filter((x) => x.text === t).length;
expect(seq).toHaveLength(6);
expect(count('a')).toBe(1);
expect(count('b')).toBe(3);
expect(count('c')).toBe(2);
expect(count('b')).toBeGreaterThan(count('a'));
});
it('keeps duplicates spread out — no tip sits next to itself', () => {
const seq = buildWeightedTips([
{ text: 'a' },
{ text: 'b', priority: 3 },
{ text: 'c', priority: 2 },
]);
for (let i = 1; i < seq.length; i++) {
expect(seq[i]!.text).not.toBe(seq[i - 1]!.text);
}
});
it('preserves array order when all weights are the default (1)', () => {
const seq = buildWeightedTips([{ text: 'x' }, { text: 'y' }, { text: 'z' }]);
expect(seq.map((t) => t.text)).toEqual(['x', 'y', 'z']);
});
it('clamps non-positive / fractional priorities to a weight of at least 1', () => {
const seq = buildWeightedTips([
{ text: 'a', priority: 0 },
{ text: 'b', priority: -5 },
{ text: 'c', priority: 1.9 },
]);
expect(seq).toHaveLength(3);
expect(seq.map((t) => t.text).toSorted()).toEqual(['a', 'b', 'c']);
});
});

View file

@ -705,7 +705,7 @@ describe("KimiTUI startup", () => {
it("starts TUI without replaying when an explicit resume needs OAuth login", async () => {
const harness = makeHarness(makeSession(), {
listSessions: vi.fn(async () => [{ id: "ses-target" }]),
listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]),
resumeSession: vi.fn(async () => {
throw loginRequiredError();
}),
@ -776,4 +776,51 @@ describe("KimiTUI startup", () => {
await expect(driver.init()).rejects.toThrow("provider config is invalid");
});
it("does not mount the footer when resuming a missing session fails", async () => {
// Regression: a stray pre-startEventLoop render used to paint the footer
// (cwd/git + "context:" statusline) to the terminal before the fatal
// error, leaving it stranded above the error message. The footer must not
// be in the layout tree when initMainTui() throws.
const harness = makeHarness(makeSession(), {
listSessions: vi.fn(async () => []),
});
const driver = makeDriver(
harness,
makeStartupInput({ session: "missing-session" }),
) as unknown as MigrateExitDriver;
await expect(driver.initMainTui()).rejects.toThrow(
'Session "missing-session" not found.',
);
expect(uiContainsFooter(driver)).toBe(false);
});
it("mounts the footer once startup reaches the main TUI", async () => {
const session = makeSession({ id: "ses-target" });
const harness = makeHarness(session, {
listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]),
});
const driver = makeDriver(
harness,
makeStartupInput({ session: "ses-target" }),
) as unknown as MigrateExitDriver;
// Not mounted until init() succeeds.
expect(uiContainsFooter(driver)).toBe(false);
await driver.initMainTui();
expect(uiContainsFooter(driver)).toBe(true);
});
});
function uiContainsFooter(driver: StartupDriver): boolean {
const target: unknown = driver.state.footer;
const visit = (node: unknown): boolean => {
if (node === target) return true;
const children = (node as { children?: unknown[] }).children;
return Array.isArray(children) && children.some(visit);
};
return visit(driver.state.ui);
}

View file

@ -156,18 +156,6 @@ export function renderHeadline(r: AgentRecord): HeadlineRender {
),
};
case 'context.mark_last_user_prompt_blocked':
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill tone="warning" variant="soft">
blocked
</Pill>
<Dim>hook: {r.hookEvent}</Dim>
</span>
),
};
case 'context.clear':
return { main: <Dim>context cleared</Dim> };

View file

@ -12,7 +12,6 @@ export const TYPE_TONE: Record<RecordType, PillTone> = {
'turn.cancel': 'warning',
'context.append_message': 'assistant',
'context.append_loop_event': 'meta',
'context.mark_last_user_prompt_blocked': 'warning',
'context.clear': 'warning',
'context.apply_compaction': 'compaction',
'tools.register_user_tool': 'tools',
@ -40,7 +39,6 @@ export const TYPE_LABEL: Record<RecordType, string> = {
'turn.cancel': 'cancel',
'context.append_message': 'message',
'context.append_loop_event': 'loop',
'context.mark_last_user_prompt_blocked': 'blocked',
'context.clear': 'clear',
'context.apply_compaction': 'compacted',
'tools.register_user_tool': 'tool+',

View file

@ -1 +0,0 @@
shamefully-hoist=true

View file

@ -84,7 +84,7 @@ The following events are triggered automatically today:
| Event | Matcher | Main payload | Behavior |
| --- | --- | --- | --- |
| `UserPromptSubmit` | Text content submitted by the user | `prompt` (`ContentPart[]` array) | Fires only for real user messages. Text returned by the hook is wrapped as a hook result, written into session history for transcript/replay, shown to the user, and the current LLM turn continues without sending the hook result to the model; if the hook blocks, the block reason is returned to the user as an assistant message and no model call is made; if all hooks produce no output, the normal LLM turn continues |
| `UserPromptSubmit` | Text content submitted by the user | `prompt` (`ContentPart[]` array) | Fires only for real user messages. Text returned by the hook is wrapped as a hook result, written into session history for transcript/replay, shown to the user, and included in model context before the current LLM turn continues; if the hook blocks, the block reason is returned to the user as an assistant message and no model call is made for that turn; if all hooks produce no output, the normal LLM turn continues |
| `PreToolUse` | Tool name | `tool_name`, `tool_input`, `tool_call_id` | Fires before permission checks. If blocked, the tool does not run |
| `PostToolUse` | Tool name | `tool_name`, `tool_input`, `tool_call_id`, `tool_output` | Fires after a successful tool call. `tool_output` is truncated to the first 2000 characters |
| `PostToolUseFailure` | Tool name | `tool_name`, `tool_input`, `tool_call_id`, `error` | Fires after a tool call fails or is blocked by a hook |
@ -106,9 +106,9 @@ hook response
</hook_result>
```
If multiple `UserPromptSubmit` hooks return text, each result gets its own `<hook_result>` tag. This message keeps its hook-result origin for transcript/replay, but is not sent to the model. The model sees the original user prompt and the current turn continues.
If multiple `UserPromptSubmit` hooks return text, each result gets its own `<hook_result>` tag. This message keeps its hook-result origin for transcript/replay and is sent to the model after the original user prompt before the current turn continues.
If a `UserPromptSubmit` hook blocks the request, the block reason uses the same format and is returned to the user, but the turn does not continue to a model call.
If a `UserPromptSubmit` hook blocks the request, the block reason uses the same format and is returned to the user, but that blocked turn does not continue to a model call. The blocked prompt and block reason remain in session history and are included in later model context.
`Stop` block reasons are appended directly as system-triggered user messages so the current turn can continue:

3179
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -84,7 +84,7 @@ Hook 命令的退出码和 stdout 会被解释为以下结果:
| 事件 | Matcher | 主要 payload | 行为 |
| --- | --- | --- | --- |
| `UserPromptSubmit` | 用户提交的文本内容 | `prompt``ContentPart[]` 数组) | 仅对真实 User 消息触发。hook 返回的文本会包裹为 hook 结果,写入会话历史用于 transcript/replay并展示给用户;当前 LLM 轮次会继续,但不会把 hook 结果发给模型;若 hook 阻断,阻断原因会作为 Assistant 消息返回给用户,且不再调用模型;若所有 hook 均无输出,正常 LLM 轮次继续 |
| `UserPromptSubmit` | 用户提交的文本内容 | `prompt``ContentPart[]` 数组) | 仅对真实 User 消息触发。hook 返回的文本会包裹为 hook 结果,写入会话历史用于 transcript/replay展示给用户,并在当前 LLM 轮次继续前加入模型上下文;若 hook 阻断,阻断原因会作为 Assistant 消息返回给用户,且该轮次不再调用模型;若所有 hook 均无输出,正常 LLM 轮次继续 |
| `PreToolUse` | 工具名 | `tool_name``tool_input``tool_call_id` | 在权限检查前触发;阻断后工具不会执行 |
| `PostToolUse` | 工具名 | `tool_name``tool_input``tool_call_id``tool_output` | 工具成功后触发;`tool_output` 被截断至前 2000 个字符 |
| `PostToolUseFailure` | 工具名 | `tool_name``tool_input``tool_call_id``error` | 工具失败或被 hook 阻断后触发 |
@ -106,9 +106,9 @@ hook response
</hook_result>
```
如果多个 `UserPromptSubmit` hook 返回文本,每个结果都会拥有独立的 `<hook_result>` 标签。这条消息会带有 hook 结果来源,用于 transcript/replay但不会发给模型。模型只看到原始用户输入,当前轮次继续。
如果多个 `UserPromptSubmit` hook 返回文本,每个结果都会拥有独立的 `<hook_result>` 标签。这条消息会带有 hook 结果来源,用于 transcript/replay并会在原始用户提示词之后发给模型,然后当前轮次继续。
如果 `UserPromptSubmit` hook 阻断请求,阻断原因会使用同样格式返回给用户,但本轮不会继续请求模型
如果 `UserPromptSubmit` hook 阻断请求,阻断原因会使用同样格式返回给用户,但被阻断的轮次不会继续请求模型。被阻断的提示词和阻断原因会保留在会话历史中,并包含在后续模型上下文里
`Stop` 的阻断原因会直接作为系统触发的 User 消息写入上下文,让当前轮次继续:

View file

@ -52,22 +52,6 @@ export class ContextMemory {
});
}
markLastUserPromptBlocked(hookEvent: string): void {
this.agent.records.logRecord({
type: 'context.mark_last_user_prompt_blocked',
hookEvent,
});
for (let i = this._history.length - 1; i >= 0; i--) {
const message = this._history[i];
if (message?.role !== 'user' || message.origin?.kind !== 'user') continue;
this._history[i] = {
...message,
origin: { ...message.origin, blockedByHook: hookEvent },
};
return;
}
}
clear(): void {
this.agent.records.logRecord({ type: 'context.clear' });
this._history = [];

View file

@ -1,145 +1,42 @@
import type { ContentPart, Message, TextPart } from '@moonshot-ai/kosong';
import { renderNotificationXml } from './notification-xml';
import type { ContextMessage } from './types';
type ProjectableMessage = Message & {
readonly origin?:
| {
readonly kind: string;
readonly event?: string | undefined;
readonly blockedByHook?: string | undefined;
}
| undefined;
};
const TRANSCRIPT_ONLY_HOOK_RESULT_EVENTS = new Set(['UserPromptSubmit']);
export interface EphemeralInjection {
kind: 'memory_recall' | 'system_reminder' | 'pending_notification';
content: string | Record<string, unknown>;
position?: 'before_user' | 'after_system';
}
export function project(
history: readonly ProjectableMessage[],
ephemeralInjections?: readonly EphemeralInjection[],
): Message[] {
export function project(history: readonly ContextMessage[]): Message[] {
// Keep partial or empty assistant placeholders away from providers.
// They can appear when a turn is aborted or errors before any content
// or tool call is appended.
const usable = history.filter((message) => {
if (isBlockedUserPrompt(message)) return false;
return (
!isTranscriptOnlyHookResult(message) &&
message.partial !== true &&
!(message.role === 'assistant' && message.content.length === 0 && message.toolCalls.length === 0)
);
});
const merged = mergeAdjacentUserMessages(usable);
const injectionMessages = ephemeralInjections?.map((injection) => renderInjection(injection));
// Ephemeral injections sit before the first history message
// (before_user) so things like system_reminder land right before the
// user turn they contextualise.
return injectionMessages ? [...injectionMessages, ...merged] : merged;
return mergeAdjacentUserMessages(usable);
}
function isTranscriptOnlyHookResult(message: ProjectableMessage): boolean {
return (
message.origin?.kind === 'hook_result' &&
TRANSCRIPT_ONLY_HOOK_RESULT_EVENTS.has(message.origin.event ?? '')
);
}
function isBlockedUserPrompt(message: ProjectableMessage): boolean {
return message.role === 'user' && message.origin?.blockedByHook === 'UserPromptSubmit';
}
/**
* Render an EphemeralInjection into a synthetic user message. System
* reminders and pending notifications use XML wrappers so the model can
* distinguish host annotations from genuine user text. `memory_recall`
* stays as free text.
*
* The merge-guard logic downstream (`mergeAdjacentUserMessages`) uses
* the `<notification ` / `<system-reminder>` opening tag to detect
* these messages, so the exact tag names are load-bearing for
* projector correctness do not rename without also updating
* `isInjectionUserMessage` below.
*/
function renderInjection(injection: EphemeralInjection): Message {
const text = renderInjectionText(injection);
return {
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
};
}
function renderInjectionText(injection: EphemeralInjection): string {
const { kind, content } = injection;
if (kind === 'pending_notification') {
// Production callers pass notification metadata, but accepting a
// string keeps older embedders from crashing on replay/projection.
if (typeof content === 'string') {
return `<notification>\n${content}\n</notification>`;
}
return renderNotificationXml(content);
}
if (kind === 'system_reminder') {
const body = typeof content === 'string' ? content : JSON.stringify(content);
return `<system-reminder>\n${body}\n</system-reminder>`;
}
const body = typeof content === 'string' ? content : JSON.stringify(content);
return body;
}
/**
* Detect whether a user message was produced by the ephemeral injection
* pipeline (system_reminder or notification XML tag). Such messages
* must never be merged with an adjacent real user turn doing so would
* smear the injection's XML wrapper into the user's actual prompt and
* confuse the LLM about where the system annotation ends.
*
*/
function isInjectionUserMessage(message: Message): boolean {
if (message.role !== 'user') return false;
const text = extractTextOnly(message);
// Cheap leading-fragment check — injections always have the opening
// tag at the start. We use `trimStart()` so leading whitespace
// doesn't defeat the check, and require `'<notification '` (with
// trailing space) so user text like `<notificationally` or the
// bare `<notification>` tag (no attributes) is not misidentified.
const trimmed = text.trimStart();
if (trimmed.startsWith('<notification ')) return true;
if (trimmed.startsWith('<system-reminder>')) return true;
if (trimmed.startsWith('<hook_result ')) return true;
if (trimmed.startsWith('<cron-fire ')) return true;
return false;
}
function mergeAdjacentUserMessages(history: readonly Message[]): Message[] {
const out: Message[] = [];
function mergeAdjacentUserMessages(history: readonly ContextMessage[]): Message[] {
const out: ContextMessage[] = [];
for (const message of history) {
const previous = out.at(-1);
if (
message.role === 'user' &&
canMergeUserMessage(message) &&
previous !== undefined &&
previous.role === 'user' &&
!isInjectionUserMessage(message) &&
!isInjectionUserMessage(previous)
canMergeUserMessage(previous)
) {
out[out.length - 1] = mergeTwoUserMessages(previous, message);
continue;
}
// Clone into a fresh Message so we never mutate input arrays.
out.push(cloneMessage(message));
out.push(message);
}
return out;
return out.map(stripContextMetadata);
}
function mergeTwoUserMessages(a: Message, b: Message): Message {
function canMergeUserMessage(message: ContextMessage): boolean {
return message.role === 'user' && message.origin?.kind === 'user';
}
function mergeTwoUserMessages(a: ContextMessage, b: ContextMessage): ContextMessage {
const aText = extractTextOnly(a);
const bText = extractTextOnly(b);
const nonTextParts = [
@ -152,6 +49,7 @@ function mergeTwoUserMessages(a: Message, b: Message): Message {
role: 'user',
content,
toolCalls: [],
origin: a.origin,
};
}
@ -162,7 +60,7 @@ function extractTextOnly(message: Message): string {
.join('');
}
function cloneMessage(message: Message): Message {
function stripContextMetadata(message: ContextMessage): Message {
return {
role: message.role,
name: message.name,

View file

@ -5,7 +5,6 @@ import type { BackgroundTaskStatus } from '../../tools/background';
export interface UserPromptOrigin {
readonly kind: 'user';
readonly blockedByHook?: string | undefined;
}
export const USER_PROMPT_ORIGIN: UserPromptOrigin = { kind: 'user' };

View file

@ -70,9 +70,6 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void {
case 'context.append_message':
agent.context.appendMessage(input.message);
return;
case 'context.mark_last_user_prompt_blocked':
agent.context.markLastUserPromptBlocked(input.hookEvent);
return;
case 'context.append_loop_event':
agent.context.appendLoopEvent(input.event);
return;

View file

@ -66,7 +66,6 @@ export interface AgentRecordEvents {
'full_compaction.complete': {};
'context.append_message': { message: ContextMessage };
'context.mark_last_user_prompt_blocked': { hookEvent: string };
'context.append_loop_event': { event: LoopRecordedEvent };
'context.clear': {};
'context.apply_compaction': CompactionResult;

View file

@ -316,7 +316,6 @@ export class TurnFlow {
signal.throwIfAborted();
const blockResult = renderUserPromptHookBlockResult(promptHookResults);
if (blockResult !== undefined) {
this.agent.context.markLastUserPromptBlocked('UserPromptSubmit');
this.agent.context.appendMessage({
role: 'assistant',
content: [{ type: 'text', text: blockResult.text }],

View file

@ -105,7 +105,8 @@ export interface ExportSessionResult {
}
export interface ListSessionsPayload {
readonly workDir: string;
readonly workDir?: string;
readonly sessionId?: string;
}
export interface CoreInfo {

View file

@ -317,12 +317,8 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
return this.resumeSession({ sessionId: id });
}
async listSessions(input: ListSessionsPayload): Promise<readonly SessionSummary[]> {
const options = input;
return this.sessionStore.list({
...options,
workDir: requiredWorkDir('listSessions', options.workDir),
});
async listSessions(input: ListSessionsPayload = {}): Promise<readonly SessionSummary[]> {
return this.sessionStore.list(input);
}
async renameSession({ sessionId, ...payload }: RenameSessionRequest): Promise<void> {

View file

@ -135,8 +135,27 @@ export class SessionStore {
await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8');
}
async list(options: ListSessionsPayload): Promise<readonly SessionSummary[]> {
const workDir = normalizeWorkDir(options.workDir);
async list(options: ListSessionsPayload = {}): Promise<readonly SessionSummary[]> {
const workDir =
options.workDir === undefined ? undefined : normalizeRequiredWorkDir(options.workDir);
const sessionId = normalizeOptionalSessionId(options.sessionId);
if (workDir !== undefined) {
if (sessionId !== undefined) {
const local = await this.summaryFromWorkDirSession(sessionId, workDir);
if (local !== undefined) return [local];
return this.listSessionId(sessionId);
}
return this.listWorkDir(workDir);
}
if (sessionId !== undefined) {
return this.listSessionId(sessionId);
}
return this.listAll();
}
private async listWorkDir(workDir: string): Promise<readonly SessionSummary[]> {
const bucketDir = join(this.sessionsDir, encodeWorkDirKey(workDir));
let entries;
try {
@ -157,6 +176,38 @@ export class SessionStore {
return sessions;
}
private async listSessionId(sessionId: string): Promise<readonly SessionSummary[]> {
try {
return [await this.get(sessionId)];
} catch (error) {
if (error instanceof KimiError && error.code === ErrorCodes.SESSION_NOT_FOUND) {
return [];
}
throw error;
}
}
private async listAll(): Promise<readonly SessionSummary[]> {
const index = await readSessionIndex(this.homeDir, this.sessionsDir);
const sessions: SessionSummary[] = [];
for (const entry of index.values()) {
if (!(await isDirectory(entry.sessionDir))) continue;
sessions.push(await this.summaryFromDir(entry.sessionId, entry.sessionDir, entry.workDir));
}
sessions.sort(compareSessionSummary);
return sessions;
}
private async summaryFromWorkDirSession(
sessionId: string,
workDir: string,
): Promise<SessionSummary | undefined> {
if (!isSafeSessionId(sessionId)) return undefined;
const sessionDir = this.sessionDirFor({ id: sessionId, workDir });
if (!(await isDirectory(sessionDir))) return undefined;
return this.summaryFromDir(sessionId, sessionDir, workDir);
}
async assertDirectory(id: string): Promise<string> {
return (await this.findExistingSessionEntry(id)).sessionDir;
}
@ -287,6 +338,17 @@ async function readOptionalState(sessionDir: string): Promise<SessionSummaryStat
}
}
function normalizeRequiredWorkDir(workDir: string): string {
if (workDir.trim() === '') {
throw new KimiError(ErrorCodes.REQUEST_WORK_DIR_REQUIRED, 'listSessions requires workDir');
}
return normalizeWorkDir(workDir);
}
function normalizeOptionalSessionId(sessionId: string | undefined): string | undefined {
return sessionId === undefined ? undefined : sessionId.trim();
}
function normalizeForkTitle(title: string | undefined, fallback: unknown): string {
if (title !== undefined) {
const normalized = title.trim();

View file

@ -14,7 +14,7 @@
import { createHash } from 'node:crypto';
import { createWriteStream, existsSync } from 'node:fs';
import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises';
import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { basename, join } from 'pathe';
import { Readable } from 'node:stream';
@ -243,8 +243,15 @@ async function downloadAndInstallRg(shareDir: string): Promise<string> {
'CDN content may have changed.',
);
}
await rename(extracted, destination);
await chmod(destination, 0o755);
const installDir = await mkdtemp(join(binDir, '.rg-install-'));
const staged = join(installDir, rgBinaryName());
try {
await copyFile(extracted, staged);
await chmod(staged, 0o755);
await rename(staged, destination);
} finally {
await rm(installDir, { recursive: true, force: true });
}
}
return destination;
} finally {

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import { renderNotificationXml } from '../../src/agent/context/notification-xml';
import { project } from '../../src/agent/context/projector';
import type { ContextMessage } from '../../src/agent/context/types';
import { estimateTokensForMessages } from '../../src/utils/tokens';
import { testAgent } from './harness/agent';
@ -79,7 +80,7 @@ describe('Agent context', () => {
]);
});
it('keeps hook result transcript messages out of LLM projection', async () => {
it('projects hook result messages into LLM projection', async () => {
const ctx = testAgent();
ctx.configure();
@ -117,19 +118,43 @@ describe('Agent context', () => {
expect(ctx.agent.context.messages).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'hooked input\n\ncontinue from stop hook' }],
content: [{ type: 'text', text: 'hooked input' }],
toolCalls: [],
},
{
role: 'user',
content: [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nhook response\n</hook_result>',
},
],
toolCalls: [],
},
{
role: 'assistant',
content: [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>',
},
],
toolCalls: [],
},
{
role: 'user',
content: [{ type: 'text', text: 'continue from stop hook' }],
toolCalls: [],
},
]);
await ctx.expectResumeMatches();
});
it('keeps blocked UserPromptSubmit prompts out of LLM projection', async () => {
it('projects blocked UserPromptSubmit prompts into LLM projection', async () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'blocked prompt' }]);
ctx.agent.context.markLastUserPromptBlocked('UserPromptSubmit');
ctx.agent.context.appendMessage({
role: 'assistant',
content: [
@ -145,6 +170,21 @@ describe('Agent context', () => {
expect(ctx.agent.context.history).toHaveLength(3);
expect(ctx.agent.context.messages).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'blocked prompt' }],
toolCalls: [],
},
{
role: 'assistant',
content: [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>',
},
],
toolCalls: [],
},
{
role: 'user',
content: [{ type: 'text', text: 'safe followup' }],
@ -509,56 +549,60 @@ describe('Agent context notification projection', () => {
expect(text).not.toContain('should stay out of the XML');
});
it('keeps pending notification injections separate from real user prompts', () => {
const messages = project(
[userMessage('Actual user prompt')],
[
{
kind: 'pending_notification',
content: {
id: 'n_1',
category: 'task',
type: 'task.done',
source_kind: 'background_task',
source_id: 'bg_1',
title: 'Task done',
severity: 'info',
body: 'Background task finished.',
},
},
],
);
expect(messages).toHaveLength(2);
expect(textOf(messages[0]!)).toMatch(/^<notification /);
expect(textOf(messages[0]!)).toContain('Task done');
expect(textOf(messages[1]!)).toBe('Actual user prompt');
});
it('does not merge a cron-fire envelope into an adjacent user message', () => {
// Cron fires arrive as user-role messages whose text starts with
// `<cron-fire `. `mergeAdjacentUserMessages` must treat them like
// <notification>/<system-reminder>/<hook_result> and keep them in
// separate messages — otherwise the envelope XML smears into a
// real user turn and confuses the LLM about where the system
// annotation ends.
const cronEnvelope =
'<cron-fire jobId="deadbeef" cron="*/5 * * * *" recurring="true" coalescedCount="1" stale="false">\n<prompt>\ncheck the deploy\n</prompt>\n</cron-fire>';
const messages = project([
userMessage(cronEnvelope),
userMessage('Actual follow-up from the user'),
userMessage(cronEnvelope, {
kind: 'cron_job',
jobId: 'deadbeef',
cron: '*/5 * * * *',
recurring: true,
coalescedCount: 1,
stale: false,
}),
userMessage('Actual follow-up from the user', { kind: 'user' }),
]);
expect(messages).toHaveLength(2);
expect(textOf(messages[0]!)).toBe(cronEnvelope);
expect(textOf(messages[1]!)).toBe('Actual follow-up from the user');
});
it('uses message origin to keep non-user-origin messages separate', () => {
const messages = project([
userMessage('Host reminder without an XML prefix', {
kind: 'injection',
variant: 'host',
}),
userMessage('Actual follow-up from the user', { kind: 'user' }),
]);
expect(messages).toHaveLength(2);
expect(textOf(messages[0]!)).toBe('Host reminder without an XML prefix');
expect(textOf(messages[1]!)).toBe('Actual follow-up from the user');
});
it('only merges user-role messages with user origin', () => {
const messages = project([
userMessage('First real prompt', { kind: 'user' }),
userMessage('Second real prompt', { kind: 'user' }),
userMessage('No origin prompt'),
userMessage('Third real prompt', { kind: 'user' }),
]);
expect(messages).toHaveLength(3);
expect(textOf(messages[0]!)).toBe('First real prompt\n\nSecond real prompt');
expect(textOf(messages[1]!)).toBe('No origin prompt');
expect(textOf(messages[2]!)).toBe('Third real prompt');
});
});
function userMessage(text: string): Message {
function userMessage(text: string, origin?: ContextMessage['origin']): ContextMessage {
return {
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
origin,
};
}

View file

@ -264,7 +264,7 @@ describe('Agent turn flow', () => {
);
});
it('continues the turn after showing UserPromptSubmit hook output without injecting it', async () => {
it('continues the turn after projecting UserPromptSubmit hook output', async () => {
const hookEngine = new HookEngine([
{
event: 'UserPromptSubmit',
@ -293,6 +293,7 @@ describe('Agent turn flow', () => {
tools: []
messages:
user: text "hooked input"
user: text "<hook_result hook_event=\\"UserPromptSubmit\\">\\nhook response 1\\n</hook_result>\\n<hook_result hook_event=\\"UserPromptSubmit\\">\\nhook response 2\\n</hook_result>"
`);
expect(events).toContainEqual(
expect.objectContaining({
@ -330,7 +331,7 @@ describe('Agent turn flow', () => {
]);
});
it('shows structured UserPromptSubmit stdout without injecting it', async () => {
it('projects structured UserPromptSubmit stdout', async () => {
const hookEngine = new HookEngine([
{
event: 'UserPromptSubmit',
@ -356,6 +357,7 @@ describe('Agent turn flow', () => {
tools: []
messages:
user: text "hooked input"
user: text "<hook_result hook_event=\\"UserPromptSubmit\\">\\n{}\\n</hook_result>\\n<hook_result hook_event=\\"UserPromptSubmit\\">\\n{\\"hookSpecificOutput\\":{}}\\n</hook_result>"
`);
expect(events).toContainEqual(
expect.objectContaining({
@ -423,7 +425,7 @@ describe('Agent turn flow', () => {
role: 'user',
content: [{ type: 'text', text: 'bad words here' }],
toolCalls: [],
origin: { kind: 'user', blockedByHook: 'UserPromptSubmit' },
origin: { kind: 'user' },
},
{
role: 'assistant',
@ -442,6 +444,8 @@ describe('Agent turn flow', () => {
system: <system-prompt>
tools: []
messages:
user: text "bad words here"
assistant: text "<hook_result hook_event=\\"UserPromptSubmit\\">\\nno profanity\\n</hook_result>"
user: text "safe followup"
`);
});

View file

@ -0,0 +1,70 @@
import { APIConnectionError, emptyUsage, isRetryableGenerateError } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import type { LLM, LLMChatParams, LLMChatResponse } from '#/loop/llm';
import { chatWithRetry } from '#/loop/retry';
function okResponse(): LLMChatResponse {
return { toolCalls: [], usage: emptyUsage() };
}
function makeInput(
llm: LLM,
signal: AbortSignal,
): Parameters<typeof chatWithRetry>[0] {
return {
llm,
params: { messages: [], tools: [], signal },
dispatchEvent: async () => {},
turnId: 't',
currentStep: 1,
stepUuid: 'u',
};
}
describe('chatWithRetry: terminated stream drops', () => {
it('retries an APIConnectionError("terminated") and succeeds on a later attempt', async () => {
// A mid-stream `terminated` is classified as a retryable APIConnectionError,
// so an intermittent connection drop should be recovered transparently.
let calls = 0;
const llm: LLM = {
systemPrompt: '',
modelName: 'mock',
isRetryableError: (e) => isRetryableGenerateError(e),
async chat(_params: LLMChatParams): Promise<LLMChatResponse> {
calls += 1;
if (calls === 1) throw new APIConnectionError('terminated');
return okResponse();
},
};
const response = await chatWithRetry(makeInput(llm, new AbortController().signal));
expect(calls).toBe(2);
expect(response).toEqual(okResponse());
});
it('does NOT retry when the signal is aborted (user ESC), surfacing a clean AbortError', async () => {
// Even though `terminated` is retryable, a user-aborted request must never
// be retried: the abort signal is checked before any retry, so it surfaces
// as an AbortError rather than a provider error.
let calls = 0;
const ac = new AbortController();
ac.abort();
const llm: LLM = {
systemPrompt: '',
modelName: 'mock',
isRetryableError: (e) => isRetryableGenerateError(e),
async chat(_params: LLMChatParams): Promise<LLMChatResponse> {
calls += 1;
throw new APIConnectionError('terminated');
},
};
await expect(chatWithRetry(makeInput(llm, ac.signal))).rejects.toMatchObject({
name: 'AbortError',
});
expect(calls).toBe(1);
});
});

View file

@ -617,7 +617,8 @@ describe('SessionSubagentHost', () => {
system: "explore prompt"
tools: Read
messages:
user: text "Earlier context\\n\\nContinue from context"
user: text "Earlier context"
user: text "Continue from context"
`);
expect(parent.allEvents).toContainEqual(
expect.objectContaining({

View file

@ -84,7 +84,11 @@ export function toolToOpenAI(tool: Tool): OpenAIToolParam {
},
};
}
const NETWORK_RE = /network|connection|connect|disconnect/i;
// `terminated` is the undici signature for an SSE/HTTP body stream that is
// dropped mid-flight (common with Node's native fetch on long reasoning
// streams). It surfaces as a raw `TypeError: terminated`, so it must be
// recognized here as a transport-layer connection failure.
const NETWORK_RE = /network|connection|connect|disconnect|terminated/i;
const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i;
function classifyBaseApiError(message: string): ChatProviderError {
@ -129,8 +133,13 @@ export function convertOpenAIError(error: unknown): ChatProviderError {
if (error instanceof OpenAIError) {
return new ChatProviderError(`Error: ${error.message}`);
}
// Raw, non-SDK errors (e.g. undici's `TypeError: terminated` raised when a
// streaming response body is dropped mid-flight) never get wrapped by the
// OpenAI SDK during stream iteration. Route them through the same
// transport-layer heuristic so genuine connection failures become
// retryable instead of fatal generic errors.
if (error instanceof Error) {
return new ChatProviderError(`Error: ${error.message}`);
return classifyBaseApiError(error.message);
}
return new ChatProviderError(`Error: ${String(error)}`);
}

View file

@ -4,6 +4,7 @@ import {
APIStatusError,
APITimeoutError,
ChatProviderError,
isRetryableGenerateError,
} from '#/errors';
import type { ContentPart } from '#/message';
import {
@ -186,6 +187,58 @@ describe('OpenAI streaming error propagation', () => {
}).rejects.toThrow(/Network connection lost/);
});
});
describe('convertOpenAIError: raw transport-layer stream errors', () => {
it('classifies undici TypeError("terminated") as a retryable APIConnectionError', () => {
// Node v24 + undici raises a raw `TypeError: terminated` when an SSE
// response stream is dropped mid-flight. It is NOT an OpenAI SDK error,
// so it falls into the generic Error branch — but it is a transport-layer
// connection failure and must be retryable like any dropped connection.
const err = new TypeError('terminated');
(err as { cause?: unknown }).cause = new Error('other side closed');
const result = convertOpenAIError(err);
expect(result).toBeInstanceOf(APIConnectionError);
expect(isRetryableGenerateError(result)).toBe(true);
});
it('still wraps an unrelated raw Error as a non-retryable ChatProviderError', () => {
const result = convertOpenAIError(new Error('something completely unrelated'));
expect(result.constructor).toBe(ChatProviderError);
expect(isRetryableGenerateError(result)).toBe(false);
});
});
describe('OpenAI streaming: undici terminated mid-stream', () => {
it('a stream that throws TypeError("terminated") rejects with retryable APIConnectionError', async () => {
// Simulates the real-world failure: the SSE stream drops mid-flight and
// undici raises a raw `TypeError: terminated` from inside the for-await
// loop. The provider must surface a retryable APIConnectionError so the
// loop retries instead of failing the turn outright.
async function* terminatedStream(): AsyncGenerator<never> {
throw new TypeError('terminated');
yield undefined as never;
}
const msg = new OpenAILegacyStreamedMessage(
terminatedStream() as AsyncIterable<never>,
true,
undefined,
);
let caught: unknown;
try {
for await (const _ of msg) {
void _;
}
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(APIConnectionError);
expect(isRetryableGenerateError(caught)).toBe(true);
});
});
describe('convertContentPart', () => {
it('converts TextPart to OpenAI text content part', () => {
expect(convertContentPart({ type: 'text', text: 'hi' })).toEqual({

View file

@ -183,7 +183,7 @@ export class KimiHarness {
return result;
}
async listSessions(options: ListSessionsOptions): Promise<readonly SessionSummary[]> {
async listSessions(options: ListSessionsOptions = {}): Promise<readonly SessionSummary[]> {
return this.rpc.listSessions(options);
}

View file

@ -167,7 +167,7 @@ export class SDKRpcClient {
return rpc.closeSession({ sessionId: input.sessionId });
}
async listSessions(input: ListSessionsOptions): Promise<readonly SessionSummary[]> {
async listSessions(input: ListSessionsOptions = {}): Promise<readonly SessionSummary[]> {
const rpc = await this.getRpc();
return rpc.listSessions(input);
}

View file

@ -113,7 +113,8 @@ export interface ExportSessionResult {
}
export interface ListSessionsOptions {
readonly workDir: string;
readonly workDir?: string;
readonly sessionId?: string;
}
export interface GetConfigOptions {

View file

@ -244,6 +244,14 @@ describe('KimiHarness.createSession transport link', () => {
expect(summary?.sessionDir).toContain(join(homeDir, 'sessions'));
expect(existsSync(join(summary!.sessionDir, 'state.json'))).toBe(true);
expect(await readFile(join(homeDir, 'session_index.jsonl'), 'utf-8')).toContain(session.id);
const summariesById = await harness.listSessions({ sessionId: session.id });
expect(summariesById).toHaveLength(1);
expect(summariesById[0]).toMatchObject({
id: session.id,
workDir,
});
await expect(harness.listSessions({ sessionId: 'ses_missing' })).resolves.toEqual([]);
} finally {
await harness.close();
}

View file

@ -144,6 +144,58 @@ describe('SessionStore.list', () => {
expect(sessions.map((session) => session.id)).toEqual(['ses_list_a']);
});
it('uses the workDir bucket before the session index when sessionId is provided', async () => {
const homeDir = await makeTempDir();
const workDir = await makeTempDir();
const store = new SessionStore(homeDir);
const local = await store.create({ id: 'ses_bucket_hit', workDir });
await rm(sessionIndexPath(homeDir), { force: true });
const sessions = await store.list({ workDir, sessionId: local.id });
expect(sessions.map((session) => session.id)).toEqual([local.id]);
});
it('falls back to the session index when a workDir-scoped sessionId is not in that bucket', async () => {
const homeDir = await makeTempDir();
const workDir = await makeTempDir();
const otherWorkDir = await makeTempDir();
const store = new SessionStore(homeDir);
await store.create({ id: 'ses_local', workDir });
const other = await store.create({ id: 'ses_index_fallback', workDir: otherWorkDir });
const sessions = await store.list({ workDir, sessionId: other.id });
expect(sessions).toHaveLength(1);
expect(sessions[0]).toMatchObject({
id: other.id,
workDir: otherWorkDir,
});
});
it('lists every indexed session when no filters are provided', async () => {
const homeDir = await makeTempDir();
const workDir = await makeTempDir();
const otherWorkDir = await makeTempDir();
const store = new SessionStore(homeDir);
await store.create({ id: 'ses_all_a', workDir });
await store.create({ id: 'ses_all_b', workDir: otherWorkDir });
const sessions = await store.list();
expect(sessions.map((session) => session.id).toSorted()).toEqual([
'ses_all_a',
'ses_all_b',
]);
});
it('returns an empty array when a sessionId filter is unknown', async () => {
const homeDir = await makeTempDir();
const store = new SessionStore(homeDir);
await expect(store.list({ sessionId: 'ses_missing' })).resolves.toEqual([]);
});
it('reads title from customTitle before title', async () => {
const homeDir = await makeTempDir();
const workDir = await makeTempDir();
@ -236,18 +288,24 @@ describe('KimiHarness.listSessions', () => {
}
});
it('rejects undefined payload as KimiError(internal)', async () => {
it('lists all sessions when no payload is provided', async () => {
const homeDir = await makeTempDir();
const workDir = await makeTempDir();
const otherWorkDir = await makeTempDir();
const harness = new KimiHarness({
identity: TEST_IDENTITY,
homeDir,
});
try {
await expect(harness.listSessions(undefined as never)).rejects.toMatchObject({
name: 'KimiError',
code: 'internal',
} satisfies Partial<KimiError>);
await harness.createSession({ id: 'ses_harness_all_a', workDir });
await harness.createSession({ id: 'ses_harness_all_b', workDir: otherWorkDir });
const sessions = await harness.listSessions();
expect(sessions.map((session) => session.id).toSorted()).toEqual([
'ses_harness_all_a',
'ses_harness_all_b',
]);
} finally {
await harness.close();
}

2672
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@ packages:
- apps/*
- apps/vis/server
- apps/vis/web
- docs
catalog:
zod: ^4.3.6