mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-23 23:56:41 +00:00
fix: agent-code-v2 re-exports
This commit is contained in:
parent
570e1524bd
commit
fd7909989a
7 changed files with 44 additions and 270 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import type { ContentPart, Message } from '@moonshot-ai/kosong';
|
||||
|
||||
import type { BackgroundTaskStatus } from '../background';
|
||||
import type { CronJobOrigin, CronMissedOrigin } from '@moonshot-ai/protocol';
|
||||
|
||||
export type SkillSource = 'project' | 'user' | 'extra' | 'builtin';
|
||||
|
||||
|
|
@ -42,23 +43,6 @@ export interface BackgroundTaskOrigin {
|
|||
readonly notificationId: string;
|
||||
}
|
||||
|
||||
export interface CronJobOrigin {
|
||||
readonly kind: 'cron_job';
|
||||
readonly jobId: string;
|
||||
readonly cron: string;
|
||||
readonly recurring: boolean;
|
||||
/** Number of theoretical fires that were collapsed into this single delivery (>= 1). */
|
||||
readonly coalescedCount: number;
|
||||
/** True for recurring tasks past the 7-day age threshold. */
|
||||
readonly stale: boolean;
|
||||
}
|
||||
|
||||
export interface CronMissedOrigin {
|
||||
readonly kind: 'cron_missed';
|
||||
/** Number of one-shot tasks bundled into this missed-fire notification. */
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface HookResultOrigin {
|
||||
readonly kind: 'hook_result';
|
||||
readonly event: string;
|
||||
|
|
|
|||
|
|
@ -34,20 +34,6 @@ export interface CronFireOptions {
|
|||
readonly firedAt?: number;
|
||||
}
|
||||
|
||||
export interface CronJobOrigin {
|
||||
readonly kind: 'cron_job';
|
||||
readonly jobId: string;
|
||||
readonly cron: string;
|
||||
readonly recurring: boolean;
|
||||
readonly coalescedCount: number;
|
||||
readonly stale: boolean;
|
||||
}
|
||||
|
||||
export interface CronMissedOrigin {
|
||||
readonly kind: 'cron_missed';
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface ICronService extends CronToolManager {
|
||||
readonly _serviceBrand: undefined;
|
||||
readonly isEnabled: boolean;
|
||||
|
|
|
|||
|
|
@ -29,9 +29,7 @@ import { IWireRecord } from '#/wireRecord';
|
|||
import {
|
||||
ICronService,
|
||||
type CronFireOptions,
|
||||
type CronJobOrigin,
|
||||
type CronLoadOptions,
|
||||
type CronMissedOrigin,
|
||||
type CronOptions,
|
||||
type CronPersistence,
|
||||
type CronTaskInit,
|
||||
|
|
@ -65,6 +63,7 @@ import {
|
|||
CRON_SCHEDULED,
|
||||
} from './tools/telemetry-events';
|
||||
import type { CronTask, CronToolManager } from './tools/types';
|
||||
import type { CronJobOrigin, CronMissedOrigin } from '@moonshot-ai/protocol';
|
||||
|
||||
declare module '#/wireRecord' {
|
||||
interface WireRecordMap {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
* verbatim. The injection target is an LLM-visible transcript where
|
||||
* double-escaping would be noisier than literal punctuation.
|
||||
*/
|
||||
import type { CronJobOrigin } from '../cron';
|
||||
import type { CronJobOrigin } from "@moonshot-ai/protocol";
|
||||
|
||||
export function renderCronFireXml(
|
||||
origin: CronJobOrigin,
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ import type { Message, TokenUsage, ToolCall } from '@moonshot-ai/kosong';
|
|||
import type { ExecutableTool, ExecutableToolResult, RunnableToolExecution } from '#/tool';
|
||||
import type { LLM } from './llm';
|
||||
|
||||
export type { ToolCall };
|
||||
|
||||
export type LoopMessageBuilder = () => Message[] | Promise<Message[]>;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { PrepareToolExecutionResult, ResolvedToolExecutionHookContext } from '#/loop';
|
||||
import type { ToolInputDisplay } from '@moonshot-ai/protocol';
|
||||
import type { ApprovalResponse } from '@moonshot-ai/protocol';
|
||||
import type { PermissionRule } from '#/permissionRules';
|
||||
|
||||
/**
|
||||
|
|
@ -13,21 +13,6 @@ import type { PermissionRule } from '#/permissionRules';
|
|||
*/
|
||||
export type PermissionMode = 'manual' | 'yolo' | 'auto';
|
||||
|
||||
|
||||
export interface ApprovalRequest {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
action: string;
|
||||
display: ToolInputDisplay;
|
||||
}
|
||||
|
||||
export interface ApprovalResponse {
|
||||
decision: 'approved' | 'rejected' | 'cancelled';
|
||||
scope?: 'session';
|
||||
feedback?: string;
|
||||
selectedLabel?: string;
|
||||
}
|
||||
|
||||
export interface PermissionData {
|
||||
mode: PermissionMode;
|
||||
rules: PermissionRule[];
|
||||
|
|
|
|||
|
|
@ -1,234 +1,56 @@
|
|||
import type { ToolCall } from '@moonshot-ai/kosong';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ErrorCodes, KimiError } from '../../../src/errors';
|
||||
import { IQuestionService, type QuestionResult } from '../../../src/services';
|
||||
import { testAgent } from './harness';
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { createServices, type TestInstantiationService } from '#/_base/di/test';
|
||||
import { IInteractionService } from '#/interaction';
|
||||
import { InteractionService } from '#/interaction/interactionService';
|
||||
import { IQuestionService, type QuestionRequest } from '#/question';
|
||||
import { QuestionService } from '#/question/questionService';
|
||||
|
||||
describe('Agent question', () => {
|
||||
it('roundtrips a question request through wire rpc', async () => {
|
||||
const ctx = testAgent();
|
||||
function makeRequest(id: string): QuestionRequest {
|
||||
return { id, prompt: 'name?' };
|
||||
}
|
||||
|
||||
const resultPromise = ctx.service(IQuestionService).request({
|
||||
sessionId: 'session-1',
|
||||
agentId: 'agent-1',
|
||||
questions: [
|
||||
{
|
||||
question: 'Pick one',
|
||||
options: [{ label: 'Yes' }, { label: 'No' }],
|
||||
},
|
||||
],
|
||||
describe('QuestionService', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
ix = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
reg.define(IInteractionService, InteractionService);
|
||||
reg.define(IQuestionService, QuestionService);
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
expect(await ctx.untilQuestion({ Yes: true })).toMatchInlineSnapshot(
|
||||
`[emit] requestQuestion { "questions": [ { "question": "Pick one", "options": [ { "label": "Yes" }, { "label": "No" } ] } ] }`,
|
||||
);
|
||||
it('request parks until answer resolves it', async () => {
|
||||
const svc = ix.get(IQuestionService);
|
||||
const req = makeRequest('q1');
|
||||
const pending = svc.request(req);
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({ Yes: true });
|
||||
await ctx.expectResumeMatches();
|
||||
expect(svc.listPending()).toEqual([req]);
|
||||
svc.answer('q1', 'kimi');
|
||||
|
||||
await expect(pending).resolves.toBe('kimi');
|
||||
expect(svc.listPending()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends multiple questions in one request', async () => {
|
||||
const ctx = testAgent();
|
||||
|
||||
const resultPromise = ctx.service(IQuestionService).request({
|
||||
sessionId: 'session-1',
|
||||
agentId: 'agent-1',
|
||||
questions: [
|
||||
{
|
||||
question: 'Pick one',
|
||||
options: [{ label: 'Yes' }, { label: 'No' }],
|
||||
},
|
||||
{
|
||||
question: 'Pick storage',
|
||||
options: [{ label: 'Postgres' }, { label: 'SQLite' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
await ctx.untilQuestion({ Yes: true, 'Pick storage': 'Postgres' }),
|
||||
).toMatchInlineSnapshot(
|
||||
`[emit] requestQuestion { "questions": [ { "question": "Pick one", "options": [ { "label": "Yes" }, { "label": "No" } ] }, { "question": "Pick storage", "options": [ { "label": "Postgres" }, { "label": "SQLite" } ] } ] }`,
|
||||
);
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({ Yes: true, 'Pick storage': 'Postgres' });
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('registers AskUserQuestion and routes model calls through question service', async () => {
|
||||
const telemetry = { track: vi.fn() };
|
||||
const ctx = testAgent({
|
||||
telemetry,
|
||||
});
|
||||
ctx.configure({ tools: ['AskUserQuestion'] });
|
||||
|
||||
expect(ctx.toolsData().find((tool) => tool.name === 'AskUserQuestion')).toMatchObject({
|
||||
active: true,
|
||||
name: 'AskUserQuestion',
|
||||
source: 'builtin',
|
||||
});
|
||||
|
||||
ctx.mockNextResponse(
|
||||
{ type: 'text', text: 'I need one choice.' },
|
||||
askQuestionCall('call_question'),
|
||||
);
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Ask me.' }] });
|
||||
await ctx.untilQuestion({
|
||||
answers: { 'Pick one': 'Yes' },
|
||||
method: 'number_key',
|
||||
});
|
||||
|
||||
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
||||
system: <system-prompt>
|
||||
tools: AskUserQuestion
|
||||
messages:
|
||||
user: text "Ask me."
|
||||
`);
|
||||
expect(requestQuestionArgs(ctx, 'call_question')).toEqual({
|
||||
turnId: 0,
|
||||
toolCallId: 'call_question',
|
||||
questions: [
|
||||
{
|
||||
question: 'Pick one',
|
||||
header: '',
|
||||
options: [
|
||||
{ label: 'Yes', description: '' },
|
||||
{ label: 'No', description: '' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'Thanks for answering.' });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
expect(toolResultArgs(ctx, 'call_question')).toMatchObject({
|
||||
output: JSON.stringify({ answers: { 'Pick one': 'Yes' } }),
|
||||
});
|
||||
expect(telemetry.track).toHaveBeenCalledWith('question_answered', {
|
||||
answered: 1,
|
||||
method: 'number_key',
|
||||
});
|
||||
expect(String(toolResultArgs(ctx, 'call_question').output)).not.toContain('number_key');
|
||||
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
||||
messages:
|
||||
<last>
|
||||
assistant: text "I need one choice." calls call_question:AskUserQuestion { "questions": [ { "question": "Pick one", "options": [ { "label": "Yes" }, { "label": "No" } ] } ] }
|
||||
tool[call_question]: text "{\\"answers\\":{\\"Pick one\\":\\"Yes\\"}}"
|
||||
`);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('returns a dismissed answer when the user dismisses AskUserQuestion', async () => {
|
||||
const telemetry = { track: vi.fn() };
|
||||
const ctx = testAgent({ telemetry });
|
||||
ctx.configure({ tools: ['AskUserQuestion'] });
|
||||
|
||||
ctx.mockNextResponse(
|
||||
{ type: 'text', text: 'I need one choice.' },
|
||||
askQuestionCall('call_dismissed'),
|
||||
);
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Ask me.' }] });
|
||||
await ctx.untilQuestion(null);
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'No answer was provided.' });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
expect(JSON.parse(String(toolResultArgs(ctx, 'call_dismissed').output))).toEqual({
|
||||
answers: {},
|
||||
note: 'User dismissed the question without answering.',
|
||||
});
|
||||
expect(telemetry.track).toHaveBeenCalledWith('question_dismissed', undefined);
|
||||
});
|
||||
|
||||
it('returns a hard error when the question service reports unsupported questions', async () => {
|
||||
const ctx = testAgent({
|
||||
questionService: unsupportedQuestionService(),
|
||||
});
|
||||
ctx.configure({ tools: ['AskUserQuestion'] });
|
||||
|
||||
ctx.mockNextResponse(
|
||||
{ type: 'text', text: 'I need one choice.' },
|
||||
askQuestionCall('call_unsupported'),
|
||||
);
|
||||
ctx.mockNextResponse({ type: 'text', text: 'I will ask directly instead.' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Ask me.' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
expect(toolResultArgs(ctx, 'call_unsupported')).toMatchObject({
|
||||
isError: true,
|
||||
output: expect.stringContaining('does not support interactive questions'),
|
||||
});
|
||||
expect(String(toolResultArgs(ctx, 'call_unsupported').output)).toContain(
|
||||
'Do NOT call this tool again',
|
||||
);
|
||||
expect(requestQuestionArgs(ctx, 'call_unsupported')).toBeUndefined();
|
||||
it('answer on unknown id is a no-op', () => {
|
||||
const svc = ix.get(IQuestionService);
|
||||
expect(() => svc.answer('missing', 'kimi')).not.toThrow();
|
||||
});
|
||||
|
||||
it('enqueue parks a question without blocking', () => {
|
||||
const svc = ix.get(IQuestionService);
|
||||
const enqueued = svc.enqueue({ id: 'q1', prompt: 'name?' });
|
||||
expect(enqueued).toEqual({ id: 'q1', prompt: 'name?' });
|
||||
expect(svc.listPending()).toEqual([{ id: 'q1', prompt: 'name?' }]);
|
||||
const req = makeRequest('q1');
|
||||
const enqueued = svc.enqueue(req);
|
||||
|
||||
expect(enqueued).toEqual(req);
|
||||
expect(svc.listPending()).toEqual([req]);
|
||||
svc.answer('q1', 'kimi');
|
||||
expect(svc.listPending()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function askQuestionCall(id: string): ToolCall {
|
||||
return {
|
||||
type: 'function',
|
||||
id,
|
||||
name: 'AskUserQuestion',
|
||||
arguments: JSON.stringify({
|
||||
questions: [
|
||||
{
|
||||
question: 'Pick one',
|
||||
options: [{ label: 'Yes' }, { label: 'No' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function requestQuestionArgs(
|
||||
ctx: ReturnType<typeof testAgent>,
|
||||
toolCallId: string,
|
||||
): unknown {
|
||||
return rpcArgs(ctx, 'requestQuestion', toolCallId);
|
||||
}
|
||||
|
||||
function toolResultArgs(
|
||||
ctx: ReturnType<typeof testAgent>,
|
||||
toolCallId: string,
|
||||
): { readonly output?: unknown; readonly isError?: boolean } {
|
||||
const args = rpcArgs(ctx, 'tool.result', toolCallId);
|
||||
expect(args).toBeDefined();
|
||||
return args as { readonly output?: unknown; readonly isError?: boolean };
|
||||
}
|
||||
|
||||
function rpcArgs(
|
||||
ctx: ReturnType<typeof testAgent>,
|
||||
event: string,
|
||||
toolCallId: string,
|
||||
): unknown {
|
||||
return ctx.allEvents.find((entry) => {
|
||||
if (entry.type !== '[rpc]' || entry.event !== event) return false;
|
||||
const args = entry.args as { readonly toolCallId?: string };
|
||||
return args.toolCallId === toolCallId;
|
||||
})?.args;
|
||||
}
|
||||
|
||||
function unsupportedQuestionService(): IQuestionService {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
request: async (): Promise<QuestionResult> => {
|
||||
throw new KimiError(ErrorCodes.NOT_IMPLEMENTED, 'Client does not support questions');
|
||||
},
|
||||
resolve: () => {},
|
||||
dismiss: () => {},
|
||||
listPending: () => [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue