fix: agent-code-v2 re-exports

This commit is contained in:
_Kerman 2026-06-29 17:56:37 +08:00
parent 570e1524bd
commit fd7909989a
7 changed files with 44 additions and 270 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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 {

View file

@ -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,

View file

@ -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[]>;
/**

View file

@ -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[];

View file

@ -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: () => [],
};
}