mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2
This commit is contained in:
commit
4977ccb445
30 changed files with 3347 additions and 243 deletions
|
|
@ -1,69 +1,78 @@
|
|||
You are about to run out of context. Write a first-person handoff note to
|
||||
yourself so you can seamlessly continue this task after the earlier
|
||||
conversation is cleared.
|
||||
|
||||
--- This message is a direct task, not part of the above conversation ---
|
||||
|
||||
You are now given a task to compact this conversation context according to specific priorities and output requirements.
|
||||
Write the note as your own continuing train of thought — first person, present
|
||||
tense, the way you would reason through the next move. Do not write a
|
||||
third-party report about someone else's work, and do not impose rigid section
|
||||
headings; let the shape follow the task. Write the note in the same language the
|
||||
conversation has been using — do not switch to English just because these
|
||||
instructions happen to be in English.
|
||||
|
||||
Output text only. DO NOT CALL ANY TOOLS. Calling tools will be rejected and fails the task. You already have all the information you need in the conversation history. You have only one chance.
|
||||
Make the note self-sufficient: the next turn will see only your most recent user
|
||||
messages and this note — every assistant message, tool call, and tool result
|
||||
above will be gone. In your own words, preserve what you genuinely need to
|
||||
continue:
|
||||
|
||||
The goal of compaction is to keep essential code patterns, technical details, and architectural decisions for continuing development without losing context after the above messages are cleared work.
|
||||
- What the latest request is actually asking for: your reading of its intent and
|
||||
any ambiguity you have already resolved — not a re-transcription, since what
|
||||
fits is kept verbatim in your most recent messages. But those kept messages are
|
||||
size-capped, so a long request is truncated there: if the latest request is
|
||||
large (a big paste or file), preserve the parts at risk of being dropped —
|
||||
above all the actual ask. If several requests are in play, say which one governs
|
||||
the next move, and re-quote any still-relevant earlier request that may have
|
||||
scrolled out of the kept messages.
|
||||
- The instructions and constraints currently in force (user preferences,
|
||||
project rules, environment and tooling limits) — condensed to what still
|
||||
matters, keeping decisions you have already settled (what you chose and why)
|
||||
separate from questions still open, so you neither silently reopen a closed
|
||||
choice nor treat an undecided point as decided.
|
||||
- What has actually been done, at high fidelity: keep the exact commands that
|
||||
were run, the exact file paths touched, and whether each succeeded or failed —
|
||||
and the results themselves, not just the commands: the concrete values
|
||||
returned, the key lines or error text, the schema or signature a lookup
|
||||
revealed, since re-running to recover them may be slow or impossible. Keep only
|
||||
the final working version of any code; drop intermediate attempts and
|
||||
already-resolved errors.
|
||||
- What you still don't know: context the next step depends on that this
|
||||
conversation never established — files or paths referenced but not yet read,
|
||||
schemas or APIs assumed but unseen, questions the user has not answered. Name
|
||||
these gaps so the next turn goes and checks them instead of assuming.
|
||||
- The forward plan — and this is the moment to invest in it. Right now you
|
||||
hold more context on this task than you ever will again; the next turn
|
||||
resumes with less, so the plan you commit here is the one it will follow.
|
||||
Give the exact next command or tool call, but don't stop at the next step:
|
||||
set out the remaining sequence to finish, the decisions you have already
|
||||
made for those upcoming steps (so the next turn doesn't reopen them), the
|
||||
obstacles or edge cases you can already foresee and how you mean to handle
|
||||
them, and any work you can commit to now — the exact patch, query, or shape
|
||||
of the final answer you already know you will produce. Anything you settle
|
||||
here is one less thing the next turn must rediscover. Include any required
|
||||
format for the final answer.
|
||||
|
||||
Your TODO list is re-attached automatically below this note from its live
|
||||
source, so do not transcribe it — copying it wastes space and can contradict the
|
||||
live version. What that list cannot hold is the reasoning between tasks — why one
|
||||
was reordered or dropped, or a decision on one that constrains another — so
|
||||
record that instead.
|
||||
|
||||
Be honest about uncertainty. If an earlier step claimed something was done but
|
||||
was never verified (tests "passing", a fix "working", a file "created"), say so
|
||||
plainly and treat it as unverified rather than fact — re-check before relying
|
||||
on it.
|
||||
|
||||
Be concise, and keep the note proportional to the task: a long multi-step task
|
||||
warrants detail, but a trivial or nearly finished exchange needs only a sentence
|
||||
or two — do not pad it out. Include the critical data, identifiers, and
|
||||
references needed to continue, and omit anything that does not change the next
|
||||
move.
|
||||
|
||||
Respond with text only. Do not call any tools — you already have everything you
|
||||
need in the conversation history.
|
||||
|
||||
{% if customInstruction %}
|
||||
Optional user instruction:
|
||||
{{ customInstruction }}
|
||||
|
||||
<!-- Compression Priorities (in order) -->
|
||||
|
||||
1. **Current Task State**: What is being worked on RIGHT NOW
|
||||
2. **Errors & Solutions**: All encountered errors and their resolutions
|
||||
3. **Code Evolution**: Final working versions only (remove intermediate attempts)
|
||||
4. **System Context**: Project structure, dependencies, environment setup
|
||||
5. **Design Decisions**: Architectural choices and their rationale
|
||||
6. **TODO Items**: Unfinished tasks and known issues
|
||||
|
||||
<!-- Required Output Structure -->
|
||||
|
||||
## Current Focus
|
||||
|
||||
[What we're working on now]
|
||||
|
||||
## Environment
|
||||
|
||||
- [Key setup/config points]
|
||||
- ...
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
- [Task]: [Brief outcome]
|
||||
- ...
|
||||
|
||||
## Active Issues
|
||||
|
||||
- [Issue]: [Status/Next steps]
|
||||
- ...
|
||||
|
||||
## Code State
|
||||
|
||||
### [Critical file name]
|
||||
|
||||
[Brief description of the file's purpose and current state]
|
||||
|
||||
```
|
||||
[The latest version of critical code snippets in this file, <20 lines]
|
||||
```
|
||||
|
||||
### [Critical file name]
|
||||
|
||||
- [Useful classes/methods/functions]: [Brief description/usage]
|
||||
- ...
|
||||
|
||||
<!-- Omit non-critical code, intermediate attempts, and resolved errors -->
|
||||
|
||||
## Important Context
|
||||
|
||||
- [Any crucial information not covered above]
|
||||
- ...
|
||||
|
||||
## All User Messages
|
||||
|
||||
- [Detailed non tool use user message]
|
||||
- ...
|
||||
|
||||
<!-- Must output a summary matching the above template in the **final answer**, not in thinking. -->
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import { IAgentProfileService } from '#/agent/profile/profile';
|
|||
import { IAgentTurnService } from '#/agent/turn/turn';
|
||||
import { ISessionTodoService } from '#/session/todo/sessionTodo';
|
||||
import { renderTodoList, type TodoItem } from '#/session/todo/todoItem';
|
||||
import { APIContextOverflowError, APIEmptyResponseError } from '#/app/llmProtocol/errors';
|
||||
import {
|
||||
APIContextOverflowError,
|
||||
APIEmptyResponseError,
|
||||
APIStatusError,
|
||||
} from '#/app/llmProtocol/errors';
|
||||
import { createUserMessage, type Message } from '#/app/llmProtocol/message';
|
||||
import { type TokenUsage } from '#/app/llmProtocol/usage';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
|
|
@ -62,6 +66,7 @@ declare module '#/agent/wireRecord/wireRecord' {
|
|||
|
||||
export const MAX_COMPACTION_RETRY_ATTEMPTS = 5;
|
||||
const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024;
|
||||
const OVERFLOW_STATUS_RECOVERY_RATIO = 0.5;
|
||||
|
||||
type CompactionTelemetryProperties = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
|
|
@ -110,7 +115,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IAgentTurnService turnService: IAgentTurnService,
|
||||
@IAgentTurnService private readonly turn: IAgentTurnService,
|
||||
@IAgentLoopService loopService: IAgentLoopService,
|
||||
) {
|
||||
super();
|
||||
|
|
@ -153,6 +158,12 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
if (this.compactionCountInTurn > this.strategy.maxCompactionPerTurn) return false;
|
||||
|
||||
const history = this.context.get();
|
||||
if (data.source === 'manual' && this.turn.getActiveTurn() !== undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.COMPACTION_UNABLE,
|
||||
'Cannot compact while a turn is active. Wait for it to finish, then retry.',
|
||||
);
|
||||
}
|
||||
const tokenCount = estimateTokensForMessages(history);
|
||||
const compactedCount = this.strategy.computeCompactCount(history, data.source);
|
||||
if (compactedCount === 0) {
|
||||
|
|
@ -222,7 +233,9 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
context: LoopErrorContext,
|
||||
next: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (!isContextOverflowError(context.error)) {
|
||||
const isOverflow =
|
||||
isContextOverflowError(context.error) || this.shouldRecoverFromPlain413(context.error);
|
||||
if (!isOverflow) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
|
@ -324,7 +337,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
let compactedCount = initialCompactedCount;
|
||||
|
||||
for (let round = 1; ; round++) {
|
||||
const result = await this.compactionRound(active, round, data);
|
||||
const result = await this.compactionRound(active, round, data, compactedCount);
|
||||
if (this._compacting !== active) throw compactionCancelledReason(active);
|
||||
|
||||
finalResult.summary = result.summary;
|
||||
|
|
@ -375,6 +388,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
active: ActiveCompaction,
|
||||
round: number,
|
||||
data: Readonly<CompactionBeginData>,
|
||||
initialCompactedCount: number,
|
||||
): Promise<CompactionResult> {
|
||||
const startedAt = Date.now();
|
||||
const originalHistory = [...this.context.get()];
|
||||
|
|
@ -382,7 +396,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
let retryCount = 0;
|
||||
|
||||
try {
|
||||
let compactedCount = originalHistory.length;
|
||||
let compactedCount = Math.min(initialCompactedCount, originalHistory.length);
|
||||
const signal = active.abortController.signal;
|
||||
signal.throwIfAborted();
|
||||
|
||||
|
|
@ -419,6 +433,12 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
messages,
|
||||
maxOutputSize: compactionMaxOutputSize,
|
||||
source: { type: 'operation', requestKind: 'full_compaction' },
|
||||
retry: {
|
||||
maxAttempts: MAX_COMPACTION_RETRY_ATTEMPTS,
|
||||
onRetry: () => {
|
||||
retryCount += 1;
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
signal,
|
||||
|
|
@ -427,7 +447,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
break;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof APIContextOverflowError ||
|
||||
this.shouldRecoverFromCompactionOverflow(error, messages) ||
|
||||
error instanceof CompactionTruncatedError ||
|
||||
error instanceof APIEmptyResponseError
|
||||
) {
|
||||
|
|
@ -515,6 +535,26 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
private tokenCountWithPending(): number {
|
||||
return this.contextSize.get().size;
|
||||
}
|
||||
|
||||
private shouldRecoverFromPlain413(
|
||||
error: unknown,
|
||||
estimatedRequestTokens = this.tokenCountWithPending(),
|
||||
): boolean {
|
||||
if (!(error instanceof APIStatusError) || error.statusCode !== 413) return false;
|
||||
const maxContextTokens = this.profile.getModelCapabilities().max_context_tokens;
|
||||
return (
|
||||
maxContextTokens > 0 &&
|
||||
estimatedRequestTokens >= maxContextTokens * OVERFLOW_STATUS_RECOVERY_RATIO
|
||||
);
|
||||
}
|
||||
|
||||
private shouldRecoverFromCompactionOverflow(
|
||||
error: unknown,
|
||||
messages: readonly Message[],
|
||||
): boolean {
|
||||
if (error instanceof APIContextOverflowError) return true;
|
||||
return this.shouldRecoverFromPlain413(error, estimateTokensForMessages(messages));
|
||||
}
|
||||
}
|
||||
|
||||
function collectSummary(finish: LLMRequestFinish): CompactionAttemptResult {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
|||
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { IAgentWireRecordService, type WireRecord } from '#/agent/wireRecord/wireRecord';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import {
|
||||
|
|
@ -150,6 +151,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
|
|||
@IFileSystemStorageService byteStore: IFileSystemStorageService,
|
||||
@ISessionContext session: ISessionContext,
|
||||
@ITaskService private readonly taskService: ITaskService,
|
||||
@IAgentWireRecordService wireRecord: IAgentWireRecordService,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
) {
|
||||
|
|
@ -170,20 +172,28 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
|
|||
}
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
wireRecord.hooks.onRestoredRecord.register(
|
||||
'task-delivered-notifications',
|
||||
async (ctx, next) => {
|
||||
this.markDeliveredNotificationsFromRecord(ctx.record);
|
||||
await next();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private restoreAfterReplay(): void {
|
||||
private async restoreAfterReplay(): Promise<void> {
|
||||
// `wire.replay` has rebuilt `TaskModel` from the persisted task.started /
|
||||
// task.terminated records. Seed the restored "ghosts" from it first (the
|
||||
// wire-replay contribution), THEN load from disk and reconcile — all inside
|
||||
// this single onRestored handler so the ordering (wire ghosts -> disk
|
||||
// ghosts -> reconcile) holds. loadFromDisk / reconcile are async (disk
|
||||
// I/O); they chain here and are never split across two hooks (splitting
|
||||
// would lose or duplicate tasks). They run fire-and-forget relative to the
|
||||
// synchronous `wire.replay`: there is no auto-started turn on resume, so
|
||||
// the local-disk reconciliation completes before the first RPC turn.
|
||||
// I/O); awaiting them keeps restore observable only after task state has
|
||||
// reached the same shape as v1's resumed background-task manager.
|
||||
this.restoreGhostsFromWire();
|
||||
void this.loadFromDisk({ replace: false }).then(() => this.reconcile());
|
||||
await this.loadFromDisk({ replace: false });
|
||||
await this.reconcile();
|
||||
}
|
||||
|
||||
private restoreGhostsFromWire(): void {
|
||||
|
|
@ -193,6 +203,12 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
|
|||
}
|
||||
}
|
||||
|
||||
private markDeliveredNotificationsFromRecord(record: WireRecord): void {
|
||||
for (const origin of taskOriginsFromRecord(record)) {
|
||||
this.markDeliveredNotification(origin);
|
||||
}
|
||||
}
|
||||
|
||||
registerTask(task: AgentTask, options: RegisterAgentTaskOptions = {}): string {
|
||||
const detached = options.detached ?? true;
|
||||
const timeoutMs = options.timeoutMs ?? task.timeoutMs;
|
||||
|
|
@ -1028,14 +1044,40 @@ function newerRestoredTask(
|
|||
|
||||
function isTaskOrigin(origin: unknown): origin is TaskOrigin {
|
||||
if (typeof origin !== 'object' || origin === null) return false;
|
||||
const kind = (origin as { kind?: unknown }).kind;
|
||||
return kind === 'task';
|
||||
const value = origin as Record<string, unknown>;
|
||||
return (
|
||||
value['kind'] === 'task' &&
|
||||
typeof value['taskId'] === 'string' &&
|
||||
typeof value['status'] === 'string' &&
|
||||
typeof value['notificationId'] === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function notificationKey(origin: TaskOrigin): string {
|
||||
return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`;
|
||||
}
|
||||
|
||||
function taskOriginsFromRecord(record: WireRecord): readonly TaskOrigin[] {
|
||||
const raw = record as {
|
||||
readonly type: string;
|
||||
readonly message?: unknown;
|
||||
readonly messages?: unknown;
|
||||
};
|
||||
if (raw.type === 'context.append_message') {
|
||||
return taskOriginFromMessage(raw.message);
|
||||
}
|
||||
if (raw.type === 'context.splice' && Array.isArray(raw.messages)) {
|
||||
return raw.messages.flatMap(taskOriginFromMessage);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function taskOriginFromMessage(message: unknown): readonly TaskOrigin[] {
|
||||
if (typeof message !== 'object' || message === null) return [];
|
||||
const origin = (message as { readonly origin?: unknown }).origin;
|
||||
return isTaskOrigin(origin) ? [origin] : [];
|
||||
}
|
||||
|
||||
function buildAgentTaskNotificationBody(info: AgentTaskInfo): string {
|
||||
const baseLine =
|
||||
info.status === 'timed_out'
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export interface UserToolRegistration {
|
|||
export interface IAgentUserToolService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
list(): readonly UserToolRegistration[];
|
||||
inheritUserTools(parent: IAgentUserToolService): void;
|
||||
register(input: UserToolRegistration): void;
|
||||
unregister(name: string): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,16 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe
|
|||
this._register(this.wire.onRestored(() => this.restoreRegisteredTools()));
|
||||
}
|
||||
|
||||
list(): readonly UserToolRegistration[] {
|
||||
return [...this.wire.getModel(UserToolModel).values()];
|
||||
}
|
||||
|
||||
inheritUserTools(parent: IAgentUserToolService): void {
|
||||
for (const registration of parent.list()) {
|
||||
this.register(registration);
|
||||
}
|
||||
}
|
||||
|
||||
register(input: UserToolRegistration): void {
|
||||
this.wire.dispatch(registerUserTool(input));
|
||||
this.applyRegister(input);
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ The current working directory is `{{ KIMI_WORK_DIR }}`. This should be considere
|
|||
|
||||
Use this as your basic understanding of the project structure. The tree only shows the first two levels for normal directories; entries marked "... and N more" indicate additional contents. Hidden directories are shown as entries only; their contents are intentionally omitted to reduce noise.
|
||||
|
||||
To inspect hidden paths the tree leaves out, prefer the dedicated tools over `ls -A`. `Glob` matches dotfiles by default — use `.*` for top-level dotfiles, or anchor on a directory such as `.github/**` or `.agents/**` to walk it; avoid bare `.git/**` or `node_modules/**`, which `Glob` traverses in full and will hit its result cap. Use `Read` for a known hidden file and `Grep` to search hidden file contents. `Grep` searches hidden files by default but skips VCS metadata (`.git` and the like) and filters secrets out of its results; `Read`, `Write`, and `Edit` refuse a fixed set of well-known secret files — `.env`, SSH private keys, and a few credential files — by design; that guard does not recognize every secret format, so judge other credential-bearing files yourself. `Bash` enforces none of these path or secret guards — it runs whatever command you give it — so the same discipline is on you there: do not use shell commands (`cat`, `cp`, `curl`, and the like) to read, copy, or transmit secret files, and stay inside the working directory unless the user has explicitly directed otherwise.
|
||||
To inspect hidden paths the tree leaves out, prefer the dedicated tools over `ls -A`. `Glob` matches dotfiles by default — use `.*` for top-level dotfiles, or anchor on a directory such as `.github/**` or `.agents/**` to walk it; avoid bare `node_modules/**`-style dependency walks, which can flood the result cap; `.git/**` returns nothing at all — `Glob`, like `Grep`, always skips VCS metadata. Use `Read` for a known hidden file and `Grep` to search hidden file contents. `Grep` searches hidden files by default but skips VCS metadata (`.git` and the like) and filters secrets out of its results; `Read`, `Write`, and `Edit` refuse a fixed set of well-known secret files — `.env`, SSH private keys, and a few credential files — by design; that guard does not recognize every secret format, so judge other credential-bearing files yourself. `Bash` enforces none of these path or secret guards — it runs whatever command you give it — so the same discipline is on you there: do not use shell commands (`cat`, `cp`, `curl`, and the like) to read, copy, or transmit secret files, and stay inside the working directory unless the user has explicitly directed otherwise.
|
||||
|
||||
The directory listing of current working directory is:
|
||||
|
||||
|
|
|
|||
|
|
@ -285,7 +285,9 @@ export class ModelResolverService extends Disposable implements IModelResolver {
|
|||
async getAuth(options): Promise<ProviderRequestAuth | undefined> {
|
||||
const tokenProvider = oauthService.resolveTokenProvider(providerKey, oauthRef);
|
||||
if (tokenProvider === undefined) throw loginRequired();
|
||||
const apiKey = await tokenProvider.getAccessToken({ force: options?.force ?? false });
|
||||
const apiKey = await tokenProvider.getAccessToken(
|
||||
options?.force === true ? { force: true } : undefined,
|
||||
);
|
||||
if (apiKey.trim().length === 0) throw loginRequired();
|
||||
return { apiKey };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,19 +11,19 @@ Execute a `{{ SHELL_NAME }}` command. Use this for shell semantics — pipes, en
|
|||
The dedicated tools render in the per-tool permission UI and keep raw stdout out of the conversation; that is why they are worth reaching for whenever one fits.
|
||||
|
||||
**Output:**
|
||||
The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command failed, the output will end with a `Command failed with exit code: N` line stating the non-zero exit code.
|
||||
The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command exits non-zero, the output ends with a `Command failed with exit code: N` line; a command killed by its timeout or interrupted by the user ends with its own message instead.
|
||||
|
||||
If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a {{ DEFAULT_BACKGROUND_TIMEOUT_S }}s timeout and `timeout` is capped at {{ MAX_BACKGROUND_TIMEOUT_S }}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. Use `TaskOutput` for a non-blocking status/output snapshot, and only set `block=true` when you explicitly want to wait for completion. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands.
|
||||
If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a {{ DEFAULT_BACKGROUND_TIMEOUT_S }}s timeout and `timeout` is capped at {{ MAX_BACKGROUND_TIMEOUT_S }}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use `TaskOutput` for a non-blocking status/output snapshot, and only set `block=true` when you explicitly want to wait for completion. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands.
|
||||
|
||||
**Guidelines for safety and security:**
|
||||
- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls.
|
||||
- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the `cwd` argument (or use absolute paths) rather than relying on a `cd` from an earlier call.
|
||||
- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running foreground commands, set the `timeout` argument in seconds. Foreground commands default to {{ DEFAULT_TIMEOUT_S }}s and allow up to {{ MAX_TIMEOUT_S }}s.
|
||||
- Avoid using `..` to access files or directories outside of the working directory.
|
||||
- Avoid modifying files outside of the working directory unless explicitly instructed to do so.
|
||||
- Never run commands that require superuser privileges unless explicitly instructed to do so.
|
||||
|
||||
**Guidelines for efficiency:**
|
||||
- For multiple related commands, use `&&` to chain them in a single call, e.g. `cd /path && ls -la`
|
||||
- Use `&&` to chain commands that genuinely depend on each other, e.g. `npm install && npm test`. Independent read-only commands (separate `git show`, `ls`, or status checks) should be issued as separate parallel Bash calls in one response, not chained into a single call — chaining serializes their execution and mixes their output. Do not stitch outputs together with `echo` separators.
|
||||
- Use `;` to run commands sequentially regardless of success/failure
|
||||
- Use `||` for conditional execution (run second command only if first fails)
|
||||
- Use pipe operations (`|`) and redirections (`>`, `>>`) to chain input and output between commands
|
||||
|
|
@ -38,6 +38,6 @@ The following common command categories are usually available. Availability stil
|
|||
- Text and data processing: `wc`, `sort`, `uniq`, `cut`, `tr`, `diff`, `xargs`
|
||||
- Archives and compression: `tar`, `gzip`, `gunzip`, `zip`, `unzip`
|
||||
- Networking and transfer: `curl`, `wget`, `ping`, `ssh`, `scp`
|
||||
- Version control: `git`
|
||||
- Version control: `git`; for GitHub-hosted work (PRs, issues, CI runs, API queries) prefer the `gh` CLI when installed — it carries the user's GitHub auth and can return structured JSON
|
||||
- Process and system: `ps`, `kill`, `top`, `env`, `date`, `uname`, `whoami`
|
||||
- Language and package toolchains: `node`, `npm`, `pnpm`, `yarn`, `python`, `pip` (use whichever the project actually relies on)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { z } from 'zod';
|
|||
import { ToolResultBuilder } from '#/agent/tool/result-builder';
|
||||
import { ToolAccesses } from '#/agent/tool/tool-access';
|
||||
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool/toolContract';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { registerTool } from '#/agent/toolRegistry/toolContribution';
|
||||
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
|
||||
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
|
||||
|
|
@ -195,6 +196,7 @@ export class GrepTool implements BuiltinTool<GrepInput> {
|
|||
@IHostFileSystem private readonly fs: IHostFileSystem,
|
||||
@IHostEnvironment private readonly env: IHostEnvironment,
|
||||
@ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
) {}
|
||||
|
||||
private get workspace(): WorkspaceConfig {
|
||||
|
|
@ -243,10 +245,17 @@ export class GrepTool implements BuiltinTool<GrepInput> {
|
|||
allowCachedFallback: true,
|
||||
});
|
||||
rgPath = resolution.path;
|
||||
if (resolution.source !== 'system-path') {
|
||||
this.telemetry.track('grep_tool_rg_fallback', {
|
||||
source: resolution.source,
|
||||
outcome: 'resolved',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return { isError: true, output: 'Grep aborted' };
|
||||
}
|
||||
this.telemetry.track('grep_tool_rg_fallback', { outcome: 'failed' });
|
||||
return { isError: true, output: rgUnavailableMessage(error) };
|
||||
}
|
||||
|
||||
|
|
@ -433,7 +442,8 @@ export class GrepTool implements BuiltinTool<GrepInput> {
|
|||
let mtime = 0;
|
||||
if (path !== undefined) {
|
||||
try {
|
||||
mtime = (await this.fs.stat(path)).mtimeMs ?? 0;
|
||||
const mtimeMs = (await this.fs.stat(path)).mtimeMs ?? 0;
|
||||
mtime = Math.trunc(mtimeMs / 1000);
|
||||
} catch {
|
||||
// Keep stat failures visible; use mtime=0 so they sort after known files.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
Read a text file from the local filesystem.
|
||||
|
||||
If the user provides a concrete file path to a text file, call Read directly. Do not `Glob`, `ls`, or otherwise pre-check known text file paths; missing or invalid file paths return errors you can handle. Do not use Read for directories; use `ls` via Bash for a known directory, or Glob when you need files/directories matching a pattern. Use `Grep` only when the task is to search for unknown content or locations.
|
||||
If the user provides a concrete file path to a text file, call Read directly. Do not `Glob`, `ls`, or otherwise pre-check known text file paths; missing or invalid file paths return errors you can handle. Do not use Read for directories; use `ls` via Bash for a known directory, or Glob when you need files matching a name pattern (Glob lists files only, never directories). Use `Grep` only when the task is to search for unknown content or locations.
|
||||
|
||||
When you need several files, prefer to read them in parallel: emit multiple `Read` calls in a single response instead of reading one file per turn.
|
||||
|
||||
- Relative paths resolve against the working directory; a path outside the working directory must be absolute.
|
||||
- Returns up to {{ MAX_LINES }} lines or {{ MAX_BYTES_KB }} KB per call, whichever comes first; lines longer than {{ MAX_LINE_LENGTH }} chars are truncated mid-line.
|
||||
- Page larger files with `line_offset` (1-based start line) and `n_lines`. Omit `n_lines` to read up to the {{ MAX_LINES }}-line cap.
|
||||
- Sensitive files (`.env` files, credential stores, SSH keys, and similar secrets) are refused to protect secrets; do not attempt to read them.
|
||||
- Sensitive files (`.env` files, credential stores, SSH private keys, and similar secrets) are refused to protect secrets; do not attempt to read them. Templates and public keys are exempt: `.env.example` / `.env.sample` / `.env.template` and public SSH keys such as `id_rsa.pub` read normally.
|
||||
- Only UTF-8 text files can be read. Non-UTF-8 encodings, binary files, and files containing NUL bytes are refused; use `ReadMediaFile` for images or video, and Bash or an MCP tool for other binary formats.
|
||||
- Negative line_offset reads from the end of the file (for example, -100 reads the last 100 lines); the absolute value cannot exceed {{ MAX_LINES }}.
|
||||
- Output format: `<line-number>\t<content>` per line.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
/**
|
||||
* `fileTools` domain — shared ripgrep (`rg`) binary locator.
|
||||
*
|
||||
* Resolves the `rg` command used by Glob and Grep through a caller-supplied
|
||||
* process probe, preferring the execution-environment PATH, then the vendor
|
||||
* hook, then the app cache, and finally bootstrapping a pinned ripgrep
|
||||
* archive into `<KIMI_CODE_HOME|~/.kimi-code>/bin` when the caller permits it.
|
||||
* Keeps callers that own a no-download fallback path opt-in-compatible.
|
||||
* Resolves the `rg` command used by Glob and Grep, preferring a file found on
|
||||
* PATH, then the vendor hook, then the app cache, and finally bootstrapping a
|
||||
* pinned ripgrep archive into `<KIMI_CODE_HOME|~/.kimi-code>/bin` when the
|
||||
* caller permits it. File lookup intentionally avoids spawning `rg --version`
|
||||
* so tool resolution has the same observable shape as v1.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createWriteStream, existsSync } from 'node:fs';
|
||||
import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises';
|
||||
import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
|
@ -105,22 +105,20 @@ async function resolveRgPath(
|
|||
}
|
||||
|
||||
export async function findExistingRg(
|
||||
probe: RgProbe,
|
||||
_probe: RgProbe,
|
||||
shareDir: string = getShareDir(),
|
||||
allowCachedFallback = true,
|
||||
): Promise<RgResolution | undefined> {
|
||||
const system = await probe.exec(['rg', '--version']).catch(() => ({ exitCode: -1 }));
|
||||
if (system.exitCode === 0) {
|
||||
return { path: 'rg', source: 'system-path' };
|
||||
}
|
||||
const system = await findRgOnPath();
|
||||
if (system !== undefined) return { path: system, source: 'system-path' };
|
||||
|
||||
if (allowCachedFallback) {
|
||||
const vendorPath = getVendorRgPath(rgBinaryName());
|
||||
if (vendorPath !== undefined && (await isUsableRg(probe, vendorPath))) {
|
||||
if (vendorPath !== undefined && (await isExecutableFile(vendorPath))) {
|
||||
return { path: vendorPath, source: 'vendor' };
|
||||
}
|
||||
const cachePath = join(shareDir, 'bin', rgBinaryName());
|
||||
if (await isUsableRg(probe, cachePath)) {
|
||||
if (await isExecutableFile(cachePath)) {
|
||||
return { path: cachePath, source: 'share-bin-cached' };
|
||||
}
|
||||
}
|
||||
|
|
@ -148,9 +146,24 @@ function getVendorRgPath(_binName: string): string | undefined {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
async function isUsableRg(probe: RgProbe, path: string): Promise<boolean> {
|
||||
const run = await probe.exec([path, '--version']).catch(() => ({ exitCode: -1 }));
|
||||
return run.exitCode === 0;
|
||||
async function findRgOnPath(): Promise<string | undefined> {
|
||||
const pathEnv = process.env['PATH'] ?? '';
|
||||
const sep = process.platform === 'win32' ? ';' : ':';
|
||||
const binName = rgBinaryName();
|
||||
for (const dir of pathEnv.split(sep)) {
|
||||
if (dir === '') continue;
|
||||
const candidate = join(dir, binName);
|
||||
if (await isExecutableFile(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function isExecutableFile(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(path)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectTarget(): string | undefined {
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
Default to a foreground subagent unless the task can run independently and there is a clear benefit to not waiting.
|
||||
|
||||
When `run_in_background=true`, the subagent runs detached from this turn. The completion arrives in a later turn as a synthetic user-role message containing its result — you do not need to poll, sleep, or check on its progress. Continue with other work or respond to the user. Never fabricate or predict what the result will say.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@ Usage notes:
|
|||
- A subagent's result is only visible to you, not to the user. When the user needs to see what a subagent produced, summarize the relevant parts yourself in your own reply.
|
||||
- Subagents use a fixed 30-minute timeout. If one times out, resume the same agent instead of starting over.
|
||||
|
||||
When NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to outweigh it.
|
||||
When NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to keep meaningful work out of your own context.
|
||||
|
||||
Once a subagent is running, leave that scope to it: do not redo its searches or reads in parallel, and do not abandon it midway and finish the job manually. Both undo the context savings the delegation was meant to buy.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAgentUserToolService } from '#/agent/userTool/userTool';
|
||||
import { isAbortError } from '#/agent/loop/errors';
|
||||
import { ToolAccesses } from '#/agent/tool/tool-access';
|
||||
import type {
|
||||
|
|
@ -89,7 +90,9 @@ export const AgentToolInputSchema = z.preprocess(
|
|||
resume: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional agent ID to resume instead of creating a new instance'),
|
||||
.describe(
|
||||
'Optional agent ID to resume instead of creating a new instance. When set, do not also pass subagent_type; the resumed agent keeps its original type.',
|
||||
),
|
||||
run_in_background: z
|
||||
.boolean()
|
||||
.optional()
|
||||
|
|
@ -256,6 +259,9 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
|
|||
permissionMode: this.permissionMode.mode,
|
||||
labels: subagentLabels(this.callerAgentId),
|
||||
});
|
||||
created.accessor
|
||||
.get(IAgentUserToolService)
|
||||
.inheritUserTools(requester.accessor.get(IAgentUserToolService));
|
||||
agentId = created.id;
|
||||
profileName = profile.name;
|
||||
promptText = await applyProfilePromptPrefix(profile, args.prompt, {
|
||||
|
|
@ -326,6 +332,14 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
|
|||
handle = await this.launch(args, toolCallId, controller);
|
||||
} catch (error) {
|
||||
signal.removeEventListener('abort', abortBeforeRegister);
|
||||
this.log.warn('subagent launch failed', {
|
||||
toolCallId,
|
||||
runInBackground,
|
||||
operation: isResume ? 'resume' : 'spawn',
|
||||
subagentType: requestedProfileName ?? DEFAULT_PROFILE_NAME,
|
||||
resumeAgentId: isResume ? resumeAgentId : undefined,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
|
@ -353,8 +367,12 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
|
|||
subagentType: handle.profileName,
|
||||
error,
|
||||
});
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
output: error instanceof Error ? error.message : String(error),
|
||||
output:
|
||||
message === 'Too many detached tasks are already running.'
|
||||
? 'Too many background tasks are already running.'
|
||||
: message,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
|
@ -439,7 +457,7 @@ function formatBackgroundAgentResult(
|
|||
`description: ${description}`,
|
||||
'',
|
||||
allowBackground
|
||||
? `next_step: The completion arrives automatically in a later turn — no polling needed. To peek at progress without blocking, call TaskOutput(task_id="${taskId}", block=false).`
|
||||
? 'next_step: The completion arrives automatically in a later turn; do NOT wait, poll, or call TaskOutput on it. Continue with other work or respond to the user.'
|
||||
: 'next_step: The completion arrives automatically in a later turn.',
|
||||
`resume_hint: To continue or recover this same subagent later, call Agent(resume="${handle.agentId}", prompt="..."). The parameter is agent_id ("${handle.agentId}"), NOT task_id ("${taskId}") or source_id from a later <notification>. Recovery cases: a later <notification type="task.lost" | "task.failed" | "task.killed"> for this subagent — its conversation history is preserved across session restarts and resume will pick it up.`,
|
||||
].join('\n');
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { linkAbortSignal } from '#/_base/utils/abort';
|
|||
import type { IAgentScopeHandle } from '#/_base/di/scope';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
|
||||
import { IAgentUserToolService } from '#/agent/userTool/userTool';
|
||||
import type { SubagentSuspendedEvent } from '@moonshot-ai/protocol';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog';
|
||||
|
|
@ -151,6 +152,9 @@ export class SessionSwarmService implements ISessionSwarmService {
|
|||
permissionMode: caller.accessor.get(IAgentPermissionModeService).mode,
|
||||
labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }),
|
||||
});
|
||||
child.accessor
|
||||
.get(IAgentUserToolService)
|
||||
.inheritUserTools(caller.accessor.get(IAgentUserToolService));
|
||||
emitAgentRunSpawned(caller, child.id, {
|
||||
profileName: options.profileName,
|
||||
parentToolCallId: options.parentToolCallId,
|
||||
|
|
|
|||
|
|
@ -66,5 +66,5 @@ export interface IWireService {
|
|||
handler: (state: DeepReadonly<S>, prev: DeepReadonly<S>) => void,
|
||||
): IDisposable;
|
||||
onEmission(handler: (emission: WireEmission) => void): IDisposable;
|
||||
onRestored(handler: () => void): IDisposable;
|
||||
onRestored(handler: () => void | Promise<void>): IDisposable;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
* Scope-agnostic.
|
||||
*/
|
||||
|
||||
import { Disposable, type IDisposable } from '#/_base/di/lifecycle';
|
||||
import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle';
|
||||
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
|
||||
import { Emitter } from '#/_base/event';
|
||||
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
|
||||
|
|
@ -103,7 +103,7 @@ export class WireService extends Disposable implements IWireService {
|
|||
private readonly derivedModels = new Map<DerivedModelDef<any>, ModelInstance>();
|
||||
private readonly reducerIndex = new Map<string, ReducerEntry[]>();
|
||||
private readonly emissionEmitter = this._register(new Emitter<WireEmission>());
|
||||
private readonly restoredEmitter = this._register(new Emitter<void>());
|
||||
private readonly restoredHandlers = new Set<() => void | Promise<void>>();
|
||||
|
||||
private dispatching = false;
|
||||
private queue: Op[] = [];
|
||||
|
|
@ -147,8 +147,9 @@ export class WireService extends Disposable implements IWireService {
|
|||
return this.emissionEmitter.event(handler);
|
||||
}
|
||||
|
||||
onRestored(handler: () => void): IDisposable {
|
||||
return this.restoredEmitter.event(handler);
|
||||
onRestored(handler: () => void | Promise<void>): IDisposable {
|
||||
this.restoredHandlers.add(handler);
|
||||
return toDisposable(() => this.restoredHandlers.delete(handler));
|
||||
}
|
||||
|
||||
attach<S>(model: DerivedModelDef<S>): IDisposable {
|
||||
|
|
@ -214,7 +215,7 @@ export class WireService extends Disposable implements IWireService {
|
|||
}
|
||||
this.execute({ ops, silent: true });
|
||||
await this.rehydrateModels();
|
||||
this.restoredEmitter.fire(undefined);
|
||||
await this.fireRestored();
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
|
|
@ -285,6 +286,16 @@ export class WireService extends Disposable implements IWireService {
|
|||
return { type: op.type, payload };
|
||||
}
|
||||
|
||||
private async fireRestored(): Promise<void> {
|
||||
for (const handler of Array.from(this.restoredHandlers)) {
|
||||
try {
|
||||
await handler();
|
||||
} catch (error) {
|
||||
onUnexpectedError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private appendToWireLog(record: PersistedRecord, model: ModelDef<any>): void {
|
||||
if (this.log === undefined) return;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,45 @@
|
|||
import { Readable, type Writable } from 'node:stream';
|
||||
|
||||
import type { KaosProcess, StatResult } from '@moonshot-ai/kaos';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type GrepInput, GrepInputSchema, GrepTool } from '../../src/tools/builtin/file/grep';
|
||||
import { SENSITIVE_DOT_VARIANT_SUFFIXES } from '../../src/tools/policies/sensitive';
|
||||
import { ensureRgPath } from '../../src/tools/support/rg-locator';
|
||||
import type { WorkspaceConfig } from '../../src/tools/support/workspace';
|
||||
import { createFakeKaos, toolContentString } from './fixtures/fake-kaos';
|
||||
import { executeTool } from './fixtures/execute-tool';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry';
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { createServices } from '#/_base/di/test';
|
||||
import type {
|
||||
ExecutableTool,
|
||||
ExecutableToolContext,
|
||||
ExecutableToolResult,
|
||||
ToolExecution,
|
||||
} from '#/agent/tool/toolContract';
|
||||
import {
|
||||
AgentBuiltinToolsRegistrar,
|
||||
IAgentBuiltinToolsRegistrar,
|
||||
} from '#/agent/toolRegistry/builtinToolsRegistrar';
|
||||
import {
|
||||
_clearToolContributionsForTests,
|
||||
getToolContributions,
|
||||
registerTool,
|
||||
} from '#/agent/toolRegistry/toolContribution';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
|
||||
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry';
|
||||
import type { PathClass } from '#/_base/execEnv/environmentProbe';
|
||||
import { PathSecurityError } from '#/_base/tools/policies/path-access';
|
||||
import { SENSITIVE_DOT_VARIANT_SUFFIXES } from '#/_base/tools/policies/sensitive';
|
||||
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
|
||||
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
|
||||
import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem';
|
||||
import { IHostProcessService, type IHostProcess } from '#/os/interface/hostProcess';
|
||||
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
|
||||
import {
|
||||
type GrepInput,
|
||||
GrepInputSchema,
|
||||
GrepTool as ProductionGrepTool,
|
||||
} from '#/os/backends/node-local/tools/grep';
|
||||
import { ensureRgPath } from '#/os/backends/node-local/tools/rgLocator';
|
||||
import { stubWorkspaceContext } from './stub-workspace-context';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
|
||||
|
||||
vi.mock('../../src/tools/support/rg-locator', () => ({
|
||||
vi.mock('#/os/backends/node-local/tools/rgLocator', () => ({
|
||||
ensureRgPath: vi.fn(async () => ({ path: '/mock/rg', source: 'system-path' })),
|
||||
rgUnavailableMessage: (cause: unknown) =>
|
||||
`rg unavailable: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
|
|
@ -65,10 +93,85 @@ const SENSITIVE_RG_ARGS = [
|
|||
'!**/.gcp/credentials/**',
|
||||
] as const;
|
||||
|
||||
function processWithOutput(stdout: string, stderr = '', exitCode = 0): KaosProcess {
|
||||
interface FakeKaos {
|
||||
pathClass(): PathClass;
|
||||
gethome(): string;
|
||||
stat(path: string): Promise<HostFileStat>;
|
||||
exec(...args: string[]): Promise<IHostProcess>;
|
||||
}
|
||||
|
||||
function notImplemented(method: string): never {
|
||||
throw new Error(`FakeKaos.${method} not implemented - override in the test`);
|
||||
}
|
||||
|
||||
function createFakeKaos(overrides: Partial<FakeKaos> = {}): FakeKaos {
|
||||
return {
|
||||
pathClass: () => 'posix',
|
||||
gethome: () => '/home/test',
|
||||
stat: () => notImplemented('stat'),
|
||||
exec: () => notImplemented('exec'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestEnv(kaos: FakeKaos): IHostEnvironment {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
osKind: 'Linux',
|
||||
osArch: 'x86_64',
|
||||
osVersion: 'test',
|
||||
shellName: 'bash',
|
||||
shellPath: '/bin/bash',
|
||||
pathClass: kaos.pathClass(),
|
||||
homeDir: kaos.gethome(),
|
||||
ready: Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
function createTestFs(kaos: FakeKaos): IHostFileSystem {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
readText: () => notImplemented('readText'),
|
||||
writeText: () => notImplemented('writeText'),
|
||||
readBytes: () => notImplemented('readBytes'),
|
||||
writeBytes: () => notImplemented('writeBytes'),
|
||||
readLines: () => notImplemented('readLines'),
|
||||
createExclusive: () => notImplemented('createExclusive'),
|
||||
stat: (path) => kaos.stat(path),
|
||||
readdir: () => notImplemented('readdir'),
|
||||
mkdir: () => notImplemented('mkdir'),
|
||||
remove: () => notImplemented('remove'),
|
||||
};
|
||||
}
|
||||
|
||||
function createTestProcessService(kaos: FakeKaos): IHostProcessService {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
spawn: (command, args = []) => kaos.exec(command, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
class GrepTool extends ProductionGrepTool {
|
||||
constructor(
|
||||
kaos: FakeKaos,
|
||||
workspaceConfig: WorkspaceConfig,
|
||||
telemetry: ITelemetryService = noopTelemetryService,
|
||||
) {
|
||||
super(
|
||||
createTestProcessService(kaos),
|
||||
createTestFs(kaos),
|
||||
createTestEnv(kaos),
|
||||
stubWorkspaceContext(workspaceConfig.workspaceDir, workspaceConfig.additionalDirs),
|
||||
telemetry,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function processWithOutput(stdout: string, stderr = '', exitCode = 0): IHostProcess {
|
||||
const stdoutStream = Readable.from([stdout]);
|
||||
const stderrStream = Readable.from([stderr]);
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
|
||||
stdout: stdoutStream,
|
||||
stderr: stderrStream,
|
||||
|
|
@ -76,29 +179,23 @@ function processWithOutput(stdout: string, stderr = '', exitCode = 0): KaosProce
|
|||
exitCode,
|
||||
wait: vi.fn().mockResolvedValue(exitCode),
|
||||
kill: vi.fn(async () => {}),
|
||||
dispose: vi.fn(async () => {
|
||||
dispose: vi.fn(() => {
|
||||
stdoutStream.destroy();
|
||||
stderrStream.destroy();
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function statResult(mtime: number): StatResult {
|
||||
function statResult(mtime: number): HostFileStat {
|
||||
return {
|
||||
stMode: 0o100000,
|
||||
stIno: 1,
|
||||
stDev: 1,
|
||||
stNlink: 1,
|
||||
stUid: 0,
|
||||
stGid: 0,
|
||||
stSize: 0,
|
||||
stAtime: mtime,
|
||||
stMtime: mtime,
|
||||
stCtime: mtime,
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
size: 0,
|
||||
mtimeMs: mtime * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function processThatExitsOnKill(stdout: string, stderr = '', exitCode = 143): KaosProcess {
|
||||
function processThatExitsOnKill(stdout: string, stderr = '', exitCode = 143): IHostProcess {
|
||||
let currentExitCode: number | null = null;
|
||||
let resolveWait: (code: number) => void;
|
||||
const waitPromise = new Promise<number>((resolve) => {
|
||||
|
|
@ -108,6 +205,7 @@ function processThatExitsOnKill(stdout: string, stderr = '', exitCode = 143): Ka
|
|||
const stderrStream = Readable.from(stderr === '' ? [] : [stderr]);
|
||||
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
|
||||
stdout: stdoutStream,
|
||||
stderr: stderrStream,
|
||||
|
|
@ -120,7 +218,7 @@ function processThatExitsOnKill(stdout: string, stderr = '', exitCode = 143): Ka
|
|||
currentExitCode = exitCode;
|
||||
resolveWait(exitCode);
|
||||
}),
|
||||
dispose: vi.fn(async () => {
|
||||
dispose: vi.fn(() => {
|
||||
stdoutStream.destroy();
|
||||
stderrStream.destroy();
|
||||
}),
|
||||
|
|
@ -128,18 +226,93 @@ function processThatExitsOnKill(stdout: string, stderr = '', exitCode = 143): Ka
|
|||
}
|
||||
|
||||
function context(args: GrepInput, abortSignal = signal) {
|
||||
return { turnId: '0', toolCallId: 'call_grep', args, signal: abortSignal };
|
||||
return { turnId: 0, toolCallId: 'call_grep', args, signal: abortSignal };
|
||||
}
|
||||
|
||||
function nullRecord(filePath: string, payload = ''): string {
|
||||
return `${filePath}\0${payload}`;
|
||||
}
|
||||
|
||||
type TestExecutableToolContext<Input> = ExecutableToolContext & {
|
||||
readonly args: Input;
|
||||
};
|
||||
|
||||
async function executeTool<Input>(
|
||||
tool: ExecutableTool<Input>,
|
||||
testContext: TestExecutableToolContext<Input>,
|
||||
): Promise<ExecutableToolResult> {
|
||||
const { args, ...executionContext } = testContext;
|
||||
let execution: ToolExecution;
|
||||
try {
|
||||
const resolved = tool.resolveExecution(args);
|
||||
execution = isPromiseLike(resolved) ? await resolved : resolved;
|
||||
} catch (error) {
|
||||
const output =
|
||||
error instanceof PathSecurityError
|
||||
? error.message
|
||||
: `Tool "${tool.name}" failed to resolve execution: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
return { isError: true, output };
|
||||
}
|
||||
if (execution.isError === true) return execution;
|
||||
return execution.execute(executionContext);
|
||||
}
|
||||
|
||||
function isPromiseLike(
|
||||
value: ToolExecution | Promise<ToolExecution>,
|
||||
): value is Promise<ToolExecution> {
|
||||
return typeof (value as Promise<ToolExecution>).then === 'function';
|
||||
}
|
||||
|
||||
function toolContentString(result: ExecutableToolResult): string {
|
||||
const c = result.output;
|
||||
if (typeof c !== 'string') {
|
||||
throw new TypeError(`expected string content, got ${typeof c}`);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('GrepTool', () => {
|
||||
it('registers through the production tool contribution and DI path', () => {
|
||||
const savedContributions = [...getToolContributions()];
|
||||
const disposables = new DisposableStore();
|
||||
try {
|
||||
_clearToolContributionsForTests();
|
||||
registerTool(ProductionGrepTool);
|
||||
|
||||
const ix = createServices(disposables, {
|
||||
strict: true,
|
||||
additionalServices: (reg) => {
|
||||
const kaos = createFakeKaos();
|
||||
reg.defineInstance(IHostProcessService, createTestProcessService(kaos));
|
||||
reg.defineInstance(IHostFileSystem, createTestFs(kaos));
|
||||
reg.defineInstance(IHostEnvironment, createTestEnv(kaos));
|
||||
reg.defineInstance(ISessionWorkspaceContext, stubWorkspaceContext('/workspace'));
|
||||
reg.defineInstance(ITelemetryService, noopTelemetryService);
|
||||
reg.define(IAgentToolRegistryService, AgentToolRegistryService);
|
||||
reg.define(IAgentBuiltinToolsRegistrar, AgentBuiltinToolsRegistrar);
|
||||
},
|
||||
});
|
||||
|
||||
ix.get(IAgentBuiltinToolsRegistrar);
|
||||
const tool = ix.get(IAgentToolRegistryService).resolve('Grep');
|
||||
|
||||
expect(tool).toBeInstanceOf(ProductionGrepTool);
|
||||
expect(tool?.name).toBe('Grep');
|
||||
} finally {
|
||||
disposables.dispose();
|
||||
_clearToolContributionsForTests();
|
||||
for (const contribution of savedContributions) {
|
||||
registerTool(contribution.ctor, contribution.options);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes current metadata and schema', () => {
|
||||
const tool = new GrepTool(createFakeKaos(), workspace);
|
||||
|
||||
|
|
@ -387,6 +560,32 @@ describe('GrepTool', () => {
|
|||
expect(stat).toHaveBeenCalledWith('/workspace/src/new.ts');
|
||||
});
|
||||
|
||||
it('keeps v1 tie order for files modified within the same second', async () => {
|
||||
const stdout = ['/workspace/src/a.ts', '/workspace/src/b.ts', '/workspace/src/c.ts', ''].join(
|
||||
'\n',
|
||||
);
|
||||
const stat = vi.fn(async (path: string) => {
|
||||
if (path === '/workspace/src/a.ts') {
|
||||
return { ...statResult(1), mtimeMs: 1000 };
|
||||
}
|
||||
if (path === '/workspace/src/b.ts') {
|
||||
return { ...statResult(1), mtimeMs: 1500 };
|
||||
}
|
||||
if (path === '/workspace/src/c.ts') {
|
||||
return { ...statResult(2), mtimeMs: 2000 };
|
||||
}
|
||||
throw new Error(`unexpected stat: ${path}`);
|
||||
});
|
||||
const tool = new GrepTool(
|
||||
createFakeKaos({ exec: vi.fn().mockResolvedValue(processWithOutput(stdout)), stat }),
|
||||
{ workspaceDir: '/workspace', additionalDirs: [] },
|
||||
);
|
||||
|
||||
const result = await executeTool(tool, context({ pattern: 'hit', head_limit: 0 }));
|
||||
|
||||
expect(toolContentString(result)).toBe(['src/c.ts', 'src/a.ts', 'src/b.ts'].join('\n'));
|
||||
});
|
||||
|
||||
it('limits concurrent mtime stats while sorting files_with_matches', async () => {
|
||||
const filePaths = Array.from(
|
||||
{ length: 40 },
|
||||
|
|
@ -1437,7 +1636,7 @@ describe('GrepTool', () => {
|
|||
it('aborts while resolving the ripgrep path without spawning', async () => {
|
||||
const controller = new AbortController();
|
||||
const exec = vi.fn();
|
||||
vi.mocked(ensureRgPath).mockImplementationOnce(({ signal: locatorSignal } = {}) => {
|
||||
vi.mocked(ensureRgPath).mockImplementationOnce((_probe, { signal: locatorSignal } = {}) => {
|
||||
expect(locatorSignal).toBe(controller.signal);
|
||||
return new Promise((_resolve, reject) => {
|
||||
const rejectAbort = (): void => {
|
||||
|
|
|
|||
|
|
@ -57,12 +57,20 @@ function deferred<T>(): {
|
|||
|
||||
describe('findExistingRg', () => {
|
||||
let fakeShare: string;
|
||||
let savedPath: string | undefined;
|
||||
beforeEach(() => {
|
||||
fakeShare = join(tmpdir(), `kimi-rg-${String(Date.now())}-${String(Math.random()).slice(2)}`);
|
||||
mkdirSync(join(fakeShare, 'bin'), { recursive: true });
|
||||
savedPath = process.env['PATH'];
|
||||
process.env['PATH'] = '';
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(fakeShare, { recursive: true, force: true });
|
||||
if (savedPath === undefined) {
|
||||
delete process.env['PATH'];
|
||||
} else {
|
||||
process.env['PATH'] = savedPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns undefined when no rg anywhere', async () => {
|
||||
|
|
@ -72,18 +80,27 @@ describe('findExistingRg', () => {
|
|||
|
||||
it('resolves from share-dir when cached', async () => {
|
||||
const cached = join(fakeShare, 'bin', process.platform === 'win32' ? 'rg.exe' : 'rg');
|
||||
const probe = probeWith((args) => (args[0] === cached ? 0 : -1));
|
||||
writeFileSync(cached, 'fake rg');
|
||||
const probe = noRgProbe();
|
||||
const result = await findExistingRg(probe, fakeShare);
|
||||
|
||||
expect(result).toEqual({ path: cached, source: 'share-bin-cached' });
|
||||
expect(probe.exec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers system PATH over share-dir when both are available', async () => {
|
||||
const probe = probeWith((args) => (args[0] === 'rg' ? 0 : -1));
|
||||
const binDir = join(fakeShare, 'path-bin');
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const systemRg = join(binDir, process.platform === 'win32' ? 'rg.exe' : 'rg');
|
||||
const cached = join(fakeShare, 'bin', process.platform === 'win32' ? 'rg.exe' : 'rg');
|
||||
writeFileSync(systemRg, 'fake system rg');
|
||||
writeFileSync(cached, 'fake cached rg');
|
||||
process.env['PATH'] = binDir;
|
||||
const probe = noRgProbe();
|
||||
const result = await findExistingRg(probe, fakeShare);
|
||||
|
||||
expect(result).toEqual({ path: 'rg', source: 'system-path' });
|
||||
expect(probe.exec).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ path: systemRg, source: 'system-path' });
|
||||
expect(probe.exec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -181,6 +198,7 @@ describe('verifyArchiveChecksum', () => {
|
|||
describe('ensureRgPath download branch', () => {
|
||||
let fakeShare: string;
|
||||
let savedFetch: typeof globalThis.fetch | undefined;
|
||||
let savedPath: string | undefined;
|
||||
beforeEach(() => {
|
||||
fakeShare = join(
|
||||
tmpdir(),
|
||||
|
|
@ -188,6 +206,8 @@ describe('ensureRgPath download branch', () => {
|
|||
);
|
||||
mkdirSync(join(fakeShare, 'bin'), { recursive: true });
|
||||
savedFetch = globalThis.fetch;
|
||||
savedPath = process.env['PATH'];
|
||||
process.env['PATH'] = '';
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(fakeShare, { recursive: true, force: true });
|
||||
|
|
@ -196,6 +216,11 @@ describe('ensureRgPath download branch', () => {
|
|||
} else {
|
||||
globalThis.fetch = savedFetch;
|
||||
}
|
||||
if (savedPath === undefined) {
|
||||
delete process.env['PATH'];
|
||||
} else {
|
||||
process.env['PATH'] = savedPath;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -231,36 +256,15 @@ describe('ensureRgPath download branch', () => {
|
|||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start bootstrap work when aborted after lookup misses', async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
const cacheProbe = deferred<{ readonly exitCode: number }>();
|
||||
const probe: RgProbe & { exec: ReturnType<typeof vi.fn> } = {
|
||||
exec: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ exitCode: -1 })
|
||||
.mockReturnValueOnce(cacheProbe.promise),
|
||||
};
|
||||
it('does not run probe subprocesses while lookup misses', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network unreachable')) as typeof fetch;
|
||||
const probe = noRgProbe();
|
||||
|
||||
const resultPromise = ensureRgPath(probe, {
|
||||
shareDir: fakeShare,
|
||||
signal: controller.signal,
|
||||
allowCachedFallback: true,
|
||||
});
|
||||
await expect(
|
||||
ensureRgPath(probe, { shareDir: fakeShare, allowCachedFallback: true }),
|
||||
).rejects.toThrow(/network unreachable/);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(probe.exec).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
controller.abort();
|
||||
await expect(resultPromise).rejects.toHaveProperty('name', 'AbortError');
|
||||
|
||||
cacheProbe.resolve({ exitCode: -1 });
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(probe.exec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the current caller wait while shared bootstrap work continues', async () => {
|
||||
|
|
@ -376,6 +380,7 @@ describe('ensureRgPath Windows download branch', () => {
|
|||
let savedFetch: typeof globalThis.fetch | undefined;
|
||||
let savedArch: string;
|
||||
let savedPlatform: string;
|
||||
let savedPath: string | undefined;
|
||||
beforeEach(() => {
|
||||
fakeShare = join(
|
||||
tmpdir(),
|
||||
|
|
@ -383,6 +388,8 @@ describe('ensureRgPath Windows download branch', () => {
|
|||
);
|
||||
mkdirSync(join(fakeShare, 'bin'), { recursive: true });
|
||||
savedFetch = globalThis.fetch;
|
||||
savedPath = process.env['PATH'];
|
||||
process.env['PATH'] = '';
|
||||
savedArch = process.arch;
|
||||
savedPlatform = process.platform;
|
||||
Object.defineProperty(process, 'arch', { value: 'x64' });
|
||||
|
|
@ -395,6 +402,11 @@ describe('ensureRgPath Windows download branch', () => {
|
|||
} else {
|
||||
globalThis.fetch = savedFetch;
|
||||
}
|
||||
if (savedPath === undefined) {
|
||||
delete process.env['PATH'];
|
||||
} else {
|
||||
process.env['PATH'] = savedPath;
|
||||
}
|
||||
Object.defineProperty(process, 'arch', { value: savedArch });
|
||||
Object.defineProperty(process, 'platform', { value: savedPlatform });
|
||||
vi.restoreAllMocks();
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { microCompactionFlag } from '#/agent/microCompaction/flag';
|
|||
import { estimateTokensForMessages } from '#/_base/utils/tokens';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
|
||||
import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../harness';
|
||||
import { appServices, testAgent } from '../harness';
|
||||
import { appServices, createCommandRunner, execEnvServices, testAgent } from '../harness';
|
||||
import {
|
||||
IAgentFullCompactionService,
|
||||
IAgentMicroCompactionService,
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
IAgentProfileService,
|
||||
ISessionTodoService,
|
||||
} from '#/index';
|
||||
import { IAgentTurnService } from '#/agent/turn/turn';
|
||||
|
||||
type GenerateFn = NonNullable<TestAgentOptions['generate']>;
|
||||
|
||||
|
|
@ -273,7 +274,7 @@ describe('FullCompaction', () => {
|
|||
compacted_count: 6,
|
||||
retry_count: 0,
|
||||
thinking_level: 'off',
|
||||
input_other: 520,
|
||||
input_other: 1181,
|
||||
output: 8,
|
||||
input_cache_read: 0,
|
||||
input_cache_creation: 0,
|
||||
|
|
@ -282,6 +283,36 @@ describe('FullCompaction', () => {
|
|||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('rejects a manual compaction while a turn is active', async () => {
|
||||
const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('should-not-run') }));
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
|
||||
tools: ['Bash'],
|
||||
});
|
||||
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
|
||||
ctx.mockNextResponse({ type: 'text', text: 'I will wait for approval.' }, bashCall());
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start the active turn' }] });
|
||||
const approval = await ctx.takeApprovalRequest();
|
||||
expect(ctx.get(IAgentTurnService).getActiveTurn()).toBeDefined();
|
||||
|
||||
await expect(ctx.rpc.beginCompaction({})).rejects.toMatchObject({
|
||||
code: 'compaction.unable',
|
||||
message: 'Cannot compact while a turn is active. Wait for it to finish, then retry.',
|
||||
});
|
||||
const events = ctx.newEvents();
|
||||
expect(eventIndex(events, 'full_compaction.begin')).toBe(-1);
|
||||
expect(eventIndex(events, 'compaction.started')).toBe(-1);
|
||||
expect(ctx.get(IAgentFullCompactionService).compacting).toBeNull();
|
||||
expect(ctx.llmCalls).toHaveLength(1);
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'Turn done.' });
|
||||
approval.respond({ decision: 'rejected', selectedLabel: 'reject' });
|
||||
await ctx.untilTurnEnd();
|
||||
expect(ctx.get(IAgentTurnService).getActiveTurn()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('projects the compacted prefix before sending the summary request', async () => {
|
||||
const ctx = testAgent();
|
||||
ctx.configure({
|
||||
|
|
@ -434,7 +465,12 @@ describe('FullCompaction', () => {
|
|||
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token', 'fresh-token']);
|
||||
expect(tokenCalls).toEqual([undefined, true, undefined]);
|
||||
expect(ctx.compactHistory()).toEqual([
|
||||
{ role: 'assistant', text: 'Recovered compacted summary.' },
|
||||
{ role: 'user', text: 'old user one' },
|
||||
{ role: 'user', text: 'recent user two' },
|
||||
{
|
||||
role: 'user',
|
||||
text: expect.stringContaining('Recovered compacted summary.'),
|
||||
},
|
||||
]);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
|
@ -666,6 +702,48 @@ describe('FullCompaction', () => {
|
|||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('reduces the compacted prefix and retries when compaction receives plain 413', async () => {
|
||||
vi.useFakeTimers();
|
||||
const firstAttemptFailed = deferred<void>();
|
||||
let attempts = 0;
|
||||
const inputs: string[][] = [];
|
||||
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
|
||||
attempts += 1;
|
||||
inputs.push(inputHistorySnapshot(history));
|
||||
if (attempts === 1) {
|
||||
firstAttemptFailed.resolve();
|
||||
throw new APIStatusError(413, 'Request Entity Too Large', 'req-compact-plain-413');
|
||||
}
|
||||
return textResult('Recovered compacted summary.');
|
||||
};
|
||||
const ctx = testAgent({ generate });
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: {
|
||||
...CATALOGUED_MODEL_CAPABILITIES,
|
||||
max_context_tokens: 20_000,
|
||||
},
|
||||
});
|
||||
ctx.appendExchange(1, 'old user one', `old assistant one ${'x'.repeat(45_000)}`, 20);
|
||||
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
|
||||
const compacted = ctx.once('full_compaction.complete');
|
||||
const completed = ctx.once('compaction.completed');
|
||||
|
||||
await ctx.rpc.beginCompaction({});
|
||||
await firstAttemptFailed.promise;
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await compacted;
|
||||
await completed;
|
||||
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[1]!.length).toBeLessThan(inputs[0]!.length);
|
||||
const compactedHistory = ctx.compactHistory();
|
||||
expect(compactedHistory.some((message) => message.text.includes('old assistant one'))).toBe(false);
|
||||
expect(compactedHistory.some((message) => message.text.includes('Recovered compacted summary.'))).toBe(true);
|
||||
vi.useRealTimers();
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('fails after exhausting retries when the model only ever returns thinking content', async () => {
|
||||
// End-to-end through the real kosong generate(): every attempt is think-only,
|
||||
// so generate() keeps throwing APIEmptyResponseError. Compaction shrinks the
|
||||
|
|
@ -744,6 +822,7 @@ describe('FullCompaction', () => {
|
|||
await compacted;
|
||||
|
||||
expect(attempts).toBe(2);
|
||||
vi.useRealTimers();
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
|
|
@ -775,6 +854,7 @@ describe('FullCompaction', () => {
|
|||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(attempts).toBe(1);
|
||||
vi.useRealTimers();
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
|
|
@ -946,6 +1026,7 @@ describe('FullCompaction', () => {
|
|||
error_type: 'APIConnectionError',
|
||||
}),
|
||||
});
|
||||
vi.useRealTimers();
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
|
|
@ -1550,7 +1631,12 @@ describe('FullCompaction', () => {
|
|||
|
||||
expect(ctx.llmCalls).toHaveLength(1);
|
||||
expect(ctx.compactHistory()).toEqual([
|
||||
{ role: 'assistant', text: 'Compacted after no-op cancel.' },
|
||||
{ role: 'user', text: 'old user one' },
|
||||
{ role: 'user', text: 'recent user two' },
|
||||
{
|
||||
role: 'user',
|
||||
text: expect.stringContaining('Compacted after no-op cancel.'),
|
||||
},
|
||||
]);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
|
@ -1605,8 +1691,10 @@ describe('FullCompaction', () => {
|
|||
|
||||
expect(ctx.llmCalls).toHaveLength(2);
|
||||
const [compactionCall, answerCall] = ctx.llmCalls;
|
||||
expect(messageText(compactionCall?.history.at(-1))).toContain('<!-- Compression Priorities');
|
||||
expect(answerCall?.history.map(messageText)).toContain('Reserved compacted summary.');
|
||||
expect(messageText(compactionCall?.history.at(-1))).toContain('first-person handoff note');
|
||||
expect(
|
||||
answerCall?.history.map(messageText).some((text) => text.includes('Reserved compacted summary.')),
|
||||
).toBe(true);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
|
|
@ -1632,8 +1720,12 @@ describe('FullCompaction', () => {
|
|||
const compactionTexts = compactionCall?.history.map(messageText) ?? [];
|
||||
expect(compactionTexts.some((text) => text.includes('keep-this-pending-verbatim'))).toBe(false);
|
||||
expect(compactionCall?.history.map((message) => message.role)).toEqual(['user', 'assistant', 'user']);
|
||||
expect(answerCall?.history.map(messageText)).toContain('Oversized prompt summary.');
|
||||
expect(messageText(answerCall?.history.at(-1))).toBe(oversizedPrompt);
|
||||
expect(
|
||||
answerCall?.history.map(messageText).some((text) => text.includes('Oversized prompt summary.')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
answerCall?.history.map(messageText).some((text) => text.includes('keep-this-pending-verbatim')),
|
||||
).toBe(true);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
|
|
@ -1659,8 +1751,12 @@ describe('FullCompaction', () => {
|
|||
const compactionTexts = compactionCall?.history.map(messageText) ?? [];
|
||||
expect(compactionTexts.some((text) => text.includes('ratio-pending-verbatim'))).toBe(false);
|
||||
expect(compactionCall?.history.map((message) => message.role)).toEqual(['user', 'assistant', 'user']);
|
||||
expect(answerCall?.history.map(messageText)).toContain('Ratio compacted summary.');
|
||||
expect(messageText(answerCall?.history.at(-1))).toBe(pendingPrompt);
|
||||
expect(
|
||||
answerCall?.history.map(messageText).some((text) => text.includes('Ratio compacted summary.')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
answerCall?.history.map(messageText).some((text) => text.includes('ratio-pending-verbatim')),
|
||||
).toBe(true);
|
||||
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
|
@ -1734,14 +1830,101 @@ describe('FullCompaction', () => {
|
|||
"user: <compaction-instruction>",
|
||||
],
|
||||
[
|
||||
"assistant: Overflow compacted summary.",
|
||||
"user: Retry after provider overflow",
|
||||
"user: old user one
|
||||
|
||||
Retry after provider overflow",
|
||||
"user: The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary.
|
||||
Overflow compacted summary.",
|
||||
],
|
||||
]
|
||||
`);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('recovers from plain 413 when estimated request is over effective max', async () => {
|
||||
let callCount = 0;
|
||||
const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
throw new APIStatusError(413, 'Request Entity Too Large', 'req-plain-413');
|
||||
}
|
||||
if (callCount === 2) {
|
||||
return textResult('Plain 413 compacted summary.');
|
||||
}
|
||||
await callbacks?.onMessagePart?.({
|
||||
type: 'text',
|
||||
text: 'Recovered after plain 413 compaction.',
|
||||
});
|
||||
return textResult('Recovered after plain 413 compaction.');
|
||||
};
|
||||
const ctx = testAgent({ generate });
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: {
|
||||
...CATALOGUED_MODEL_CAPABILITIES,
|
||||
max_context_tokens: 200_000,
|
||||
},
|
||||
});
|
||||
ctx.appendExchange(1, 'old user one', `old assistant one ${'x'.repeat(600_000)}`, 150_000);
|
||||
ctx.newEvents();
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry after plain 413' }] });
|
||||
const events = await ctx.untilTurnEnd();
|
||||
|
||||
expect(callCount).toBe(3);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'compaction.started',
|
||||
args: { trigger: 'auto' },
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'compaction.completed',
|
||||
args: expect.objectContaining({
|
||||
result: expect.objectContaining({
|
||||
summary: 'Plain 413 compacted summary.',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'turn.ended',
|
||||
args: { turnId: 0, reason: 'completed' },
|
||||
}),
|
||||
);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('does not compact plain 413 when estimated request is small', async () => {
|
||||
const generate: GenerateFn = async () => {
|
||||
throw new APIStatusError(413, 'Request Entity Too Large', 'req-small-413');
|
||||
};
|
||||
const ctx = testAgent({ generate });
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: {
|
||||
...CATALOGUED_MODEL_CAPABILITIES,
|
||||
max_context_tokens: 200_000,
|
||||
},
|
||||
});
|
||||
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
|
||||
ctx.newEvents();
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'small prompt' }] });
|
||||
const events = await ctx.untilTurnEnd();
|
||||
|
||||
expect(eventIndex(events, 'compaction.started')).toBe(-1);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'turn.ended',
|
||||
args: expect.objectContaining({ turnId: 0, reason: 'failed' }),
|
||||
}),
|
||||
);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('does not reset the step budget after provider context overflow compaction', async () => {
|
||||
let callCount = 0;
|
||||
const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => {
|
||||
|
|
@ -2348,6 +2531,15 @@ function textMessage(role: 'user' | 'assistant', text: string): Message {
|
|||
};
|
||||
}
|
||||
|
||||
function bashCall(): ToolCall {
|
||||
return {
|
||||
type: 'function',
|
||||
id: 'call_bash',
|
||||
name: 'Bash',
|
||||
arguments: JSON.stringify({ command: 'printf should-not-run', timeout: 60 }),
|
||||
};
|
||||
}
|
||||
|
||||
function messageText(message: Message | undefined): string {
|
||||
return message?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? '';
|
||||
}
|
||||
|
|
@ -2381,5 +2573,5 @@ function inputHistorySnapshot(history: readonly Message[]): string[] {
|
|||
}
|
||||
|
||||
function normalizeInputText(text: string): string {
|
||||
return text.includes('compact this conversation context') ? '<compaction-instruction>' : text;
|
||||
return text.includes('first-person handoff note') ? '<compaction-instruction>' : text;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -560,18 +560,22 @@ export function questionServices(service: ISessionQuestionService): TestAgentSer
|
|||
export function externalHookServices(
|
||||
hookRunner: Pick<IExternalHooksRunnerService, 'trigger' | 'triggerBlock' | 'fireAndForgetTrigger'> | undefined,
|
||||
): TestAgentServiceOverride {
|
||||
const runner: IExternalHooksRunnerService =
|
||||
hookRunner === undefined
|
||||
? noopHookRunner
|
||||
: isRunnerLike(hookRunner)
|
||||
? hookRunner
|
||||
: { ...noopHookRunner, ...hookRunner };
|
||||
return [
|
||||
appService(IExternalHooksRunnerService, runner),
|
||||
appService(IExternalHooksRunnerService, resolveExternalHooksRunner(hookRunner)),
|
||||
agentService(IAgentExternalHooksService, new SyncDescriptor(AgentExternalHooksService)),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveExternalHooksRunner(
|
||||
hookRunner: Pick<IExternalHooksRunnerService, 'trigger' | 'triggerBlock' | 'fireAndForgetTrigger'> | undefined,
|
||||
): IExternalHooksRunnerService {
|
||||
return hookRunner === undefined
|
||||
? noopHookRunner
|
||||
: isRunnerLike(hookRunner)
|
||||
? hookRunner
|
||||
: { ...noopHookRunner, ...hookRunner };
|
||||
}
|
||||
|
||||
function isRunnerLike(
|
||||
value: Pick<IExternalHooksRunnerService, 'trigger' | 'triggerBlock' | 'fireAndForgetTrigger'>,
|
||||
): value is IExternalHooksRunnerService {
|
||||
|
|
@ -982,6 +986,12 @@ export class AgentTestContext {
|
|||
if (options.telemetry !== undefined) {
|
||||
reg.defineInstance(ITelemetryService, options.telemetry);
|
||||
}
|
||||
if (options.hookEngine !== undefined) {
|
||||
reg.defineInstance(
|
||||
IExternalHooksRunnerService,
|
||||
resolveExternalHooksRunner(options.hookEngine),
|
||||
);
|
||||
}
|
||||
reg.defineInstance(IHostTerminalService, createHostTerminalService());
|
||||
// The real `HostEnvironmentService` probes the host asynchronously (`ready`);
|
||||
// builtin tools (e.g. `BashTool`) read `osKind`/`shellName` synchronously at
|
||||
|
|
@ -1068,9 +1078,7 @@ export class AgentTestContext {
|
|||
);
|
||||
reg.defineDescriptor(
|
||||
IAgentExternalHooksService,
|
||||
new SyncDescriptor(AgentExternalHooksService, [
|
||||
options.hookEngine === undefined ? {} : { hookEngine: options.hookEngine },
|
||||
]),
|
||||
new SyncDescriptor(AgentExternalHooksService),
|
||||
);
|
||||
reg.defineDescriptor(
|
||||
IAgentMicroCompactionService,
|
||||
|
|
@ -1184,7 +1192,6 @@ export class AgentTestContext {
|
|||
|
||||
private async replayRestoredRecordsSince(restoredStart: number): Promise<void> {
|
||||
const restored = this.wireRecord.getRecords().slice(restoredStart);
|
||||
if (restored.length === 0) return;
|
||||
await this.get(IAgentWireService).replay(...(restored as readonly PersistedRecord[]));
|
||||
}
|
||||
|
||||
|
|
@ -1548,6 +1555,7 @@ export class AgentTestContext {
|
|||
await this.drainWirePersistence();
|
||||
const profile = this.get(IAgentProfileService);
|
||||
const configSnapshot = structuredClone(this.get(IConfigService).getAll() as KimiConfig);
|
||||
let resumedThroughRecord = this.recordHistory.length;
|
||||
const resumed = createTestAgent(
|
||||
{ autoConfigure: false, cwd: profile.data().cwd },
|
||||
...this.serviceOverrides,
|
||||
|
|
@ -1561,6 +1569,13 @@ export class AgentTestContext {
|
|||
try {
|
||||
await resumed.restorePersisted();
|
||||
await resumed.waitForSessionMetadata();
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await this.drainWirePersistence();
|
||||
if (this.recordHistory.length === resumedThroughRecord) break;
|
||||
const nextRecords = this.recordHistory.slice(resumedThroughRecord).map(cloneRecord);
|
||||
resumedThroughRecord = this.recordHistory.length;
|
||||
await resumed.restore(nextRecords);
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line jest/no-standalone-expect
|
||||
expect(resumeStateSnapshot(resumed)).toEqual(resumeStateSnapshot(this));
|
||||
|
|
@ -1575,11 +1590,22 @@ export class AgentTestContext {
|
|||
}
|
||||
|
||||
private async drainWirePersistence(): Promise<void> {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
const wireRecord = this.get(IAgentWireRecordService);
|
||||
await wireRecord.flush();
|
||||
let lastRecordCount = -1;
|
||||
for (let i = 0; i < 25; i += 1) {
|
||||
for (let j = 0; j < 5; j += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await wireRecord.flush();
|
||||
if (
|
||||
this.recordHistory.length === lastRecordCount &&
|
||||
pendingTaskNotificationKeys(this.recordHistory).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastRecordCount = this.recordHistory.length;
|
||||
}
|
||||
}
|
||||
|
||||
async close(_reason = 'Agent runtime test closed'): Promise<void> {
|
||||
|
|
@ -1992,6 +2018,73 @@ function isSystemReminderMessage(message: ContextMessage): boolean {
|
|||
return text.startsWith('<system-reminder>');
|
||||
}
|
||||
|
||||
function pendingTaskNotificationKeys(records: readonly PersistedWireRecord[]): readonly string[] {
|
||||
const terminal = new Set<string>();
|
||||
const delivered = new Set<string>();
|
||||
for (const record of records) {
|
||||
if (record.type === 'task.terminated') {
|
||||
const info = record['info'];
|
||||
if (isTaskInfoLike(info) && info.detached !== false && info.terminalNotificationSuppressed !== true) {
|
||||
terminal.add(taskNotificationKey(info.taskId, info.status));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const message of contextMessagesFromRecord(record)) {
|
||||
const origin = message.origin;
|
||||
if (isTaskOriginLike(origin)) {
|
||||
delivered.add(`${origin.taskId}\0${origin.status}\0${origin.notificationId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...terminal].filter((key) => !delivered.has(key));
|
||||
}
|
||||
|
||||
function contextMessagesFromRecord(record: PersistedWireRecord): readonly ContextMessage[] {
|
||||
if (record.type === 'context.append_message') {
|
||||
const message = record['message'];
|
||||
return isContextMessageLike(message) ? [message] : [];
|
||||
}
|
||||
if (record.type === 'context.splice') {
|
||||
const messages = record['messages'];
|
||||
return Array.isArray(messages)
|
||||
? messages.filter(isContextMessageLike)
|
||||
: [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isContextMessageLike(value: unknown): value is ContextMessage {
|
||||
return typeof value === 'object' && value !== null && 'role' in value;
|
||||
}
|
||||
|
||||
function isTaskInfoLike(value: unknown): value is {
|
||||
readonly taskId: string;
|
||||
readonly status: string;
|
||||
readonly detached?: boolean;
|
||||
readonly terminalNotificationSuppressed?: boolean;
|
||||
} {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const info = value as Record<string, unknown>;
|
||||
return typeof info['taskId'] === 'string' && typeof info['status'] === 'string';
|
||||
}
|
||||
|
||||
function isTaskOriginLike(value: unknown): value is {
|
||||
readonly taskId: string;
|
||||
readonly status: string;
|
||||
readonly notificationId: string;
|
||||
} {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const origin = value as Record<string, unknown>;
|
||||
return origin['kind'] === 'task' &&
|
||||
typeof origin['taskId'] === 'string' &&
|
||||
typeof origin['status'] === 'string' &&
|
||||
typeof origin['notificationId'] === 'string';
|
||||
}
|
||||
|
||||
function taskNotificationKey(taskId: string, status: string): string {
|
||||
return `${taskId}\0${status}\0task:${taskId}:${status}`;
|
||||
}
|
||||
|
||||
function configStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot['config'] {
|
||||
const profile = ctx.get(IAgentProfileService);
|
||||
const data = profile.data();
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ function formatText(text: string): string {
|
|||
if (isPlanModeReminder(text)) {
|
||||
return '<plan-mode-reminder>';
|
||||
}
|
||||
if (text.includes('<!-- Compression Priorities (in order) -->')) {
|
||||
if (text.includes('first-person handoff note')) {
|
||||
return '<compaction-instruction>';
|
||||
}
|
||||
return JSON.stringify(text);
|
||||
|
|
|
|||
|
|
@ -299,12 +299,12 @@ describe('ModelResolverService', () => {
|
|||
|
||||
it('force-refreshes OAuth credentials and replays a request after 401', async () => {
|
||||
configureOAuthModel();
|
||||
const tokenCalls: boolean[] = [];
|
||||
const tokenCalls: Array<boolean | undefined> = [];
|
||||
const authKeys: string[] = [];
|
||||
resolveTokenProvider.mockReturnValue({
|
||||
getAccessToken: async (options: { readonly force: boolean }) => {
|
||||
tokenCalls.push(options.force);
|
||||
return options.force ? 'forced-refresh-token' : 'fresh-token';
|
||||
getAccessToken: async (options?: { readonly force?: boolean }) => {
|
||||
tokenCalls.push(options?.force);
|
||||
return options?.force === true ? 'forced-refresh-token' : 'fresh-token';
|
||||
},
|
||||
});
|
||||
generateImpl = async (_system, _tools, _history, options) => {
|
||||
|
|
@ -333,7 +333,7 @@ describe('ModelResolverService', () => {
|
|||
}
|
||||
|
||||
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
|
||||
expect(tokenCalls).toEqual([false, true]);
|
||||
expect(tokenCalls).toEqual([undefined, true]);
|
||||
expect(events).toContainEqual({ type: 'part', part: { type: 'text', text: 'recovered' } });
|
||||
});
|
||||
|
||||
|
|
@ -341,8 +341,8 @@ describe('ModelResolverService', () => {
|
|||
configureOAuthModel();
|
||||
const authKeys: string[] = [];
|
||||
resolveTokenProvider.mockReturnValue({
|
||||
getAccessToken: async (options: { readonly force: boolean }) =>
|
||||
options.force ? 'forced-refresh-token' : 'fresh-token',
|
||||
getAccessToken: async (options?: { readonly force?: boolean }) =>
|
||||
options?.force === true ? 'forced-refresh-token' : 'fresh-token',
|
||||
});
|
||||
generateImpl = async (_system, _tools, _history, options) => {
|
||||
authKeys.push(options?.auth?.apiKey ?? '<missing>');
|
||||
|
|
@ -393,12 +393,12 @@ describe('ModelResolverService', () => {
|
|||
|
||||
it('force-refreshes OAuth credentials and replays video upload after 401', async () => {
|
||||
configureOAuthModel();
|
||||
const tokenCalls: boolean[] = [];
|
||||
const tokenCalls: Array<boolean | undefined> = [];
|
||||
const authKeys: string[] = [];
|
||||
resolveTokenProvider.mockReturnValue({
|
||||
getAccessToken: async (options: { readonly force: boolean }) => {
|
||||
tokenCalls.push(options.force);
|
||||
return options.force ? 'forced-refresh-token' : 'fresh-token';
|
||||
getAccessToken: async (options?: { readonly force?: boolean }) => {
|
||||
tokenCalls.push(options?.force);
|
||||
return options?.force === true ? 'forced-refresh-token' : 'fresh-token';
|
||||
},
|
||||
});
|
||||
uploadVideoImpl = async (_input, options) => {
|
||||
|
|
@ -416,7 +416,7 @@ describe('ModelResolverService', () => {
|
|||
videoUrl: { url: 'https://example.test/video' },
|
||||
});
|
||||
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
|
||||
expect(tokenCalls).toEqual([false, true]);
|
||||
expect(tokenCalls).toEqual([undefined, true]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -136,7 +136,11 @@ describe('WireService', () => {
|
|||
let changes = 0;
|
||||
let restored = 0;
|
||||
disposables.add(replayed.subscribe(CounterModel, () => (changes += 1)));
|
||||
disposables.add(replayed.onRestored(() => (restored += 1)));
|
||||
disposables.add(
|
||||
replayed.onRestored(() => {
|
||||
restored += 1;
|
||||
}),
|
||||
);
|
||||
|
||||
await replayed.replay(...records);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { createControlledPromise } from '@antfu/utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { IAgentScopeHandle } from '#/_base/di/scope';
|
||||
|
|
@ -6,10 +7,13 @@ import { SyncDescriptor } from '#/_base/di/descriptors';
|
|||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { TestInstantiationService } from '#/_base/di/test';
|
||||
import { Event } from '#/_base/event';
|
||||
import { userCancellationReason } from '#/_base/utils/abort';
|
||||
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
|
||||
import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile';
|
||||
import { IAgentUserToolService } from '#/agent/userTool/userTool';
|
||||
import { IEventBus, type DomainEvent } from '#/app/event/eventBus';
|
||||
import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog';
|
||||
import { APIProviderRateLimitError } from '#/app/llmProtocol/errors';
|
||||
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry';
|
||||
import {
|
||||
IAgentLifecycleService,
|
||||
|
|
@ -29,7 +33,11 @@ import { ILogService } from '#/_base/log/log';
|
|||
import {
|
||||
AgentRunBatch,
|
||||
resolveSwarmMaxConcurrency,
|
||||
type AgentRunAttemptHandle,
|
||||
type AgentRunAttemptOptions,
|
||||
type AgentRunBatchLauncher,
|
||||
type AgentRunResult,
|
||||
type AgentRunSuspendedEvent,
|
||||
type AgentSpawnAttemptOptions,
|
||||
type QueuedAgentRunTask,
|
||||
} from '#/session/swarm/agentRunBatch';
|
||||
|
|
@ -66,6 +74,702 @@ describe('resolveSwarmMaxConcurrency', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('AgentRunBatch scheduling contract', () => {
|
||||
it('normal phase starts five tasks immediately, then one task every 700ms', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner();
|
||||
const running = runBatch(
|
||||
Array.from({ length: 9 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(699);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(attempts).toHaveLength(6);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(attempts).toHaveLength(7);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(attempts).toHaveLength(8);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(attempts).toHaveLength(9);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(attempts).toHaveLength(9);
|
||||
|
||||
attempts.forEach((attempt, index) => {
|
||||
attempt.outcome.resolve({
|
||||
task: attempt.task,
|
||||
agentId: `agent-${String(index + 1)}`,
|
||||
status: 'completed',
|
||||
result: `result ${String(index + 1)}`,
|
||||
});
|
||||
});
|
||||
const results = await running;
|
||||
|
||||
expect(results).toHaveLength(9);
|
||||
expect(results.every((result) => result.status === 'completed')).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('user cancellation returns completed, started, and not-started task results', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner();
|
||||
const running = runBatch(
|
||||
Array.from({ length: 6 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
attempts[0]!.outcome.resolve({
|
||||
task: attempts[0]!.task,
|
||||
agentId: 'agent-1',
|
||||
status: 'completed',
|
||||
result: 'completed 1',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
controller.abort(userCancellationReason());
|
||||
const results = await running;
|
||||
|
||||
expect(
|
||||
results.map((result) => ({
|
||||
data: result.task.data,
|
||||
agentId: result.agentId,
|
||||
status: result.status,
|
||||
state: result.state,
|
||||
result: result.result,
|
||||
error: result.error,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
data: 1,
|
||||
agentId: 'agent-1',
|
||||
status: 'completed',
|
||||
state: undefined,
|
||||
result: 'completed 1',
|
||||
error: undefined,
|
||||
},
|
||||
{
|
||||
data: 2,
|
||||
agentId: 'agent-2',
|
||||
status: 'aborted',
|
||||
state: 'started',
|
||||
result: undefined,
|
||||
error: 'The user manually interrupted this subagent batch before this subagent finished.',
|
||||
},
|
||||
{
|
||||
data: 3,
|
||||
agentId: 'agent-3',
|
||||
status: 'aborted',
|
||||
state: 'started',
|
||||
result: undefined,
|
||||
error: 'The user manually interrupted this subagent batch before this subagent finished.',
|
||||
},
|
||||
{
|
||||
data: 4,
|
||||
agentId: 'agent-4',
|
||||
status: 'aborted',
|
||||
state: 'started',
|
||||
result: undefined,
|
||||
error: 'The user manually interrupted this subagent batch before this subagent finished.',
|
||||
},
|
||||
{
|
||||
data: 5,
|
||||
agentId: 'agent-5',
|
||||
status: 'aborted',
|
||||
state: 'started',
|
||||
result: undefined,
|
||||
error: 'The user manually interrupted this subagent batch before this subagent finished.',
|
||||
},
|
||||
{
|
||||
data: 6,
|
||||
agentId: undefined,
|
||||
status: 'aborted',
|
||||
state: 'not_started',
|
||||
result: undefined,
|
||||
error:
|
||||
'The user manually interrupted this subagent batch before this subagent was started.',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('normal phase keeps processing completions while waiting for the next launch', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner();
|
||||
const running = runBatch(
|
||||
Array.from({ length: 6 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
attempts[0]!.outcome.resolve({
|
||||
task: attempts[0]!.task,
|
||||
agentId: 'agent-1',
|
||||
status: 'completed',
|
||||
result: 'completed 1',
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(699);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(attempts).toHaveLength(6);
|
||||
|
||||
attempts.slice(1).forEach((attempt, index) => {
|
||||
attempt.outcome.resolve({
|
||||
task: attempt.task,
|
||||
agentId: `agent-${String(index + 2)}`,
|
||||
status: 'completed',
|
||||
result: `completed ${String(index + 2)}`,
|
||||
});
|
||||
});
|
||||
await expect(running).resolves.toHaveLength(6);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rate-limit phase starts when the first provider rate limit stops the normal ramp', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner();
|
||||
const running = runBatch(
|
||||
Array.from({ length: 9 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
void running.catch(() => {});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
attempts.forEach((attempt) => {
|
||||
attempt.markReady();
|
||||
});
|
||||
|
||||
attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
attempts[1]!.outcome.resolve({
|
||||
task: attempts[1]!.task,
|
||||
agentId: 'agent-2',
|
||||
status: 'completed',
|
||||
result: 'completed 2',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(attempts).toHaveLength(6);
|
||||
expect(attempts[5]!.task.data).toBe(1);
|
||||
expect(attempts[5]!.retryAgentId).toBe('agent-1');
|
||||
|
||||
controller.abort();
|
||||
await expect(running).rejects.toThrow();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rate-limit phase requeues 429 tasks, emits suspended, and throttles launches', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const onSuspended = vi.fn();
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner({ onSuspended });
|
||||
const running = runBatch(
|
||||
Array.from({ length: 8 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
void running.catch(() => {});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
attempts.forEach((attempt) => {
|
||||
attempt.markReady();
|
||||
});
|
||||
attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' });
|
||||
attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-2' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(onSuspended).toHaveBeenCalledTimes(2);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
expect(attempts).toHaveLength(6);
|
||||
expect(attempts[5]!.task.data).toBe(2);
|
||||
expect(attempts[5]!.retryAgentId).toBe('agent-2');
|
||||
|
||||
controller.abort();
|
||||
await expect(running).rejects.toThrow();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails the only unfinished task on provider rate limit instead of suspending forever', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onSuspended = vi.fn();
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner({ onSuspended });
|
||||
const running = runBatch(
|
||||
Array.from({ length: 2 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(2);
|
||||
attempts.forEach((attempt) => {
|
||||
attempt.markReady();
|
||||
});
|
||||
|
||||
attempts[0]!.outcome.resolve({
|
||||
task: attempts[0]!.task,
|
||||
agentId: 'agent-1',
|
||||
status: 'completed',
|
||||
result: 'completed 1',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-2' });
|
||||
await expect(running).resolves.toMatchObject([
|
||||
{
|
||||
task: { data: 1 },
|
||||
agentId: 'agent-1',
|
||||
status: 'completed',
|
||||
result: 'completed 1',
|
||||
},
|
||||
{
|
||||
task: { data: 2 },
|
||||
agentId: 'agent-2',
|
||||
status: 'failed',
|
||||
state: 'started',
|
||||
error: 'Rate limited',
|
||||
},
|
||||
]);
|
||||
expect(onSuspended).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rate-limit capacity blocks launches while active attempts fill all slots', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner();
|
||||
const running = runBatch(
|
||||
Array.from({ length: 12 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
void running.catch(() => {});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
attempts.slice(0, 5).forEach((attempt) => {
|
||||
attempt.markReady();
|
||||
});
|
||||
|
||||
for (let count = 6; count <= 12; count += 1) {
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(attempts).toHaveLength(count);
|
||||
attempts[count - 1]!.markReady();
|
||||
}
|
||||
|
||||
attempts.slice(0, 12).forEach((attempt) => {
|
||||
attempt.markReady();
|
||||
});
|
||||
|
||||
attempts[0]!.outcome.resolve({
|
||||
type: 'rate_limited',
|
||||
agentId: 'agent-1',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(attempts).toHaveLength(12);
|
||||
|
||||
controller.abort();
|
||||
await expect(running).rejects.toThrow();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rate-limit recovery adds one capacity slot after three quiet minutes with queued work', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner();
|
||||
const running = runBatch(
|
||||
Array.from({ length: 6 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
void running.catch(() => {});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
attempts.forEach((attempt) => {
|
||||
attempt.markReady();
|
||||
});
|
||||
|
||||
attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-2' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
attempts[2]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-3' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
attempts[3]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-4' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(179_999);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(attempts).toHaveLength(6);
|
||||
expect(attempts[5]!.task.data).toBe(4);
|
||||
expect(attempts[5]!.retryAgentId).toBe('agent-4');
|
||||
|
||||
controller.abort();
|
||||
await expect(running).rejects.toThrow();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rate-limit phase keeps launches bounded after repeated 429s', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner();
|
||||
const running = runBatch(
|
||||
Array.from({ length: 8 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
void running.catch(() => {});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
attempts.forEach((attempt) => {
|
||||
attempt.markReady();
|
||||
});
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
attempts[index]!.outcome.resolve({
|
||||
type: 'rate_limited',
|
||||
agentId: `agent-${String(index + 1)}`,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(attempts).toHaveLength(6);
|
||||
expect(attempts[5]!.task.data).toBe(3);
|
||||
expect(attempts[5]!.retryAgentId).toBe('agent-3');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(attempts).toHaveLength(7);
|
||||
expect(attempts[6]!.task.data).toBe(2);
|
||||
expect(attempts[6]!.retryAgentId).toBe('agent-2');
|
||||
|
||||
controller.abort();
|
||||
await expect(running).rejects.toThrow();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rate-limit phase schedules another launch after starting while capacity remains', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner();
|
||||
const running = runBatch(
|
||||
Array.from({ length: 8 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
void running.catch(() => {});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
attempts.forEach((attempt) => {
|
||||
attempt.markReady();
|
||||
});
|
||||
|
||||
attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
attempts[1]!.outcome.resolve({
|
||||
task: attempts[1]!.task,
|
||||
agentId: 'agent-2',
|
||||
status: 'completed',
|
||||
result: 'completed 2',
|
||||
});
|
||||
attempts[2]!.outcome.resolve({
|
||||
task: attempts[2]!.task,
|
||||
agentId: 'agent-3',
|
||||
status: 'completed',
|
||||
result: 'completed 3',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_999);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(attempts).toHaveLength(6);
|
||||
expect(attempts[5]!.task.data).toBe(1);
|
||||
expect(attempts[5]!.retryAgentId).toBe('agent-1');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_999);
|
||||
expect(attempts).toHaveLength(6);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(attempts).toHaveLength(7);
|
||||
expect(attempts[6]!.task.data).toBe(6);
|
||||
expect(attempts[6]!.retryAgentId).toBeUndefined();
|
||||
|
||||
controller.abort();
|
||||
await expect(running).rejects.toThrow();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('task timeout fails only that task', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner();
|
||||
const running = runBatch([{ ...queuedAgentRunTask(1), timeout: 10_000 }], {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
attempts[0]!.markReady();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9999);
|
||||
expect(attempts).toHaveLength(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(running).resolves.toMatchObject([
|
||||
{
|
||||
task: { data: 1 },
|
||||
agentId: 'agent-1',
|
||||
status: 'failed',
|
||||
state: 'started',
|
||||
error: 'Subagent timed out.',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not spend task timeout while the task is queued', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let settled = false;
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner();
|
||||
const running = runBatch(
|
||||
[
|
||||
...Array.from({ length: 5 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ ...queuedAgentRunTask(6), timeout: 1000 },
|
||||
],
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
void running.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(699);
|
||||
expect(attempts).toHaveLength(5);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(attempts).toHaveLength(6);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
attempts.slice(0, 5).forEach((attempt, index) => {
|
||||
attempt.outcome.resolve({
|
||||
task: attempt.task,
|
||||
agentId: `agent-${String(index + 1)}`,
|
||||
status: 'completed',
|
||||
result: `completed ${String(index + 1)}`,
|
||||
});
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(running).resolves.toMatchObject([
|
||||
{ task: { data: 1 }, status: 'completed' },
|
||||
{ task: { data: 2 }, status: 'completed' },
|
||||
{ task: { data: 3 }, status: 'completed' },
|
||||
{ task: { data: 4 }, status: 'completed' },
|
||||
{ task: { data: 5 }, status: 'completed' },
|
||||
{
|
||||
task: { data: 6 },
|
||||
agentId: 'agent-6',
|
||||
status: 'failed',
|
||||
state: 'started',
|
||||
error: 'Subagent timed out.',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rate-limit phase continues launching after rate-limited attempts settle', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner({
|
||||
readyDelay: (attemptIndex) => (attemptIndex >= 7 ? 100 : undefined),
|
||||
});
|
||||
|
||||
const running = runBatch(
|
||||
Array.from({ length: 9 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
void running.catch(() => {});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
attempts.slice(0, 5).forEach((attempt) => {
|
||||
attempt.markReady();
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(attempts).toHaveLength(6);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(attempts).toHaveLength(7);
|
||||
|
||||
attempts[5]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-6' });
|
||||
attempts[6]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-7' });
|
||||
attempts[0]!.outcome.resolve({
|
||||
task: attempts[0]!.task,
|
||||
agentId: 'agent-1',
|
||||
status: 'completed',
|
||||
result: 'completed 1',
|
||||
});
|
||||
attempts[1]!.outcome.resolve({
|
||||
task: attempts[1]!.task,
|
||||
agentId: 'agent-2',
|
||||
status: 'completed',
|
||||
result: 'completed 2',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(12_000);
|
||||
expect(attempts).toHaveLength(8);
|
||||
expect(attempts[7]!.task.data).toBe(7);
|
||||
expect(attempts[7]!.retryAgentId).toBe('agent-7');
|
||||
|
||||
controller.abort();
|
||||
await expect(running).rejects.toThrow();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentRunBatch max concurrency cap', () => {
|
||||
it('caps in-flight tasks at maxConcurrency during the normal phase', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { runBatch, attempts } = createMockAgentRunBatchRunner({ maxConcurrency: 3 });
|
||||
const running = runBatch(
|
||||
Array.from({ length: 9 }, (_, index) => queuedAgentRunTask(index + 1)),
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
const resolved = new Set<number>();
|
||||
const resolveOne = (index: number) => {
|
||||
const attempt = attempts[index]!;
|
||||
resolved.add(index);
|
||||
attempt.outcome.resolve({
|
||||
task: attempt.task,
|
||||
agentId: `agent-${String(index + 1)}`,
|
||||
status: 'completed',
|
||||
result: `result ${String(index + 1)}`,
|
||||
});
|
||||
};
|
||||
const inFlight = () => attempts.length - resolved.size;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(3);
|
||||
expect(inFlight()).toBe(3);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(attempts).toHaveLength(3);
|
||||
|
||||
resolveOne(0);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(4);
|
||||
expect(inFlight()).toBeLessThanOrEqual(3);
|
||||
|
||||
resolveOne(1);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
expect(inFlight()).toBeLessThanOrEqual(3);
|
||||
|
||||
resolveOne(2);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toHaveLength(5);
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(attempts).toHaveLength(6);
|
||||
expect(inFlight()).toBeLessThanOrEqual(3);
|
||||
|
||||
for (let index = 3; index < 9; index += 1) {
|
||||
resolveOne(index);
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(inFlight()).toBeLessThanOrEqual(3);
|
||||
}
|
||||
|
||||
const results = await running;
|
||||
expect(results).toHaveLength(9);
|
||||
expect(results.every((result) => result.status === 'completed')).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentRunBatch swarm item forwarding', () => {
|
||||
function recordingLauncher() {
|
||||
const spawned: AgentSpawnAttemptOptions[] = [];
|
||||
|
|
@ -133,13 +837,14 @@ describe('SessionSwarmService metadata compatibility', () => {
|
|||
let lifecycle: IAgentLifecycleService;
|
||||
let createAgent: ReturnType<typeof vi.fn>;
|
||||
let runAgent: ReturnType<typeof vi.fn>;
|
||||
let eventBus: IEventBus;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
ix = disposables.add(new TestInstantiationService());
|
||||
agents = {};
|
||||
handles = new Map();
|
||||
const eventBus = eventBusStub();
|
||||
eventBus = eventBusStub();
|
||||
lifecycle = lifecycleStub(handles, eventBus);
|
||||
createAgent = lifecycle.create as ReturnType<typeof vi.fn>;
|
||||
runAgent = lifecycle.run as ReturnType<typeof vi.fn>;
|
||||
|
|
@ -298,6 +1003,42 @@ describe('SessionSwarmService metadata compatibility', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('inherits parent user tools on spawned children', async () => {
|
||||
const parentUserTools = userToolServiceStub();
|
||||
const childUserTools = userToolServiceStub();
|
||||
handles.set(
|
||||
'main',
|
||||
agentHandle('main', lifecycle, eventBus, {}, new Map([
|
||||
[IAgentUserToolService, parentUserTools],
|
||||
])),
|
||||
);
|
||||
createAgent.mockImplementationOnce(async (opts: CreateAgentOptions = {}) => {
|
||||
const id = opts.agentId ?? 'agent-new';
|
||||
const handle = agentHandle(
|
||||
id,
|
||||
lifecycle,
|
||||
eventBus,
|
||||
{
|
||||
profileName: opts.binding?.profile ?? 'coder',
|
||||
modelAlias: opts.binding?.model ?? 'kimi-test',
|
||||
thinkingLevel: opts.binding?.thinking ?? 'medium',
|
||||
cwd: opts.binding?.cwd ?? '/repo',
|
||||
},
|
||||
new Map([[IAgentUserToolService, childUserTools]]),
|
||||
);
|
||||
handles.set(id, handle);
|
||||
return handle;
|
||||
});
|
||||
const service = ix.get(ISessionSwarmService);
|
||||
|
||||
await service.run({
|
||||
callerAgentId: 'main',
|
||||
tasks: [spawnSessionTask('src/a.ts')],
|
||||
});
|
||||
|
||||
expect(childUserTools.inheritUserTools).toHaveBeenCalledWith(parentUserTools);
|
||||
});
|
||||
|
||||
it('keeps v1 resume ownership errors inside the per-subagent result', async () => {
|
||||
agents['other-child'] = {
|
||||
homedir: '/tmp/kimi/s1/agents/other-child',
|
||||
|
|
@ -397,6 +1138,7 @@ function agentHandle(
|
|||
lifecycle: IAgentLifecycleService,
|
||||
eventBus: IEventBus,
|
||||
data: Partial<ProfileData> = {},
|
||||
services: ReadonlyMap<unknown, unknown> = new Map(),
|
||||
): IAgentScopeHandle {
|
||||
const profile = profileService({
|
||||
cwd: '/repo',
|
||||
|
|
@ -418,8 +1160,11 @@ function agentHandle(
|
|||
kind: LifecycleScope.Agent,
|
||||
accessor: {
|
||||
get: ((serviceId: unknown) => {
|
||||
const service = services.get(serviceId);
|
||||
if (service !== undefined) return service;
|
||||
if (serviceId === IAgentProfileService) return profile;
|
||||
if (serviceId === IAgentPermissionModeService) return permissionMode;
|
||||
if (serviceId === IAgentUserToolService) return userToolServiceStub();
|
||||
if (serviceId === IEventBus) return eventBus;
|
||||
if (serviceId === ITelemetryService) return noopTelemetryService;
|
||||
if (serviceId === IAgentLifecycleService) return lifecycle;
|
||||
|
|
@ -437,6 +1182,16 @@ function profileService(data: ProfileData): IAgentProfileService {
|
|||
} as IAgentProfileService;
|
||||
}
|
||||
|
||||
function userToolServiceStub(): IAgentUserToolService {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
list: () => [],
|
||||
inheritUserTools: vi.fn<(parent: IAgentUserToolService) => void>(),
|
||||
register: () => {},
|
||||
unregister: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function eventBusStub(): IEventBus {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
|
|
@ -444,3 +1199,165 @@ function eventBusStub(): IEventBus {
|
|||
subscribe: vi.fn(() => ({ dispose: () => {} })) as IEventBus['subscribe'],
|
||||
};
|
||||
}
|
||||
|
||||
type MockAgentRunAttemptOutcome<T> =
|
||||
| AgentRunResult<T>
|
||||
| {
|
||||
readonly type: 'rate_limited';
|
||||
readonly agentId: string;
|
||||
};
|
||||
|
||||
type MockAgentRunAttemptRecord = {
|
||||
readonly task: QueuedAgentRunTask<number>;
|
||||
readonly retryAgentId?: string;
|
||||
readonly markReady: () => void;
|
||||
readonly outcome: ReturnType<typeof createControlledPromise<MockAgentRunAttemptOutcome<number>>>;
|
||||
};
|
||||
|
||||
type MockAgentRunBatchRunnerOptions = {
|
||||
readonly onSuspended?: (event: AgentRunSuspendedEvent) => void;
|
||||
readonly readyDelay?: (attemptIndex: number) => number | undefined;
|
||||
readonly maxConcurrency?: number;
|
||||
};
|
||||
|
||||
function createMockAgentRunBatchRunner(
|
||||
options: MockAgentRunBatchRunnerOptions = {},
|
||||
): {
|
||||
readonly runBatch: <T>(
|
||||
tasks: readonly QueuedAgentRunTask<T>[],
|
||||
options?: { readonly signal?: AbortSignal },
|
||||
) => Promise<Array<AgentRunResult<T>>>;
|
||||
readonly attempts: MockAgentRunAttemptRecord[];
|
||||
} {
|
||||
const attempts: MockAgentRunAttemptRecord[] = [];
|
||||
let activeTasks: readonly QueuedAgentRunTask<unknown>[] = [];
|
||||
|
||||
const createHandle = <T,>(
|
||||
runOptions: AgentRunAttemptOptions,
|
||||
agentId: string,
|
||||
profileName: string,
|
||||
retryAgentId?: string,
|
||||
): AgentRunAttemptHandle => {
|
||||
const task = findMockAgentRunTask<T>(activeTasks, runOptions);
|
||||
const outcome = createControlledPromise<MockAgentRunAttemptOutcome<T>>();
|
||||
const markReady = () => {
|
||||
runOptions.onReady?.();
|
||||
};
|
||||
const attemptIndex = attempts.length;
|
||||
attempts.push({
|
||||
task: task as unknown as QueuedAgentRunTask<number>,
|
||||
retryAgentId,
|
||||
markReady,
|
||||
outcome: outcome as unknown as MockAgentRunAttemptRecord['outcome'],
|
||||
});
|
||||
|
||||
const delay = options.readyDelay?.(attemptIndex);
|
||||
if (delay !== undefined) setTimeout(markReady, delay);
|
||||
|
||||
return {
|
||||
agentId,
|
||||
profileName,
|
||||
completion: completionFromMockAgentRunOutcome(outcome, runOptions.signal),
|
||||
};
|
||||
};
|
||||
|
||||
const launcher: AgentRunBatchLauncher = {
|
||||
spawn: async (spawnOptions) => {
|
||||
const task = findMockAgentRunTask(activeTasks, spawnOptions);
|
||||
return createHandle(
|
||||
spawnOptions,
|
||||
mockAgentRunId(task, attempts.length),
|
||||
spawnOptions.profileName,
|
||||
);
|
||||
},
|
||||
resume: async (agentId, runOptions) => createHandle(runOptions, agentId, 'subagent'),
|
||||
retry: async (agentId, runOptions) => createHandle(runOptions, agentId, 'subagent', agentId),
|
||||
suspended: (event) => {
|
||||
options.onSuspended?.(event);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
runBatch: <T,>(
|
||||
tasks: readonly QueuedAgentRunTask<T>[],
|
||||
runOptions?: { readonly signal?: AbortSignal },
|
||||
) => {
|
||||
activeTasks = tasks.map((task) => ({
|
||||
...task,
|
||||
signal: task.signal ?? runOptions?.signal,
|
||||
}));
|
||||
return new AgentRunBatch(launcher, activeTasks as readonly QueuedAgentRunTask<T>[], {
|
||||
maxConcurrency: options.maxConcurrency,
|
||||
}).run();
|
||||
},
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
function findMockAgentRunTask<T>(
|
||||
tasks: readonly QueuedAgentRunTask<unknown>[],
|
||||
options: AgentRunAttemptOptions,
|
||||
): QueuedAgentRunTask<T> {
|
||||
const task = tasks.find(
|
||||
(candidate) =>
|
||||
candidate.prompt === options.prompt &&
|
||||
candidate.parentToolCallId === options.parentToolCallId,
|
||||
);
|
||||
if (task === undefined) {
|
||||
throw new Error(`No mock queued task for prompt "${options.prompt}"`);
|
||||
}
|
||||
return task as QueuedAgentRunTask<T>;
|
||||
}
|
||||
|
||||
function mockAgentRunId(task: QueuedAgentRunTask<unknown>, attemptIndex: number): string {
|
||||
if (typeof task.data === 'number') return `agent-${String(task.data)}`;
|
||||
return `agent-${String(attemptIndex + 1)}`;
|
||||
}
|
||||
|
||||
function completionFromMockAgentRunOutcome<T>(
|
||||
outcome: ReturnType<typeof createControlledPromise<MockAgentRunAttemptOutcome<T>>>,
|
||||
signal: AbortSignal,
|
||||
): AgentRunAttemptHandle['completion'] {
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => {
|
||||
reject(signal.reason ?? new Error('Aborted'));
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
outcome.then(
|
||||
(result) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (isMockAgentRunRateLimitOutcome(result)) {
|
||||
reject(new APIProviderRateLimitError('Rate limited', result.agentId));
|
||||
return;
|
||||
}
|
||||
if (result.status === 'completed') {
|
||||
resolve({ result: result.result ?? '', usage: result.usage });
|
||||
return;
|
||||
}
|
||||
reject(new Error(result.error ?? result.status));
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isMockAgentRunRateLimitOutcome<T>(
|
||||
outcome: MockAgentRunAttemptOutcome<T>,
|
||||
): outcome is Extract<MockAgentRunAttemptOutcome<T>, { readonly type: 'rate_limited' }> {
|
||||
return 'type' in outcome && outcome.type === 'rate_limited';
|
||||
}
|
||||
|
||||
function queuedAgentRunTask(index: number): QueuedAgentRunTask<number> {
|
||||
return {
|
||||
kind: 'spawn',
|
||||
data: index,
|
||||
profileName: 'coder',
|
||||
parentToolCallId: 'call_swarm',
|
||||
prompt: `Review item-${String(index)}`,
|
||||
description: `Review #${String(index)}`,
|
||||
runInBackground: false,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ function createAgentTaskService(options: {
|
|||
launched: Promise.resolve(undefined),
|
||||
});
|
||||
const context = ctx.get(IAgentContextMemoryService);
|
||||
const spliceHistorySpy = vi.spyOn(context, 'splice');
|
||||
const appendHistorySpy = vi.spyOn(context, 'append');
|
||||
|
||||
const agent: FakeTaskAgent = {
|
||||
emittedEvents,
|
||||
|
|
@ -224,7 +224,7 @@ function createAgentTaskService(options: {
|
|||
? undefined
|
||||
: { task: { maxRunningTasks: options.maxRunningTasks } },
|
||||
telemetry: { track },
|
||||
context: { appendUserMessage: spliceHistorySpy },
|
||||
context: { appendUserMessage: appendHistorySpy },
|
||||
turn: { steer: steerSpy },
|
||||
hooks: options.hooks,
|
||||
};
|
||||
|
|
@ -254,12 +254,8 @@ async function cleanupSessionDir(
|
|||
}
|
||||
|
||||
function firstAppendedContextMessage(agent: FakeTaskAgent): TestContextMessage {
|
||||
const call = agent.context.appendUserMessage.mock.calls[0] as unknown as [
|
||||
number,
|
||||
number,
|
||||
readonly TestContextMessage[],
|
||||
];
|
||||
const message = call[2].at(-1);
|
||||
const call = agent.context.appendUserMessage.mock.calls[0] as unknown as TestContextMessage[];
|
||||
const message = call.at(-1);
|
||||
if (message === undefined) throw new Error('Expected an appended context message');
|
||||
return message;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
|||
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { ITaskService } from '#/app/task/task';
|
||||
|
||||
import { stubContextMemory, stubWireRecord } from '../contextMemory/stubs';
|
||||
|
||||
|
|
@ -54,6 +56,18 @@ describe('AgentTaskService', () => {
|
|||
ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IAgentWireRecordService, stubWireRecord());
|
||||
ix.stub(IAgentWireService, stubWireService());
|
||||
ix.stub(IEventBus, {
|
||||
publish: () => {},
|
||||
subscribe: () => toDisposable(() => {}),
|
||||
});
|
||||
ix.stub(ITaskService, {
|
||||
run: () => {
|
||||
throw new Error('ITaskService.run is not used by this test');
|
||||
},
|
||||
defer: () => {
|
||||
throw new Error('ITaskService.defer is not used by this test');
|
||||
},
|
||||
});
|
||||
ix.stub(IAgentContextMemoryService, stubContextMemory());
|
||||
ix.stub(ITelemetryService, { track: () => {} });
|
||||
ix.stub(IAgentToolRegistryService, {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -129,6 +129,45 @@ describe('AgentUserToolService (wire-backed)', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('inherits currently registered parent user tools into another agent service', async () => {
|
||||
svc.register(toolA);
|
||||
svc.register(toolB);
|
||||
svc.unregister(toolB.name);
|
||||
|
||||
const ixChild = disposables.add(new TestInstantiationService());
|
||||
ixChild.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ixChild.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ixChild.set(
|
||||
IAgentWireService,
|
||||
new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: 'user-tool-child' }]),
|
||||
);
|
||||
ixChild.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
|
||||
const childProfile = createProfileStub();
|
||||
ixChild.stub(IAgentProfileService, childProfile);
|
||||
ixChild.stub(ISessionInteractionService, createInteractionStub());
|
||||
ixChild.set(IAgentUserToolService, new SyncDescriptor(AgentUserToolService));
|
||||
|
||||
const child = ixChild.get(IAgentUserToolService);
|
||||
const childWire = ixChild.get(IAgentWireService);
|
||||
const childRegistry = ixChild.get(IAgentToolRegistryService);
|
||||
child.inheritUserTools(svc);
|
||||
|
||||
expect(child.list()).toEqual([toolA]);
|
||||
expect(modelOf(childWire).get(toolA.name)).toEqual(toolA);
|
||||
expect(modelOf(childWire).has(toolB.name)).toBe(false);
|
||||
expect(childRegistry.resolve(toolA.name)).toBeDefined();
|
||||
expect(childProfile.active.has(toolA.name)).toBe(true);
|
||||
expect(childProfile.active.has(toolB.name)).toBe(false);
|
||||
|
||||
const childRecords: PersistedRecord[] = [];
|
||||
for await (const record of ixChild
|
||||
.get(IAppendLogStore)
|
||||
.read<PersistedRecord>(SCOPE, 'user-tool-child')) {
|
||||
childRecords.push(record);
|
||||
}
|
||||
expect(childRecords).toEqual([{ type: 'tools.register_user_tool', ...toolA }]);
|
||||
});
|
||||
|
||||
it('re-registering an equal tool is a no-op on the model (same reference)', () => {
|
||||
svc.register(toolA);
|
||||
const before = modelOf(wire);
|
||||
|
|
|
|||
|
|
@ -518,22 +518,19 @@ describe('Agent resume', () => {
|
|||
message.origin.taskId === 'agent-new00000',
|
||||
),
|
||||
).toBe(true);
|
||||
// The newly delivered notification is persisted as a v1.5
|
||||
// `context.splice` (append) record, not the legacy
|
||||
// `context.append_message`.
|
||||
// The newly delivered notification is persisted through the current
|
||||
// context append primitive.
|
||||
expect(persistence.appended).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'context.splice',
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
origin: {
|
||||
kind: 'task',
|
||||
taskId: 'agent-new00000',
|
||||
status: 'completed',
|
||||
notificationId: 'task:agent-new00000:completed',
|
||||
},
|
||||
}),
|
||||
]),
|
||||
type: 'context.append_message',
|
||||
message: expect.objectContaining({
|
||||
origin: {
|
||||
kind: 'task',
|
||||
taskId: 'agent-new00000',
|
||||
status: 'completed',
|
||||
notificationId: 'task:agent-new00000:completed',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue