mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 06:05:36 +00:00
Merge remote-tracking branch 'origin/main' into kaiyi/karachi
This commit is contained in:
commit
fc5e4bf787
47 changed files with 3362 additions and 3454 deletions
5
.changeset/fix-resume-missing-session-footer-leak.md
Normal file
5
.changeset/fix-resume-missing-session-footer-leak.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix footer leaking onto the terminal when resuming a non-existent session.
|
||||
6
.changeset/fix-ripgrep-cross-device-copy.md
Normal file
6
.changeset/fix-ripgrep-cross-device-copy.md
Normal 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.
|
||||
5
.changeset/footer-rotating-tips.md
Normal file
5
.changeset/footer-rotating-tips.md
Normal 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.
|
||||
5
.changeset/plan-usage-percent-used.md
Normal file
5
.changeset/plan-usage-percent-used.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Improve the usage information display in the TUI.
|
||||
6
.changeset/project-context-messages.md
Normal file
6
.changeset/project-context-messages.md
Normal 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.
|
||||
7
.changeset/resume-cross-workdir-sessions.md
Normal file
7
.changeset/resume-cross-workdir-sessions.md
Normal 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.
|
||||
6
.changeset/retry-terminated-stream.md
Normal file
6
.changeset/retry-terminated-stream.md
Normal 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.
|
||||
6
.changeset/user-prompt-hook-context.md
Normal file
6
.changeset/user-prompt-hook-context.md
Normal 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.
|
||||
3
.github/workflows/docs-deploy.yml
vendored
3
.github/workflows/docs-deploy.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> };
|
||||
|
||||
|
|
|
|||
|
|
@ -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+',
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
shamefully-hoist=true
|
||||
|
|
@ -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
3179
docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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 消息写入上下文,让当前轮次继续:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -105,7 +105,8 @@ export interface ExportSessionResult {
|
|||
}
|
||||
|
||||
export interface ListSessionsPayload {
|
||||
readonly workDir: string;
|
||||
readonly workDir?: string;
|
||||
readonly sessionId?: string;
|
||||
}
|
||||
|
||||
export interface CoreInfo {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
`);
|
||||
});
|
||||
|
|
|
|||
70
packages/agent-core/test/loop/retry.test.ts
Normal file
70
packages/agent-core/test/loop/retry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,8 @@ export interface ExportSessionResult {
|
|||
}
|
||||
|
||||
export interface ListSessionsOptions {
|
||||
readonly workDir: string;
|
||||
readonly workDir?: string;
|
||||
readonly sessionId?: string;
|
||||
}
|
||||
|
||||
export interface GetConfigOptions {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
2672
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,7 @@ packages:
|
|||
- apps/*
|
||||
- apps/vis/server
|
||||
- apps/vis/web
|
||||
- docs
|
||||
|
||||
catalog:
|
||||
zod: ^4.3.6
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue