Preserve task execution, process cleanup and tool permission boundaries

Preserve the reviewed user-facing behavior, public-edition boundaries and owning regression coverage.

Prompt audit: Keep existing permission, ownership and recovery contracts at their current tool, runtime or test owner (Hard constraint). No separate resident instruction is added. Drop excluded hosted-only branches; retain shared side effects and tests. No optional prompt rewrite.

Prompt confirmation: The requester confirmed the exact proposed final behaviors and existing resource placements by directing that the primary implementation be followed, then explicitly requested this PR and merge. Preserve that confirmed scope only.

- Keep: The behavior and placement detailed in the audit above. Type: Hard constraint. Why: Preserve its existing canonical owner without duplicated resident instructions.
- Drop: Superseded overlapping wording and excluded hosted-only behavior. Type: Hard constraint. Why: Preserve the confirmed public capability and authority boundary.

Confirmed-by: Requester, exact final-state proposal and subsequent delivery request.
Scope: The previously confirmed final behavior and existing placement described above; no optional prompt rewrite.
This commit is contained in:
wyt committed 2026-11-04 23:22:45 +08:00
1 parent 5d1d78e8bb
commit 120c4c22db
63 files changed
+4287 -2170

No files matched your search

+11 -4
View File
@@ -78,6 +78,8 @@ export class PersistentSession extends Session {
* instead of rewriting every retained turn. */
private messageStartOffsets: number[] = [];
private sessionFileSize = 0;
// Logical queued tail state, captured with each ordered write for retry.
private needsAppendSeparator = false;
// Capture ordered disk operations, not the trimmed model/history window.
// Runtime barriers stop new work while this queue cannot be drained.
private pendingWrites: Array<() => void> = [];
@@ -180,6 +182,7 @@ export class PersistentSession extends Session {
super.clear();
this.messageStartOffsets = [];
this.sessionFileSize = 0;
this.needsAppendSeparator = false;
if (!fs.existsSync(this.sessionFile)) {
// Context can legitimately exist before the first transcript row (for
// example, a scoped tool surface is established while building the
@@ -192,6 +195,7 @@ export class PersistentSession extends Session {
try {
raw = fs.readFileSync(this.sessionFile);
this.sessionFileSize = raw.length;
this.needsAppendSeparator = raw.length > 0 && raw[raw.length - 1] !== 0x0a;
} catch (err) {
log.warn("session read failed", { code: errorCodeForLog(err) });
this.loadContextFromDisk();
@@ -225,8 +229,8 @@ export class PersistentSession extends Session {
// Skip corrupt line — keep going rather than throwing away everything.
}
};
for (let index = 0; index < raw.length; index++) {
if (raw[index] === 0x0a) loadLine(index);
for (let lineEnd = raw.indexOf(0x0a); lineEnd !== -1; lineEnd = raw.indexOf(0x0a, lineStart)) {
loadLine(lineEnd);
}
if (lineStart < raw.length) loadLine(raw.length);
@@ -655,9 +659,11 @@ export class PersistentSession extends Session {
/** Capture the expected byte offset so partial appends can be retried without
* duplicating a complete row or joining a new row onto a torn write. */
private appendToDisk(record: Message): void {
const line = JSON.stringify({ ...record, ts: Date.now() }) + "\n";
const separator = this.needsAppendSeparator ? "\n" : "";
const line = separator + JSON.stringify({ ...record, ts: Date.now() }) + "\n";
const start = this.sessionFileSize;
this.messageStartOffsets.push(start);
this.needsAppendSeparator = false;
this.messageStartOffsets.push(start + separator.length);
this.sessionFileSize += Buffer.byteLength(line, "utf-8");
let attempted = false;
this.enqueueWrite(() => {
@@ -725,6 +731,7 @@ export class PersistentSession extends Session {
offset += Buffer.byteLength(line, "utf-8");
}
this.sessionFileSize = offset;
this.needsAppendSeparator = false;
}
/** Explicit full replacement (rebase/reset/heal), never an append retry. */
+33 -7
View File
@@ -15,6 +15,7 @@ import type {
import {
AuthError,
ContextOverflowError,
ProviderCandidateChangedError,
classifyRetryableError,
isProviderRateLimitError,
isRetryableError,
@@ -1008,7 +1009,7 @@ function outputLimitToolRetryControl(maxChunkChars: number): string {
return [
"The preceding response reached the output-token limit before completing a valid tool call. No tool call from that response was executed or saved.",
"Retry the intended action now as one complete, concise tool call; do not repeat explanatory prose before it.",
`For a long new text file, keep this file-content chunk at or below ${maxChunkChars} characters, use write_file only for the first chunk, and copy its returned revision into append_file.base_revision for later chunks (expected_size is legacy fallback only).`,
`For a long new text file, keep this file-content chunk at or below ${maxChunkChars} characters, use write_file only for the first chunk, and copy its returned revision into append_file.base_revision for later chunks.`,
"For an existing file, prefer a targeted edit_file or apply_patch call instead of rewriting the whole file.",
].join("\n");
}
@@ -1702,6 +1703,9 @@ export class AgentRunner {
// blocks. Total recoveries stay bounded by the tool-loop budget (each
// re-arm requires a completed call, and those are capped).
let overflowRecoveryAttempted = false;
// One re-preparation when the provider's first candidate changes after
// preparation; re-armed by a completed call like overflow recovery.
let candidateRepreparationAttempted = false;
const toolNamesSet = new Set<string>();
const skillsLoadedSet = new Set<string>();
let transientToolErrors = 0;
@@ -1887,7 +1891,11 @@ export class AgentRunner {
});
}
const historyModel = this.modelCatalogEntry(modelId, servedModelId);
// A provider that knows its next candidate names it, so a run that had
// to use a larger fallback once compacts back onto the earlier model
// instead of staying on the fallback for every later round.
const callBudgetModel = provider.contextBudgetModel?.() ?? servedModelId;
const historyModel = this.modelCatalogEntry(modelId, callBudgetModel);
if (historyModel?.contextWindow && Number.isFinite(historyModel.contextWindow)) {
const usable = historyModel.contextWindow - (historyModel.maxOutputTokens ?? 8_192);
this.session.configureHistoryBudget(usable, Math.floor(usable * CONTEXT_COMPACTION_TRIGGER_RATIO),
@@ -1903,9 +1911,9 @@ export class AgentRunner {
systemPrompt,
toolDefs,
budgetEphemeral || undefined,
servedModelId,
callBudgetModel,
);
const callUsableInputTokens = this.resolveUsableInputTokens(modelId, servedModelId);
const callUsableInputTokens = this.resolveUsableInputTokens(modelId, callBudgetModel);
// In-memory scalar evidence only; the provider emits it on failure.
const contextBudgetDiagnostics: Record<string, number | string> = {
version: 1,
@@ -2028,7 +2036,7 @@ export class AgentRunner {
this.session.contentEpoch(),
this.session.getEstimatorCalibration(),
);
const measuredModel = servedModelId || modelId;
const measuredModel = callBudgetModel || modelId;
Object.assign(contextBudgetDiagnostics, {
estimated_input_tokens: preparedTokens.tokens, estimate_source: preparedTokens.source,
});
@@ -2210,6 +2218,7 @@ export class AgentRunner {
// growth with fresh foldable content: re-arm the one-shot recovery
// (see the declaration comment for the bound).
overflowRecoveryAttempted = false;
candidateRepreparationAttempted = false;
// Fall back to a text-only content block if the provider didn't
// include `content` in message_end (older providers / custom stream
@@ -3275,6 +3284,23 @@ export class AgentRunner {
return;
}
// Nothing reached the stream, so preparing again is safe: compaction
// now targets the candidate the provider will offer next. It does not
// consume a provider retry slot; a repeat surfaces the original cause.
if (err instanceof ProviderCandidateChangedError) {
if (!candidateRepreparationAttempted) {
candidateRepreparationAttempted = true;
log.warn("provider candidate changed after preparation; preparing again", {
sessionId: this.session.getSessionId(),
model: modelId,
cause: logErrorRef(err.cause ?? err),
});
attempt--;
continue;
}
err = err.cause ?? err;
}
if (err instanceof AuthError) {
const e = this.errorResult(startTime, modelId, provider.id, {
kind: "auth",
@@ -3285,14 +3311,14 @@ export class AgentRunner {
return;
}
if (err instanceof ContextOverflowError
if (err instanceof ContextOverflowError
|| (err instanceof Error && err.name === "ContextOverflowError")) {
// Candidate admission already checked the unchanged request against
// every available window. This is not an upstream estimation miss:
// folding Session or retrying would defeat the read-only skip contract.
if ((err as ContextOverflowError).admissionChecked) {
const e = this.errorResult(startTime, modelId, provider.id, {
kind: "context_overflow", message: err.message, code: (err as ContextOverflowError).code,
kind: "context_overflow", message: err.message, code: "CONTEXT_CAPACITY_INSUFFICIENT",
}, lastUsage, toolLoops, compactionCount, false, [...toolNamesSet], [...skillsLoadedSet], transientToolErrors, permanentToolErrors, finalizedRunTimings(startTime, timings), convergenceSignals());
yield { type: "done", result: e };
return;
+2
View File
@@ -22,6 +22,7 @@ export {
AuthError,
RateLimitError,
ContextOverflowError,
ProviderCandidateChangedError,
OutputLimitError,
ProviderError,
StorageFullError,
@@ -120,6 +121,7 @@ export {
processStartTool,
processStopTool,
processWriteTool,
stopAllProcessSessions,
workspaceDiffTool,
WORKSPACE_DIFF_PROVIDER_STATE_KEY,
defineTool,
+10 -3
View File
@@ -25,9 +25,10 @@ export type ToolDefinition = {
/** Parameters for an LLM completion request. */
export type CompletionParams = {
/** Request-local measurement from core preparation. Input references identify
* the unchanged request view; transformed inputs and foreign candidates must
* estimate afresh. Cloneable bookkeeping, never sent to an API or persisted. */
/** Request-local measurement from core preparation. `model` is the configured
* model preparation priced for; input references identify the unchanged
* request view. Transformed inputs and other candidates must estimate afresh.
* Cloneable bookkeeping, never sent to an API or persisted. */
requestTokenEstimate?: {
model: string;
messages: Message[];
@@ -116,6 +117,12 @@ export interface LLMProvider {
/** Test whether this provider's credentials are valid. */
validateAuth(): Promise<boolean>;
/** Optional, read-only: the configured model the next request will be
* offered to first. Preparation prices its context budget against this
* model's catalog window and labels its measurement with it. Absent means
* the last model the provider reported serving. */
contextBudgetModel?(): string | undefined;
}
/** Provider factory function. */
+8 -45
View File
@@ -16,7 +16,7 @@ import { createHash } from "node:crypto";
import { createLogger } from "../shared/logger.js";
import { errorCodeForLog } from "../shared/errors.js";
import { blockedCommand } from "./command-policy.js";
import { windowsTreeCleanupCommand } from "./windows-process-tree.js";
import { queueWindowsTreeCleanup } from "./windows-cleanup-batch.js";
import { compilePowerShellChain, parsePowerShellChain } from "./powershell-chain.js";
import {
ProcessOutputCapture,
@@ -81,7 +81,7 @@ export interface SandboxResult {
/** Optional managed continuation; ordinary execute callers retain closed stdin. */
export interface SandboxContinuation {
onStart(control: { write(chars: string): void; stop(): void }): void;
onStart(control: { pid?: number; write(chars: string): void; stop(): void; kill(): void }): void;
onOutput(bytes: Buffer, stream: "stdout" | "stderr", env: NodeJS.ProcessEnv): void;
}
@@ -114,11 +114,6 @@ export function formatProcessStartFailure(error: unknown): string {
+ "The command was not executed; retry once or use another available tool.";
}
function windowsSystem32Tool(name: string): string {
const root = process.env.SystemRoot || process.env.WINDIR || "C:\\Windows";
return path.win32.join(root, "System32", name);
}
export function killProcessTree(
child: KillableChild,
signal: NodeJS.Signals = "SIGTERM",
@@ -138,12 +133,7 @@ export function killProcessTree(
opts.onComplete?.();
return;
}
let completed = false;
let helperOutput = "";
let authorized = false;
const complete = (failed: boolean) => {
if (completed) return;
completed = true;
queueWindowsTreeCleanup(child, KILL_GRACE_MS, opts.spawnFn ?? spawn, (failed, terminated) => {
if (failed) {
log.warn("Shell process tree termination failed", {
platform: "win32", fallback: "direct_child_only",
@@ -152,39 +142,9 @@ export function killProcessTree(
try { child.kill(signal); } catch { /* Process may already be dead. */ }
}
}
if (!failed && authorized && helperOutput.replace(/\r\n/g, "\n") === "ready\nroot-terminated\n") {
opts.onRootTerminated?.();
}
if (terminated) opts.onRootTerminated?.();
opts.onComplete?.();
};
try {
const killer = (opts.spawnFn ?? spawn)(windowsSystem32Tool("WindowsPowerShell\\v1.0\\powershell.exe"), [
"-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", windowsTreeCleanupCommand(child.pid),
], {
stdio: ["pipe", "pipe", "ignore"],
windowsHide: true,
timeout: KILL_GRACE_MS,
});
// The helper pins the PID first. Only authorize it while the original
// ChildProcess is still live; a late helper must not target a reused PID.
let acknowledged = false;
killer.stdin?.on("error", () => undefined);
killer.stdout?.on("data", (data: Buffer) => {
helperOutput = (helperOutput + data.toString("utf8")).slice(0, 64);
if (acknowledged || !helperOutput.includes("\n")) return;
acknowledged = true;
const owned = helperOutput.split(/\r?\n/, 1)[0] === "ready" && child.exitCode == null && child.signalCode == null;
authorized = owned;
killer.stdin?.end(owned ? "go\n" : "cancel\n");
});
killer.once("error", () => complete(true));
// Exit can precede the final pipe data; close owns the complete receipt.
killer.once("close", (code) => complete(code !== 0 &&
(authorized || (child.exitCode == null && child.signalCode == null))));
if (typeof killer.unref === "function") killer.unref();
} catch {
complete(true);
}
});
return;
}
@@ -894,11 +854,14 @@ export class SandboxExecutor {
}
if (continuation) {
continuation.onStart({
pid: child.pid,
write: (chars) => {
if (settled || killed || child.stdin.destroyed) throw new Error("Process is not running");
child.stdin.write(chars, "utf8");
},
stop: () => { aborted = true; killChild(); },
// Host exit cannot rely on killChild's escalation timer, which dies with the host.
kill: () => killProcessTree(child, "SIGKILL"),
});
} else child.stdin.end();
@@ -0,0 +1,119 @@
import { spawn, type ChildProcess } from "node:child_process";
import path from "node:path";
import { windowsTreeCleanupCommand } from "./windows-process-tree.js";
type OwnedChild = Pick<ChildProcess, "pid"> & Partial<Pick<ChildProcess, "exitCode" | "signalCode">>;
type Request = {
child: OwnedChild; deadline: number; timer: ReturnType<typeof setTimeout>;
authorized: boolean; ready: boolean; receipt?: boolean; failed: boolean; settled: boolean;
finish(failed: boolean, terminated: boolean): void;
};
type Batch = { requests: Request[]; accepting: boolean; closed: boolean; helper?: ChildProcess };
// Share only cold startup. The manifest seals admission, so later cancellation
// cannot add work ahead of roots already being swept or renew their lifetime.
const starting = new WeakMap<typeof spawn, Batch>();
const MAX_BATCH_ROOTS = 64;
const MAX_RECEIPT_BYTES = 8192;
const live = (child: OwnedChild) => child.exitCode == null && child.signalCode == null;
export function queueWindowsTreeCleanup(
child: OwnedChild, timeoutMs: number, spawnFn: typeof spawn,
onComplete: (failed: boolean, terminated: boolean) => void,
): void {
if (!Number.isSafeInteger(child.pid) || child.pid! <= 0) { onComplete(true, false); return; }
const deadline = Date.now() + timeoutMs;
let batch = starting.get(spawnFn);
const fresh = !batch || !batch.accepting || batch.requests.length >= MAX_BATCH_ROOTS;
if (fresh) {
batch = { requests: [], accepting: true, closed: false };
starting.set(spawnFn, batch);
}
const owner = batch!;
const seal = () => {
owner.accepting = false;
if (starting.get(spawnFn) === owner) starting.delete(spawnFn);
};
const request: Request = {
child, deadline, authorized: false, ready: false, failed: false, settled: false,
timer: undefined as unknown as ReturnType<typeof setTimeout>,
finish(failed, terminated) {
if (request.settled) return;
request.settled = true;
clearTimeout(request.timer);
// I/O can be dispatched before an already-due timer after a busy turn.
// A late success receipt must not bypass the request's deadline.
failed ||= Date.now() >= request.deadline;
onComplete(failed && (request.authorized || live(child)), !failed && request.authorized && terminated);
},
};
owner.requests.push(request);
request.timer = setTimeout(() => {
// A wedged startup cannot stay open through a stream of later arrivals.
seal();
request.finish(true, false);
if (owner.requests.every(item => item.settled)) {
seal();
try { owner.helper?.kill(); } catch { /* Already exited. */ }
}
}, Math.max(0, deadline - Date.now()));
request.timer.unref?.();
if (!fresh) return;
let buffered = "", receivedBytes = 0, manifestSent = false, invalid = false;
const complete = (failed: boolean) => {
if (owner.closed) return;
owner.closed = true;
seal();
for (const item of owner.requests) item.finish(failed || item.receipt === undefined, item.receipt === true);
owner.helper?.stdin?.destroy();
};
const fail = () => {
invalid = true;
complete(true);
try { owner.helper?.kill(); } catch { /* Already exited. */ }
};
try {
const systemRoot = process.env.SystemRoot || process.env.WINDIR || "C:\\Windows";
const helper = spawnFn(path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), [
"-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", windowsTreeCleanupCommand(),
], { stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
owner.helper = helper;
helper.stdin?.on("error", fail);
helper.stdout?.on("data", (data: Buffer) => {
if (owner.closed) return;
receivedBytes += data.length;
if (receivedBytes > MAX_RECEIPT_BYTES) { fail(); return; }
buffered += data.toString("utf8");
let end: number;
while ((end = buffered.indexOf("\n")) >= 0) {
const line = buffered.slice(0, end).replace(/\r$/, "");
buffered = buffered.slice(end + 1);
if (line === "accepting" && !manifestSent) {
seal();
manifestSent = true;
helper.stdin?.write(owner.requests.map((item, id) => `${id},${item.child.pid},${item.deadline}`).join(";") + "\n");
continue;
}
const match = /^(ready|done|failed):(\d+)(?::([01]))?$/.exec(line);
const item = match ? owner.requests[Number(match[2])] : undefined;
if (!manifestSent || !match || !item || item.failed || invalid) { fail(); return; }
if (match[1] === "ready" && match[3] === undefined && !item.ready && item.receipt === undefined) {
item.ready = true;
item.authorized = !item.settled && Date.now() < item.deadline && live(item.child);
helper.stdin?.write(`${item.authorized ? "go" : "cancel"}:${match[2]}\n`);
} else if (match[1] === "done" && match[3] !== undefined && item.ready && item.receipt === undefined) {
item.receipt = match[3] === "1";
} else if (match[1] === "failed" && match[3] === undefined && item.receipt === undefined) {
// Keep the deadline armed until close; an error receipt must not
// leave a wedged helper alive with no remaining watchdog.
item.failed = true;
} else { fail(); return; }
}
});
helper.once("error", fail);
// Exit may precede final pipe data; close owns the complete receipt.
helper.once("close", (code) => complete(code !== 0 || invalid || buffered.length !== 0));
// Completion belongs to the helper's close event. Keep that process handle
// referenced until the bounded sweep settles, including after its pipes EOF.
} catch { fail(); }
}
@@ -1,18 +1,14 @@
/** Cancellation-only Windows helper. Open handles pin identities even after
* exit; the host must acknowledge readiness while its original child is live.
* This avoids both taskkill's snapshot/child-creation race and PID reuse.
* No helper runs on the normal command path, and no native payload is shipped.
/** Cancellation-only helper. Handles and creation times pin each tree
* independently; cold PowerShell startup and WMI snapshots are shared.
* No helper runs on the healthy path and no native payload is shipped.
*/
export function windowsTreeCleanupCommand(pid: number): string {
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Invalid process identity");
return Buffer.from(`$targetProcessId = ${pid}\n` + SCRIPT, "utf16le").toString("base64");
export function windowsTreeCleanupCommand(): string {
return Buffer.from(SCRIPT, "utf16le").toString("base64");
}
const SCRIPT = String.raw`
function Read-ProcessSnapshot {
# Older processes cannot descend from the pinned root. Filter at the WMI
# boundary so concurrent cancellations do not materialize every host process.
$since = [System.Management.ManagementDateTimeConverter]::ToDmtfDateTime($owned[$targetProcessId].Started)
$since = [System.Management.ManagementDateTimeConverter]::ToDmtfDateTime($earliest)
$query = [System.Management.ManagementObjectSearcher]::new("SELECT ProcessId,ParentProcessId,CreationDate FROM Win32_Process WHERE CreationDate >= '$since'")
$rows = $null
try {
@@ -31,98 +27,134 @@ function Read-ProcessSnapshot {
$query.Dispose()
}
}
function Check-Deadline($tree) {
if ([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() -ge $tree.Deadline) { throw 'cleanup_deadline' }
}
function Stop-Entry($entry, $tree) {
Check-Deadline $tree
try {
if (-not $entry.Process.HasExited) { $entry.Process.Kill() }
} catch { if (-not $entry.Process.HasExited) { throw } }
}
$ErrorActionPreference = 'Stop'
$owned = @{}
$ordered = [Collections.Generic.List[object]]::new()
$result = 1
$rootTerminated = $false
$trees = [Collections.Generic.List[object]]::new()
$earliest = [DateTime]::MaxValue
try {
$root = [Diagnostics.Process]::GetProcessById($targetProcessId)
$null = $root.Handle
$owned[$targetProcessId] = @{ Process = $root; Started = $root.StartTime.ToUniversalTime() }
$ordered.Add($owned[$targetProcessId])
[Console]::Out.WriteLine('ready')
[Console]::Out.WriteLine('accepting')
[Console]::Out.Flush()
if ([Console]::In.ReadLine() -cne 'go') { $result = 0; return }
# Stop the root immediately, before WMI startup can delay cancellation.
# Its held handle keeps the parent identity valid for orphan discovery.
if (-not $root.HasExited) {
$manifest = [Console]::In.ReadLine()
foreach ($spec in $manifest.Split(';')) {
$parts = $spec.Split(',')
$targetProcessId = [int]$parts[1]
$tree = @{ Id = [int]$parts[0]; Deadline = [long]$parts[2]; Owned = @{};
Ordered = [Collections.Generic.List[object]]::new(); Result = 1; Terminated = $false; Active = $false }
$trees.Add($tree)
$root = $null
try {
$root.Kill()
$rootTerminated = $true
Check-Deadline $tree
$root = [Diagnostics.Process]::GetProcessById($targetProcessId)
$null = $root.Handle
$entry = @{ Process = $root; Started = $root.StartTime.ToUniversalTime() }
$tree.Owned[$targetProcessId] = $entry
$tree.Ordered.Add($entry)
[Console]::Out.WriteLine('ready:' + $tree.Id)
[Console]::Out.Flush()
if ([Console]::In.ReadLine() -cne ('go:' + $tree.Id)) { $tree.Result = 0; continue }
Check-Deadline $tree
# Stop the root immediately, before WMI startup can delay cancellation.
if (-not $root.HasExited) {
try { $root.Kill(); $tree.Terminated = $true }
catch { if (-not $root.HasExited) { throw } }
}
$tree.Active = $true
if ($entry.Started -lt $earliest) { $earliest = $entry.Started }
} catch {
# A natural exit between HasExited and Kill is not our termination.
if (-not $root.HasExited) { throw }
if ($null -ne $root -and $tree.Owned.Count -eq 0) { $root.Dispose() }
}
}
if (-not $root.WaitForExit(500)) { throw 'termination_incomplete' }
$rows = @(Read-ProcessSnapshot)
for ($pass = 0; $pass -lt 4; $pass++) {
# Hold every discoverable identity before killing parents. Old or reused
# parent ids are rejected by birth time; WMI truncates it to microseconds.
do {
$added = 0
foreach ($row in $rows) {
$id = [int]$row.ProcessId
$parent = $owned[[int]$row.ParentProcessId]
if ($null -eq $parent -or $owned.ContainsKey($id)) { continue }
if ($row.CreationDate.ToUniversalTime() -lt $parent.Started) { continue }
$candidate = $null
# Issue all root kills before waiting. Sequential Kill/Wait pairs multiply
# shell exit latency across the batch and can consume the cleanup deadline.
foreach ($tree in $trees) {
if (-not $tree.Active) { continue }
try {
Check-Deadline $tree
if (-not $tree.Ordered[0].Process.WaitForExit(500)) { throw 'termination_incomplete' }
} catch { $tree.Active = $false }
}
if ($earliest -ne [DateTime]::MaxValue) {
$rows = @(Read-ProcessSnapshot)
for ($pass = 0; $pass -lt 4; $pass++) {
foreach ($tree in $trees) {
if (-not $tree.Active) { continue }
try {
$candidate = [Diagnostics.Process]::GetProcessById($id)
$null = $candidate.Handle
$started = $candidate.StartTime.ToUniversalTime()
$ticks = $started.Ticks - ($started.Ticks % 10)
if ($ticks -ne $row.CreationDate.ToUniversalTime().Ticks) { continue }
$owned[$id] = @{ Process = $candidate; Started = $started }
$ordered.Add($owned[$id])
$candidate = $null
$added++
} catch [ArgumentException] {
# The snapshot member exited before its handle could be acquired.
} finally {
if ($null -ne $candidate) { $candidate.Dispose() }
}
Check-Deadline $tree
$owned = $tree.Owned
do {
$added = 0
foreach ($row in $rows) {
$id = [int]$row.ProcessId
$parent = $owned[[int]$row.ParentProcessId]
if ($null -eq $parent -or $owned.ContainsKey($id)) { continue }
if ($row.CreationDate -lt $parent.Started) { continue }
Check-Deadline $tree
$candidate = $null
try {
$candidate = [Diagnostics.Process]::GetProcessById($id)
$null = $candidate.Handle
$started = $candidate.StartTime.ToUniversalTime()
$ticks = $started.Ticks - ($started.Ticks % 10)
if ($ticks -ne $row.CreationDate.Ticks) { continue }
$owned[$id] = @{ Process = $candidate; Started = $started }
$tree.Ordered.Add($owned[$id])
$candidate = $null
$added++
} catch [ArgumentException] {
# Snapshot member exited before its handle could be acquired.
} finally { if ($null -ne $candidate) { $candidate.Dispose() } }
}
} while ($added -gt 0)
if ($tree.Ordered.Count -eq 1) { $tree.Result = 0; $tree.Active = $false; continue }
# Parent-first order stops producers; held handles retain lineage for
# children born after this snapshot, without accepting recycled PIDs.
foreach ($entry in $tree.Ordered) { Stop-Entry $entry $tree }
} catch { $tree.Active = $false }
}
} while ($added -gt 0)
# The pinned root has already exited. If the first snapshot contains no
# live descendants, no owned producer can create another child. Avoid a
# redundant WMI query in this common shell-startup cancellation path.
if ($ordered.Count -eq 1) { $result = 0; break }
# Stop producers first, retaining their handles for the next snapshot.
# A child created during this sweep remains linked to a pinned parent.
# Discovery only adds a child after its parent, so this order already
# stops producers first without a sort or PowerShell module auto-loading.
foreach ($entry in $ordered) {
try {
if (-not $entry.Process.HasExited) { $entry.Process.Kill() }
if (-not $entry.Process.WaitForExit(500)) { throw 'termination_incomplete' }
} catch {
if (-not $entry.Process.HasExited) { throw }
# All known producers have received termination before waiting for any
# one's exit. Keep every identity pinned until the verification snapshot.
foreach ($tree in $trees) {
if (-not $tree.Active) { continue }
try {
foreach ($entry in $tree.Ordered) {
Check-Deadline $tree
if (-not $entry.Process.WaitForExit(500)) { throw 'termination_incomplete' }
}
} catch { $tree.Active = $false }
}
$active = @($trees | Where-Object { $_.Active })
if ($active.Count -eq 0) { break }
$rows = @(Read-ProcessSnapshot)
foreach ($tree in $active) {
$remaining = $false
foreach ($row in $rows) {
$parent = $tree.Owned[[int]$row.ParentProcessId]
if ($null -ne $parent -and -not $tree.Owned.ContainsKey([int]$row.ProcessId) -and $row.CreationDate -ge $parent.Started) {
$remaining = $true; break
}
}
if (-not $remaining) { $tree.Result = 0; $tree.Active = $false }
}
}
$rows = @(Read-ProcessSnapshot)
$remaining = @($rows | Where-Object {
$parent = $owned[[int]$_.ParentProcessId]
$null -ne $parent -and -not $owned.ContainsKey([int]$_.ProcessId) -and
$_.CreationDate.ToUniversalTime() -ge $parent.Started
})
if ($remaining.Count -eq 0) { $result = 0; break }
}
} catch {
# Only the classified exit code crosses the boundary, never raw WMI errors.
$result = 1
# Incomplete trees keep failure; never emit raw WMI errors or private paths.
} finally {
foreach ($entry in $owned.Values) { $entry.Process.Dispose() }
}
if ($result -eq 0 -and $rootTerminated) {
# Private helper evidence, never command output. Publish only after the
# pinned root's Kill/WaitForExit and the complete descendant sweep succeed.
[Console]::Out.WriteLine('root-terminated')
foreach ($tree in $trees) {
foreach ($entry in $tree.Owned.Values) { $entry.Process.Dispose() }
if ($tree.Result -eq 0) {
[Console]::Out.WriteLine('done:' + $tree.Id + ':' + [int]$tree.Terminated)
} else { [Console]::Out.WriteLine('failed:' + $tree.Id) }
}
[Console]::Out.Flush()
}
exit $result
exit 0
`;
+10
View File
@@ -36,6 +36,16 @@ export class ContextOverflowError extends CoreAgentError {
}
}
/** The provider's first candidate changed after this request was prepared, and
* the new one was skipped only for capacity. Core prepares the same request
* once more for it; `cause` is the failure to surface if that cannot help. */
export class ProviderCandidateChangedError extends CoreAgentError {
constructor(message: string, cause?: Error) {
super(message, "PROVIDER_CANDIDATE_CHANGED", cause);
this.name = "ProviderCandidateChangedError";
}
}
export class OutputLimitError extends CoreAgentError {
constructor(message: string, cause?: Error) {
super(message, "OUTPUT_LIMIT", cause);
+1 -6
View File
@@ -217,19 +217,14 @@ function textFileHash(content: string): string {
*/
export const DEFAULT_BASH_TIMEOUT_MS = 60 * 60_000;
export const BASH_PROGRESS_INTERVAL_MS = 60_000;
const LEGACY_DEFAULT_BASH_TIMEOUTS_MS = new Set([30_000, 300_000]);
export function normalizeBashTimeoutMs(
value: unknown,
opts: { legacyDefault?: boolean } = {},
): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return DEFAULT_BASH_TIMEOUT_MS;
}
const timeoutMs = Math.round(value);
return opts.legacyDefault && LEGACY_DEFAULT_BASH_TIMEOUTS_MS.has(timeoutMs)
? DEFAULT_BASH_TIMEOUT_MS
: timeoutMs;
return Math.round(value);
}
// Sequential by default: the core package has no way to prove a command
+30 -3
View File
@@ -7,7 +7,10 @@ import { isProgrammaticToolCallContext } from "./run-program.js";
const OUTPUT_CHARS = 1_000_000;
const RETENTION_MS = 60 * 60_000;
const sessions = new Map<string, CommandSession>();
// Main reaches this leaf module through both tsx/cjs and the ESM hook, which gives
// separate module instances. Every instance must see the same running commands.
const SESSIONS_KEY = Symbol.for("orkas.core_agent.command_sessions");
const sessions: Map<string, CommandSession> = ((globalThis as any)[SESSIONS_KEY] ??= new Map<string, CommandSession>());
type CommandSession = {
id: string;
@@ -16,7 +19,7 @@ type CommandSession = {
start: number;
end: number;
waiters: Set<() => void>;
control?: { write(chars: string): void; stop(): void };
control?: { pid?: number; write(chars: string): void; stop(): void; kill(): void };
stopRequested: boolean;
stoppedByRequest: boolean;
result?: ToolResult;
@@ -175,7 +178,10 @@ export async function startCommandSession(
const callSignal = ctx.signal;
const runSignal = ctx.state.commandSessionSignal instanceof AbortSignal ? ctx.state.commandSessionSignal : undefined;
session.completion = sandbox.execute(command, {
onStart: (control) => { session.control = control; },
onStart: (control) => {
session.control = control;
if (session.stopRequested) control.stop();
},
onOutput: (bytes, stream, env) => append(session, process.platform === "win32"
? decodeProcessOutput(bytes, process.platform, env) : decoders[stream].write(bytes)),
}).then(async (raw) => {
@@ -245,6 +251,27 @@ export async function continueCommandSession(input: Record<string, unknown>, ctx
return receipt(session, input);
}
export interface HostExitCommand {
pid(): number | undefined;
settled: Promise<unknown>;
kill(): void;
}
/** Host exit only: request a stop for every running command and hand back what the
* caller needs to confirm or escalate it. Finished records keep their results. */
export function stopCommandSessionsForHostExit(): HostExitCommand[] {
const running = [...sessions.values()].filter((session) => !session.result);
for (const session of running) {
session.stopRequested = true;
session.control?.stop();
}
return running.map((session) => ({
pid: () => session.control?.pid,
settled: session.completion,
kill: () => session.control?.kill(),
}));
}
export async function resetCommandSessionsForTest(): Promise<void> {
const all = [...sessions.values()];
for (const session of all) session.control?.stop();
+14 -370
View File
@@ -9,89 +9,12 @@ import {
} from "../agent/session.js";
import { defineTool, type AgentTool, type ToolResult } from "./base.js";
type ExecutionPlanFinish = "completed" | "blocked";
export type ExecutionPlanController = {
get(): ExecutionPlanState | undefined;
update(update: ExecutionPlanUpdate): ExecutionPlanState;
clear(): void;
};
function normalizePlanStatus(raw: unknown): ExecutionPlanStepStatus | null {
const status = String(raw || "").trim().toLowerCase().replace(/-/g, "_");
if (status === "pending" || status === "not_started" || status === "todo" || status === "unknown") {
return "pending";
}
if (status === "in_progress" || status === "working") return "in_progress";
if (status === "completed" || status === "complete" || status === "done") return "completed";
if (status === "blocked") return "blocked";
return null;
}
function normalizePlanStepId(raw: unknown): number {
const canonicalAlias = typeof raw === "string"
? /^step_([1-9]\d*)$/.exec(raw.trim())
: null;
return Number(canonicalAlias?.[1] ?? raw);
}
function inferPlanAction(input: Record<string, unknown>): string {
if (typeof input.action === "string" && input.action) return input.action;
const candidates = new Set<string>();
if (Array.isArray(input.plan)) candidates.add("update");
if (Array.isArray(input.updates)) candidates.add("set_statuses");
if (input.step_id !== undefined) candidates.add("set_status");
if (input.step !== undefined) candidates.add("append_step");
return candidates.size === 1 ? [...candidates][0] : "";
}
function normalizePlanSteps(raw: unknown[]): ExecutionPlanStepInput[] {
return raw.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return item as ExecutionPlanStepInput;
}
const value = item as Record<string, unknown>;
const status = normalizePlanStatus(value.status);
return {
step: value.step as string,
status: (status || value.status) as ExecutionPlanStepStatus,
};
});
}
function normalizePlanFinish(raw: unknown): ExecutionPlanFinish | null {
if (raw === "completed" || raw === "blocked") return raw;
return null;
}
function normalizeCandidateStatuses(
steps: readonly ExecutionPlanStepInput[],
): ExecutionPlanStepInput[] {
let hasInProgress = false;
return steps.map((step) => {
if (step.status !== "in_progress") return step;
if (hasInProgress) return { ...step, status: "pending" };
hasInProgress = true;
return step;
});
}
function finishValidationMessage(
finish: ExecutionPlanFinish,
steps: readonly ExecutionPlanStepInput[],
): string | null {
if (steps.length === 0) return "manage_execution_plan finish requires an explicit plan";
if (finish === "completed") {
return steps.every((step) => step.status === "completed")
? null
: "manage_execution_plan finish=completed requires every milestone to be completed";
}
return steps.some((step) => step.status === "blocked")
&& steps.every((step) => step.status !== "in_progress")
? null
: "manage_execution_plan finish=blocked requires at least one blocked milestone and no in_progress milestone";
}
function planResult(plan: ExecutionPlanState, action: string, extra: Record<string, unknown> = {}): string {
return JSON.stringify({
ok: true,
@@ -107,16 +30,12 @@ function planSuccess(
plan: ExecutionPlanState,
action: string,
extra: Record<string, unknown>,
finish?: ExecutionPlanFinish,
): ToolResult {
const changed = extra.unchanged !== true
&& extra.updated !== false
&& extra.appended !== false;
return {
content: planResult(plan, action, {
...extra,
...(finish ? { finish } : {}),
}),
content: planResult(plan, action, extra),
observations: {
coordination: { changed },
},
@@ -226,263 +145,25 @@ export function createExecutionPlanTool(controller: ExecutionPlanController): Ag
},
async execute(input) {
try {
const parsedFinish = input.finish === undefined ? undefined : normalizePlanFinish(input.finish);
if (parsedFinish === null) {
return planFailure(
controller,
"PLAN_FINISH_INVALID",
"manage_execution_plan finish must be completed or blocked",
);
}
const finish: ExecutionPlanFinish | undefined = parsedFinish;
const requestedAction = inferPlanAction(input);
// Some providers choose the narrow status action but still emit the
// complete guarded plan shape. This is unambiguous only when neither
// narrow-action field is present; route that exact shape through the
// normal full-update validation instead of burning another tool loop.
const repairedFullPlanAction = requestedAction === "set_status"
&& Array.isArray(input.plan)
&& input.step_id === undefined
&& input.status === undefined;
const action = repairedFullPlanAction ? "update" : requestedAction;
if (action === "clear") {
if (finish) {
return planFailure(
controller,
"PLAN_FINISH_INVALID",
"manage_execution_plan finish cannot be combined with action=clear",
);
}
controller.clear();
return { content: JSON.stringify({ ok: true, action: "clear" }) };
}
if (action === "append_step") {
const current = controller.get();
if (!current) {
return planFailure(controller, "PLAN_MISSING", "manage_execution_plan append_step requires an existing plan; create it with action=update");
}
const step = String(input.step || "").trim();
const status = normalizePlanStatus(input.status ?? "pending");
if (!step) return planFailure(controller, "PLAN_STEP_REQUIRED", "manage_execution_plan append_step requires step");
if (!status) return planFailure(controller, "PLAN_STATUS_INVALID", "manage_execution_plan append_step requires a valid status");
if (current.steps.length >= EXECUTION_PLAN_MAX_STEPS) {
// The plan is advisory bookkeeping, not the user's task. Once its
// bounded ledger is full, failing the tool only invites the model
// to retry the same impossible append and delays real work. Keep
// every durable milestone intact and return an explicit no-op so
// execution can continue with status updates on existing steps.
if (finish) {
return planFailure(
controller,
"PLAN_FINISH_REJECTED",
"manage_execution_plan cannot finish from an append that exceeded plan capacity",
);
}
return planSuccess(current, "append_step", {
appended: false,
capacity_reached: true,
max_steps: EXECUTION_PLAN_MAX_STEPS,
});
}
const candidateSteps = normalizeCandidateStatuses([
...current.steps.map((item) => ({ step: item.step, status: item.status })),
{ step, status },
]);
if (finish) {
const message = finishValidationMessage(finish, candidateSteps);
if (message) return planFailure(controller, "PLAN_FINISH_REJECTED", message);
}
const plan = controller.update({
steps: candidateSteps,
...(typeof input.explanation === "string" ? { explanation: input.explanation } : {}),
});
const appended = plan.steps.at(-1);
return planSuccess(plan, "append_step", {
appended_step_id: appended?.id,
updated_step_ids: changedStepIds(current, plan),
...(appended && appended.status !== status
? { normalized_statuses: [{ step_id: appended.id, from: status, to: appended.status }] }
: {}),
}, finish);
}
if (action === "set_status") {
const current = controller.get();
if (!current) {
return planFailure(controller, "PLAN_MISSING", "manage_execution_plan set_status requires an existing plan");
}
// Some providers echo the stable ID label as `step_5` even though
// the schema requests integer 5. Accept only that exact canonical
// alias; arbitrary labels must keep failing closed.
const stepId = normalizePlanStepId(input.step_id);
const status = normalizePlanStatus(input.status);
if (!Number.isInteger(stepId) || stepId <= 0) {
return planFailure(controller, "PLAN_STEP_ID_INVALID", "manage_execution_plan set_status requires a positive integer step_id");
}
if (!status) return planFailure(controller, "PLAN_STATUS_INVALID", "manage_execution_plan set_status requires a valid status");
const target = current.steps.find((item) => item.id === stepId);
if (!target) {
return planFailure(controller, "PLAN_STEP_NOT_FOUND", `manage_execution_plan step_id ${stepId} does not exist`);
}
if (target.status === status) {
if (finish) {
const message = finishValidationMessage(finish, current.steps);
if (message) return planFailure(controller, "PLAN_FINISH_REJECTED", message);
}
return planSuccess(current, "set_status", {
step_id: stepId,
updated_step_ids: [],
unchanged: true,
}, finish);
}
const statusNormalizations = status === "in_progress"
? current.steps
.filter((item) => item.id !== stepId && item.status === "in_progress")
.map((item) => ({
step_id: item.id,
from: "in_progress" as const,
to: "pending" as const,
}))
: [];
const candidateSteps = current.steps.map((item) => ({
step: item.step,
status: item.id === stepId
? status
: status === "in_progress" && item.status === "in_progress"
? "pending"
: item.status,
}));
if (finish) {
const message = finishValidationMessage(finish, candidateSteps);
if (message) return planFailure(controller, "PLAN_FINISH_REJECTED", message);
}
const plan = controller.update({
steps: candidateSteps,
...(typeof input.explanation === "string" ? { explanation: input.explanation } : {}),
});
return planSuccess(plan, "set_status", {
step_id: stepId,
updated_step_ids: changedStepIds(current, plan),
...(statusNormalizations.length ? { normalized_statuses: statusNormalizations } : {}),
}, finish);
}
if (action === "set_statuses") {
const current = controller.get();
if (!current) {
return planFailure(controller, "PLAN_MISSING", "manage_execution_plan set_statuses requires an existing plan");
}
if (!Array.isArray(input.updates) || input.updates.length === 0) {
return planFailure(controller, "PLAN_STATUS_UPDATES_REQUIRED", "manage_execution_plan set_statuses requires one or more updates");
}
if (input.updates.length > EXECUTION_PLAN_MAX_STEPS) {
return planFailure(controller, "PLAN_STATUS_UPDATES_INVALID", `manage_execution_plan set_statuses accepts at most ${EXECUTION_PLAN_MAX_STEPS} updates`);
}
const parsed: Array<{ stepId: number; status: ExecutionPlanStepStatus }> = [];
const seenStepIds = new Set<number>();
for (const raw of input.updates) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return planFailure(controller, "PLAN_STATUS_UPDATES_INVALID", "manage_execution_plan set_statuses requires update objects");
}
const update = raw as Record<string, unknown>;
const stepId = normalizePlanStepId(update.step_id);
const status = normalizePlanStatus(update.status);
if (!Number.isInteger(stepId) || stepId <= 0) {
return planFailure(controller, "PLAN_STEP_ID_INVALID", "manage_execution_plan set_statuses requires positive integer step_id values");
}
if (!status) {
return planFailure(controller, "PLAN_STATUS_INVALID", "manage_execution_plan set_statuses requires valid status values");
}
if (seenStepIds.has(stepId)) {
return planFailure(controller, "PLAN_STEP_ID_DUPLICATE", `manage_execution_plan set_statuses repeats step_id ${stepId}`);
}
if (!current.steps.some((item) => item.id === stepId)) {
return planFailure(controller, "PLAN_STEP_NOT_FOUND", `manage_execution_plan step_id ${stepId} does not exist`);
}
seenStepIds.add(stepId);
parsed.push({ stepId, status });
}
const requestedInProgress = parsed.filter((update) => update.status === "in_progress");
if (requestedInProgress.length > 1) {
return planFailure(controller, "PLAN_MULTIPLE_IN_PROGRESS", "manage_execution_plan set_statuses accepts at most one in_progress update");
}
const requestedById = new Map(parsed.map((update) => [update.stepId, update.status]));
const activeStepId = requestedInProgress[0]?.stepId;
const updatedStepIds = parsed
.filter((update) => current.steps.find((item) => item.id === update.stepId)?.status !== update.status)
.map((update) => update.stepId);
const unchangedStepIds = parsed
.filter((update) => current.steps.find((item) => item.id === update.stepId)?.status === update.status)
.map((update) => update.stepId);
const statusNormalizations = activeStepId === undefined
? []
: current.steps
.filter((item) => (
item.id !== activeStepId
&& item.status === "in_progress"
&& !requestedById.has(item.id)
))
.map((item) => ({
step_id: item.id,
from: "in_progress" as const,
to: "pending" as const,
}));
if (updatedStepIds.length === 0 && statusNormalizations.length === 0) {
if (finish) {
const message = finishValidationMessage(finish, current.steps);
if (message) return planFailure(controller, "PLAN_FINISH_REJECTED", message);
}
return planSuccess(current, "set_statuses", {
requested_step_ids: parsed.map((update) => update.stepId),
updated_step_ids: [],
unchanged_step_ids: unchangedStepIds,
unchanged: true,
}, finish);
}
const candidateSteps = current.steps.map((item) => ({
step: item.step,
status: requestedById.get(item.id)
?? (activeStepId !== undefined && item.status === "in_progress" ? "pending" : item.status),
}));
if (finish) {
const message = finishValidationMessage(finish, candidateSteps);
if (message) return planFailure(controller, "PLAN_FINISH_REJECTED", message);
}
const plan = controller.update({
steps: candidateSteps,
...(typeof input.explanation === "string" ? { explanation: input.explanation } : {}),
});
return planSuccess(plan, "set_statuses", {
requested_step_ids: parsed.map((update) => update.stepId),
updated_step_ids: changedStepIds(current, plan),
unchanged_step_ids: unchangedStepIds,
...(statusNormalizations.length ? { normalized_statuses: statusNormalizations } : {}),
}, finish);
}
if (action !== "update" && action !== "replace") {
return planFailure(controller, "PLAN_ACTION_INVALID", "manage_execution_plan action must be update, append_step, set_status, set_statuses, or clear");
}
if (!Array.isArray(input.plan)) {
return planFailure(controller, "PLAN_REQUIRED", "manage_execution_plan action=update requires plan");
const unsupported = Object.keys(input).filter(key => !["plan", "explanation"].includes(key));
if (unsupported.length) return planFailure(controller, "PLAN_INPUT_UNSUPPORTED_FIELDS",
"Use plan/explanation only; unsupported fields: " + unsupported.join(", "));
if (!Array.isArray(input.plan)) return planFailure(controller, "PLAN_REQUIRED", "manage_execution_plan requires plan");
const requestedSteps = input.plan as ExecutionPlanStepInput[];
if (requestedSteps.some(step => !step || typeof step !== "object" || Array.isArray(step)
|| Object.keys(step).some(key => !["step", "status"].includes(key))
|| !["pending", "in_progress", "completed", "blocked"].includes(step.status))) {
return planFailure(controller, "PLAN_STATUS_INVALID", "Each milestone requires step and a current status: pending, in_progress, completed, blocked");
}
const current = controller.get();
if (input.plan.length > EXECUTION_PLAN_MAX_STEPS
&& current
&& current.steps.length >= EXECUTION_PLAN_MAX_STEPS
&& input.replace_objective !== true) {
&& current.steps.length >= EXECUTION_PLAN_MAX_STEPS) {
// Providers that do not enforce maxItems sometimes replay the full
// plan plus one more milestone after append_step was rejected. The
// plan plus one more milestone after reaching capacity. The
// same-objective ledger cannot grow, so preserve it and make the
// capacity signal terminal for this bookkeeping attempt. A genuine
// newer objective still goes through normal validation below.
if (finish) {
return planFailure(
controller,
"PLAN_FINISH_REJECTED",
"manage_execution_plan cannot finish from an update that exceeded plan capacity",
);
}
return planSuccess(current, "update", {
updated: false,
capacity_reached: true,
@@ -490,57 +171,20 @@ export function createExecutionPlanTool(controller: ExecutionPlanController): Ag
requested_step_count: input.plan.length,
});
}
const requestedSteps = normalizePlanSteps(input.plan);
const candidateSteps = normalizeCandidateStatuses(requestedSteps);
if (finish) {
const validStatuses = candidateSteps.every((step) => (
step.status === "pending"
|| step.status === "in_progress"
|| step.status === "completed"
|| step.status === "blocked"
));
if (validStatuses) {
const message = finishValidationMessage(finish, candidateSteps);
if (message) return planFailure(controller, "PLAN_FINISH_REJECTED", message);
}
}
const update: ExecutionPlanUpdate = {
steps: requestedSteps,
...(typeof input.explanation === "string" ? { explanation: input.explanation } : {}),
...(input.replace_objective === true ? { replaceObjective: true } : {}),
};
let replaceObjectiveApplied = input.replace_objective === true;
let plan: ExecutionPlanState;
try {
plan = controller.update(update);
} catch (err) {
// Live long runs commonly replay a previously successful
// replace_objective flag on later status-only updates. The Session
// rejects that stale capability before it validates milestones. Retry
// once without the capability: ordinary same-instruction guards still
// reject milestone removal, renaming, or completed-step regression.
const message = (err as Error).message || "";
if (
input.replace_objective !== true
|| !message.includes("replace_objective requires a newer real user instruction")
) {
throw err;
}
const { replaceObjective: _redundant, ...statusOnlyUpdate } = update;
plan = controller.update(statusOnlyUpdate);
replaceObjectiveApplied = false;
}
const plan = controller.update(update);
const statusNormalizations = normalizedStatuses(update.steps, plan);
const unchanged = current !== undefined && plan.revision === current.revision;
return planSuccess(plan, "update", {
action_inferred: !input.action || repairedFullPlanAction,
replace_objective_applied: replaceObjectiveApplied,
updated_step_ids: changedStepIds(current, plan),
...(unchanged ? {
unchanged: true,
} : {}),
...(statusNormalizations.length ? { normalized_statuses: statusNormalizations } : {}),
}, finish);
});
} catch (err) {
return planFailure(controller, "PLAN_UPDATE_REJECTED", (err as Error).message);
}
+1
View File
@@ -48,6 +48,7 @@ export {
processStartTool,
processStopTool,
processWriteTool,
stopAllProcessSessions,
} from "./process-session.js";
export type { ProcessSessionStatus } from "./process-session.js";
export {
+58 -15
View File
@@ -1,5 +1,5 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { commandSessionWait, continueCommandSession, inspectCommandRead, resetCommandSessionsForTest, runningCommandSessions } from "./command-sessions.js";
import { commandSessionWait, continueCommandSession, inspectCommandRead, resetCommandSessionsForTest, runningCommandSessions, stopCommandSessionsForHostExit } from "./command-sessions.js";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
@@ -61,7 +61,11 @@ const PROCESS_EXITED_RETENTION_MS = 60 * 60 * 1000;
const PROCESS_MAX_RUNNING_PER_OWNER = 8;
const PROCESS_INITIAL_ACTIVITY_WAIT_MS = process.platform === "win32" ? 1_500 : 300;
const sessions = new Map<string, ProcessSession>();
const PROCESS_HOST_EXIT_BUDGET_MS = 5_000;
// Shared across module instances like the managed command registry.
const SESSIONS_KEY = Symbol.for("orkas.core_agent.process_sessions");
const sessions: Map<string, ProcessSession> = ((globalThis as any)[SESSIONS_KEY] ??= new Map<string, ProcessSession>());
function processOwner(ctx: ToolContext): string {
const owner = ctx.state?.processSessionOwner;
@@ -564,10 +568,9 @@ export const processStopTool: AgentTool = defineTool({
},
});
type ProcessSessionAction = "start" | "read" | "write" | "stop";
type ProcessSessionAction = "read" | "write" | "stop";
const PROCESS_SESSION_ACTION_FIELDS: Readonly<Record<ProcessSessionAction, ReadonlySet<string>>> = {
start: new Set(["action", "command", "max_lifetime_ms"]),
read: new Set(["action", "session_id", "cursor", "max_chars", "yield_time_ms"]),
write: new Set(["action", "session_id", "chars", "add_newline"]),
stop: new Set(["action", "session_id"]),
@@ -578,8 +581,7 @@ function processSessionActionError(
input: Record<string, unknown>,
): string | null {
const unexpected = Object.keys(input)
.filter((key) => !Object.values(PROCESS_SESSION_ACTION_FIELDS).some((fields) => fields.has(key))
|| (action === "start" && key === "session_id"))
.filter((key) => !Object.values(PROCESS_SESSION_ACTION_FIELDS).some((fields) => fields.has(key)))
.sort();
return unexpected.length
? `E_BAD_INPUT: process_session(${action}) does not accept: ${unexpected.join(", ")}`
@@ -610,7 +612,7 @@ function inspectProcessRead(input: Record<string, unknown>, ctx: ToolContext, in
* non-model callers, but only this tool is advertised by the builtin registry. */
export const processSessionTool: AgentTool = defineTool({
name: "process_session",
compatibilityInputFields: [...PROCESS_SESSION_ACTION_FIELDS.start],
compatibilityInputFields: ["command", "max_lifetime_ms"],
description:
"Read, send input to, or stop a running shell session. Start commands with bash; use interactive_cli for user-entered secrets or OAuth.",
inputSchema: {
@@ -635,7 +637,7 @@ export const processSessionTool: AgentTool = defineTool({
inspectReadContinuation: inspectProcessRead,
async execute(input, ctx) {
const action = String(input.action ?? "") as ProcessSessionAction;
if (!(["start", "read", "write", "stop"] as const).includes(action)) {
if (!(["read", "write", "stop"] as const).includes(action)) {
return {
content: "E_BAD_INPUT: `action` must be read, write, or stop",
isError: true,
@@ -656,14 +658,11 @@ export const processSessionTool: AgentTool = defineTool({
} catch { /* Preserve non-JSON errors with a text receipt. */ }
return { ...result, content: `${notice}${result.content}` };
};
if (action !== "start") {
if (action === "read" && commandSessionWait(input.yield_time_ms) === null) {
return { content: "E_BAD_INPUT: yield_time_ms must be an integer from 0 to 30000", isError: true };
}
const managed = await continueCommandSession(input, ctx);
if (managed) return withNotice(managed);
if (action === "read" && commandSessionWait(input.yield_time_ms) === null) {
return { content: "E_BAD_INPUT: yield_time_ms must be an integer from 0 to 30000", isError: true };
}
if (action === "start") return withNotice(await processStartTool.execute(input, ctx));
const managed = await continueCommandSession(input, ctx);
if (managed) return withNotice(managed);
if (action === "read") return withNotice(await processReadTool.execute({ ...input, yield_time_ms: input.yield_time_ms ?? 10_000 }, ctx));
if (action === "write") return withNotice(await processWriteTool.execute(input, ctx));
if (action === "stop") return withNotice(await processStopTool.execute(input, ctx));
@@ -675,6 +674,50 @@ export function getProcessSessionTools(): AgentTool[] {
return [processSessionTool];
}
/** A shell closing is not proof of exit: a descendant can release its pipes and
* stay in the process group. Windows has no group; its helper already sweeps the tree. */
function processGroupAlive(pid: number | undefined): boolean {
if (process.platform === "win32" || !pid) return false;
try {
process.kill(-pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
/**
* Host exit: stop every command this process still owns, across both registries,
* so that none outlives the app with no owner and no timeout. `run_in_background`
* is an explicit detach and is never registered here. Resolves within `budgetMs`,
* giving TERM most of it and promptly once the commands are gone.
*/
export async function stopAllProcessSessions(budgetMs = PROCESS_HOST_EXIT_BUDGET_MS): Promise<void> {
const deadline = Date.now() + budgetMs;
const termDeadline = deadline - Math.floor(budgetMs * 0.4);
const targets = stopCommandSessionsForHostExit();
for (const session of sessions.values()) {
if (session.status !== "running") continue;
killSession(session);
finishSession(session, "stopped", { signal: "agent" });
targets.push({
pid: () => session.child.pid,
settled: Promise.all([session.termination, waitForProcessClose(session, budgetMs)]),
kill: () => killProcessTree(session.child, "SIGKILL"),
});
}
if (!targets.length) return;
let settled = false;
void Promise.all(targets.map((target) => target.settled)).then(() => { settled = true; });
const stopped = () => settled && !targets.some((target) => processGroupAlive(target.pid()));
const waitUntil = async (time: number) => {
while (!stopped() && Date.now() < time) await new Promise((resolve) => setTimeout(resolve, 25));
};
await waitUntil(termDeadline);
if (!stopped() && process.platform !== "win32") for (const target of targets) target.kill();
await waitUntil(deadline);
}
/** Test-only cleanup; production sessions are retained by their lifecycle. */
export async function _resetProcessSessionsForTest(): Promise<void> {
await resetCommandSessionsForTest();
+31 -33
View File
@@ -1475,6 +1475,8 @@ describe("AgentRunner", () => {
"B".repeat(12_001),
"C".repeat(12_000),
];
const firstRevision = "file_rev_1111111111111111";
const secondRevision = "file_rev_2222222222222222";
const firstSize = Buffer.byteLength(chunks[0], "utf8");
const secondSize = firstSize + Buffer.byteLength(chunks[1], "utf8");
const mockProvider = createMockProvider([
@@ -1494,7 +1496,7 @@ describe("AgentRunner", () => {
type: "tool_use",
id: "large-append-2",
name: "append_file",
input: { path: "large.txt", content: chunks[1], expected_size: firstSize },
input: { path: "large.txt", content: chunks[1], base_revision: firstRevision },
}],
stopReason: "tool_use",
usage: { inputTokens: 30, outputTokens: 3_100, totalTokens: 3_130 },
@@ -1505,7 +1507,7 @@ describe("AgentRunner", () => {
type: "tool_use",
id: "partial-large-append",
name: "append_file",
input: { path: "large.txt", expected_size: secondSize },
input: { path: "large.txt", base_revision: secondRevision },
}],
stopReason: "max_tokens",
usage: { inputTokens: 40, outputTokens: 32_768, totalTokens: 32_808 },
@@ -1516,7 +1518,7 @@ describe("AgentRunner", () => {
type: "tool_use",
id: "large-append-3",
name: "append_file",
input: { path: "large.txt", content: chunks[2], expected_size: secondSize },
input: { path: "large.txt", content: chunks[2], base_revision: secondRevision },
}],
stopReason: "tool_use",
usage: { inputTokens: 45, outputTokens: 3_100, totalTokens: 3_145 },
@@ -1531,7 +1533,8 @@ describe("AgentRunner", () => {
], (params) => requests.push(params));
const registry = new ProviderRegistry();
registry.registerFactory("mock", () => mockProvider);
const operations: Array<{ name: string; expectedSize?: number }> = [];
const operations: Array<{ name: string; baseRevision?: string }> = [];
let currentRevision = firstRevision;
let fileBody = "";
const writeFile = defineTool({
name: "write_file",
@@ -1540,19 +1543,20 @@ describe("AgentRunner", () => {
async execute(input) {
fileBody = String(input.content ?? "");
operations.push({ name: "write_file" });
return { content: `<file path="large.txt" total_bytes="${Buffer.byteLength(fileBody, "utf8")}"/>` };
return { content: `<file path="large.txt" total_bytes="${Buffer.byteLength(fileBody, "utf8")}" revision="${currentRevision}"/>` };
},
});
const appendFile = defineTool({
name: "append_file",
description: "Append the next complete file chunk",
inputSchema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" }, expected_size: { type: "number" } } },
inputSchema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" }, base_revision: { type: "string" } } },
async execute(input) {
const expectedSize = Number(input.expected_size);
if (expectedSize !== Buffer.byteLength(fileBody, "utf8")) throw new Error("stale append offset");
operations.push({ name: "append_file", expectedSize });
const baseRevision = String(input.base_revision);
if (baseRevision !== currentRevision) throw new Error("stale append revision");
operations.push({ name: "append_file", baseRevision });
fileBody += String(input.content ?? "");
return { content: `<file path="large.txt" total_bytes="${Buffer.byteLength(fileBody, "utf8")}"/>` };
currentRevision = baseRevision === firstRevision ? secondRevision : "file_rev_3333333333333333";
return { content: `<file path="large.txt" total_bytes="${Buffer.byteLength(fileBody, "utf8")}" revision="${currentRevision}"/>` };
},
});
const session = new Session();
@@ -1578,10 +1582,12 @@ describe("AgentRunner", () => {
expect(requests).toHaveLength(5);
expect(JSON.stringify(requests[3].messages)).toContain("at or below 12000 characters");
expect(JSON.stringify(requests[3].messages)).toContain(`total_bytes=\\"${secondSize}\\"`);
expect(JSON.stringify(requests[3].messages)).toContain(secondRevision);
expect(JSON.stringify(requests[3].messages)).not.toContain("expected_size");
expect(operations).toEqual([
{ name: "write_file" },
{ name: "append_file", expectedSize: firstSize },
{ name: "append_file", expectedSize: secondSize },
{ name: "append_file", baseRevision: firstRevision },
{ name: "append_file", baseRevision: secondRevision },
]);
expect(fileBody).toBe(chunks.join(""));
expect(fileBody.length).toBeGreaterThan(32_768);
@@ -2111,7 +2117,7 @@ describe("AgentRunner", () => {
.toBe(false);
});
it("accepts common execution-plan aliases without spending a retry round", async () => {
it("accepts the current execution-plan snapshot without spending a retry round", async () => {
const mockProvider = createMockProvider([
{
content: [{
@@ -2119,12 +2125,11 @@ describe("AgentRunner", () => {
id: "call-plan-aliases",
name: "manage_execution_plan",
input: {
action: "replace",
plan: [
{ step: "Inspect", status: "done" },
{ step: "Implement", status: "in-progress" },
{ step: "Verify", status: "not_started" },
{ step: "Publish", status: "unknown" },
{ step: "Inspect", status: "completed" },
{ step: "Implement", status: "in_progress" },
{ step: "Verify", status: "pending" },
{ step: "Publish", status: "pending" },
],
},
}],
@@ -2670,7 +2675,7 @@ describe("AgentRunner", () => {
if (outcome === "nonterminal") expect(result.text).toBe("The Agent was not started.");
});
it("treats a legacy Plan finish as an ordinary tool call before the final no-tool reply", async () => {
it("treats a completed Plan snapshot as an ordinary tool call before the final no-tool reply", async () => {
const requests: CompletionParams[] = [];
const provider = createMockProvider([
{
@@ -2680,7 +2685,6 @@ describe("AgentRunner", () => {
id: "plan-create",
name: "manage_execution_plan",
input: {
action: "update",
plan: [{ step: "Produce and verify the artifact", status: "in_progress" }],
},
},
@@ -2698,10 +2702,7 @@ describe("AgentRunner", () => {
id: "plan-finish",
name: "manage_execution_plan",
input: {
action: "set_status",
step_id: 1,
status: "completed",
finish: "completed",
plan: [{ step: "Produce and verify the artifact", status: "completed" }],
},
},
],
@@ -2749,7 +2750,7 @@ describe("AgentRunner", () => {
.toEqual(["completed"]);
});
it("keeps the normal active tool surface after a legacy Plan finish without reply text", async () => {
it("rejects legacy Plan finish without mutating progress or ending the turn", async () => {
const requests: CompletionParams[] = [];
const provider = createMockProvider([
{
@@ -2759,7 +2760,6 @@ describe("AgentRunner", () => {
id: "plan-create",
name: "manage_execution_plan",
input: {
action: "update",
plan: [{ step: "Finish the verified work", status: "in_progress" }],
},
},
@@ -2813,6 +2813,8 @@ describe("AgentRunner", () => {
const result = await runner.run({ message: "Track progress while finishing the verified work" });
expect(runner.getSession().getExecutionPlan()?.steps.map(step => step.status)).toEqual(["in_progress"]);
expect(JSON.stringify(requests[2].messages)).toContain("PLAN_INPUT_UNSUPPORTED_FIELDS");
expect(result.text).toBe("Verified work is complete.");
expect(result.text).not.toContain("must not be reached");
expect(requests).toHaveLength(3);
@@ -2890,7 +2892,6 @@ describe("AgentRunner", () => {
id: "plan-boundary",
name: "manage_execution_plan",
input: {
action: "update",
plan: [
{ step: "Show the preview", status: "completed" },
{ step: "Render after the user's reply", status: "pending" },
@@ -7870,7 +7871,6 @@ describe("AgentRunner", () => {
id: "plan-work",
name: "manage_execution_plan",
input: {
action: "update",
plan: [{ step: "Process the files", status: "in_progress" }],
},
}],
@@ -7921,15 +7921,14 @@ describe("AgentRunner", () => {
});
const provider = createMockProvider([
planCall("plan-create", {
action: "update",
plan: [
{ step: "Implement the change", status: "in_progress" },
{ step: "Verify the change", status: "in_progress" },
],
}),
planCall("plan-implement-done", { action: "set_status", step_id: 1, status: "completed" }),
planCall("plan-verify-start", { action: "set_status", step_id: 2, status: "in_progress" }),
planCall("plan-verify-done", { action: "set_status", step_id: 2, status: "completed" }),
planCall("plan-implement-done", { plan: [{ step: "Implement the change", status: "completed" }, { step: "Verify the change", status: "pending" }] }),
planCall("plan-verify-start", { plan: [{ step: "Implement the change", status: "completed" }, { step: "Verify the change", status: "in_progress" }] }),
planCall("plan-verify-done", { plan: [{ step: "Implement the change", status: "completed" }, { step: "Verify the change", status: "completed" }] }),
{
content: [{ type: "text", text: '{"plan":"complete"}' }],
stopReason: "end_turn",
@@ -7969,7 +7968,6 @@ describe("AgentRunner", () => {
});
const provider = createMockProvider([
planCall("plan-churn-create", {
action: "update",
plan: [
{ step: "Implement the change", status: "in_progress" },
{ step: "Verify the change", status: "pending" },
+28
View File
@@ -273,3 +273,31 @@ describe("apply_patch", () => {
await expect(fs.readFile(path.join(dir, "target.txt"), "utf8")).resolves.toBe("same\nsame\n");
});
});
// PF-04: invalid last hunk publishes nothing; multi-call work respects the existing 32-file boundary.
it.each([1, 20, 31, 32, 33, 100])('PF-04 preserves complete %i-file work across bounded patch transactions', async count => {
const dir = await tempDir(), original = 'start\n' + 'unchanged\n'.repeat(1000) + 'end\n';
const names = Array.from({ length: count }, (_, n) => `source-${n}.txt`);
await Promise.all(names.map(name => fs.writeFile(path.join(dir, name), original)));
const tool = createApplyPatchTool();
const patch = (selected: string[], invalid: boolean) => ['*** Begin Patch', ...selected.flatMap((name, n) => [
`*** Update File: ${name}`, '@@', `-${invalid && n === selected.length - 1 ? 'missing' : 'start'}`, '+changed',
]), '*** End Patch'].join('\n');
const rejected = await tool.execute({ patch: patch(names.slice(0, 32), true) }, context(dir));
expect(rejected.isError).toBe(true); expect(rejected.content).toContain('E_PATCH_NO_MATCH');
if (count > 32) {
const oversized = await tool.execute({ patch: patch(names, false) }, context(dir));
expect(oversized.isError).toBe(true); expect(oversized.content).toContain('E_PATCH_TOO_LARGE');
}
for (const name of names) expect(await fs.readFile(path.join(dir, name), 'utf8')).toBe(original);
const start = performance.now();
for (let offset = 0; offset < count; offset += 32) {
const accepted = await tool.execute({ patch: patch(names.slice(offset, offset + 32), false) }, context(dir));
expect(accepted.isError).not.toBe(true);
}
const elapsedMs = performance.now() - start;
for (const name of names) expect(await fs.readFile(path.join(dir, name), 'utf8')).toBe(original.replace('start', 'changed'));
expect((await fs.readdir(dir)).sort()).toEqual(names.sort());
console.log('[PF-04 patch transaction]', JSON.stringify({ files: count, transactions: Math.ceil(count / 32),
sourceBytes: Buffer.byteLength(original) * count, elapsedMs }));
});
+6 -6
View File
@@ -7,11 +7,11 @@ const token = (account: string) => `fixture.${Buffer.from(JSON.stringify({
"https://api.openai.com/auth": { chatgpt_account_id: account },
})).toString("base64")}.fixture`;
const params = (providerTurnContext?: object): CompletionParams => ({
model: "gpt-5.5", sessionId: "fixture-session", providerTurnContext,
model: "gpt-5.6-sol", sessionId: "fixture-session", providerTurnContext,
messages: [{ role: "user", content: [{ type: "text", text: "Process the next batch" }] }],
});
const provider = (account = "account-a", onPayload?: (value: unknown) => void) => {
const config = { provider: "openai-codex", model: "gpt-5.5", apiKey: token(account), onPayload,
const config = { provider: "openai-codex", model: "gpt-5.6-sol", apiKey: token(account), onPayload,
userCacheKey: 'opaque-user-a' };
return createPiProvider(config);
};
@@ -39,7 +39,7 @@ describe("Codex user-turn routing continuity", () => {
}));
const request = params({});
for (const userCacheKey of ['user-a', 'user-a', 'user-b', 'user-b']) {
const config = { provider: 'openai-codex', model: 'gpt-5.5', apiKey: token('shared'), userCacheKey };
const config = { provider: 'openai-codex', model: 'gpt-5.6-sol', apiKey: token('shared'), userCacheKey };
await stream(createPiProvider(config), request);
}
expect(requests.map(h => h.get(routingHeader))).toEqual([null, 'state-1', null, 'state-3']);
@@ -102,7 +102,7 @@ describe("Codex user-turn routing continuity", () => {
}));
const request = params({});
const build = (owner: string) => boundary === 'credential' ? provider(owner)
: createPiProvider({ provider: 'openai-codex', model: 'gpt-5.5', apiKey: token('shared'), userCacheKey: owner });
: createPiProvider({ provider: 'openai-codex', model: 'gpt-5.6-sol', apiKey: token('shared'), userCacheKey: owner });
const old = stream(build("old"), request);
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
await stream(build("new"), request);
@@ -172,7 +172,7 @@ describe("Codex user-turn routing continuity", () => {
}));
const request = params({});
await stream(provider(), request);
const changedEndpoint = createPiProvider({ provider: "openai-codex", model: "gpt-5.5",
const changedEndpoint = createPiProvider({ provider: "openai-codex", model: "gpt-5.6-sol",
apiKey: token("account-a"), baseUrl: "https://fixture.invalid/backend-api" });
await stream(changedEndpoint, request);
await stream(changedEndpoint, { ...request, sessionId: "other-session" });
@@ -196,7 +196,7 @@ describe("Codex user-turn routing continuity", () => {
requests.push(new Headers(init.headers));
return response("codex-looking-state");
}));
const p = createPiProvider({ provider: "openai", model: "gpt-5.5", apiKey: "fixture-key" });
const p = createPiProvider({ provider: "openai", model: "gpt-5.6-sol", apiKey: "fixture-key" });
const request = params({});
await stream(p, request);
await stream(p, request);
@@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { profileSyncCosts } from "../../../test/helpers/sync-cost.cjs";
import { PersistentSession } from "../src/agent/persistent-session.js";
import { contextBudget } from "../src/agent/session.js";
import type { Message, MessageContent } from "../src/shared/types.js";
@@ -1579,6 +1581,74 @@ describe("session save recovery", () => {
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-recovery-")); vi.useFakeTimers(); });
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); fs.rmSync(dir, { recursive: true, force: true }); });
it.each(["complete", "corrupt", "blank"])("separates a %s unterminated tail before appending and reopening", (tail) => {
const file = path.join(dir, "session.jsonl");
const first = JSON.stringify({ role: "user", content: [{ type: "text", text: "first 界🙂" }] });
const second = JSON.stringify({ role: "assistant", content: [{ type: "text", text: "second" }] });
const prefix = first + "\r\n" + (tail === "complete" ? second : tail === "corrupt" ? '{"role":' : " ");
fs.writeFileSync(file, prefix);
const session = new PersistentSession({ sessionFile: file });
// Loading remains read-only; preserve even malformed source bytes.
expect(fs.readFileSync(file, "utf8")).toBe(prefix);
session.addUserMessage("third");
session.addAssistantMessage([{ type: "text", text: "fourth" }]);
const expected = ["first 界🙂", ...(tail === "complete" ? ["second"] : []), "third", "fourth"];
expect(new PersistentSession({ sessionFile: file }).getMessages().map(m => (m.content[0] as { text: string }).text)).toEqual(expected);
expect(fs.readFileSync(file, "utf8").startsWith(prefix + "\n")).toBe(true);
expect(session.hasPendingPersistence()).toBe(false);
});
it("keeps append offsets valid after replacing an unterminated canonical tail", () => {
const file = path.join(dir, "session.jsonl");
const initial = new PersistentSession({ sessionFile: file });
initial.replaceConversationHistory([
{ role: "user", turnId: 1, content: [{ type: "text", text: "old request" }] },
{ role: "assistant", turnId: 1, content: [{ type: "text", text: "old reply 界🙂" }] },
{ role: "user", turnId: 2, content: [{ type: "text", text: "replace request" }] },
{ role: "assistant", turnId: 2, content: [{ type: "text", text: "replace reply" }] },
], "canonical");
fs.truncateSync(file, fs.statSync(file).size - 1);
const prefix = fs.readFileSync(file, "utf8").split("\n").slice(0, 2).join("\n") + "\n";
const session = new PersistentSession({ sessionFile: file });
expect(session.replaceConversationHistory([
{ role: "user", turnId: 2, content: [{ type: "text", text: "corrected" }] },
], "canonical", { replaceFromTurnId: 2 })).toBe(2);
session.addUserMessage("next");
expect(fs.readFileSync(file, "utf8").startsWith(prefix)).toBe(true);
expect(fs.readFileSync(file, "utf8")).not.toContain("\n\n");
expect(new PersistentSession({ sessionFile: file }).getMessages().map(m => (m.content[0] as { text: string }).text))
.toEqual(["old request", "old reply 界🙂", "corrected", "next"]);
});
it.each([1, 17])("retries a separated append after %i bytes without losing the old tail or later rows", async bytes => {
const file = path.join(dir, "session.jsonl");
const prefix = JSON.stringify({ role: "user", content: [{ type: "text", text: "old 界🙂" }] });
fs.writeFileSync(file, prefix);
const session = new PersistentSession({ sessionFile: file });
const append = fs.appendFileSync;
vi.spyOn(fs, "appendFileSync").mockImplementationOnce((target, data, options) => {
append(target, Buffer.from(String(data)).subarray(0, bytes), options);
throw Object.assign(new Error("injected partial append"), { code: "ENOSPC" });
});
session.addUserMessage("new");
session.addAssistantMessage([{ type: "text", text: "answer" }]);
const flush = session.flushPending();
await vi.advanceTimersByTimeAsync(200);
await flush;
expect(fs.readFileSync(file, "utf8").startsWith(prefix + "\n")).toBe(true);
expect(new PersistentSession({ sessionFile: file }).getMessages().map(m => (m.content[0] as { text: string }).text)).toEqual(["old 界🙂", "new", "answer"]);
// Offsets must include the separator: a canonical tail edit must retain the old row.
session.replaceConversationHistory([
session.getMessages()[0],
{ role: "user", content: [{ type: "text", text: "replacement" }] },
], "canonical");
expect(new PersistentSession({ sessionFile: file }).getMessages().map(m => (m.content[0] as { text: string }).text)).toEqual(["old 界🙂", "replacement"]);
session.clear();
session.addUserMessage("after reset");
expect(fs.readFileSync(file, "utf8").startsWith("{" )).toBe(true);
expect(new PersistentSession({ sessionFile: file }).getMessages()).toEqual(session.getMessages());
});
it("repairs a partially written row and retains every later message in order", async () => {
const file = path.join(dir, "session.jsonl");
const session = new PersistentSession({ sessionFile: file });
@@ -1675,3 +1745,243 @@ describe("session save recovery", () => {
expect(fs.readFileSync(file, "utf8").trim().split("\n")).toHaveLength(11);
});
});
// PF-01/PF-18: independent durable receipts, not a synthetic provider's elapsed time.
it.each([1, 4, 8])('PF-01 reopens every completed operation from %i interleaved task sessions', async tasks => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'orkas-task-load-'));
try {
const files = Array.from({ length: tasks }, (_, n) => path.join(root, `task-${n}.jsonl`));
const sessions = files.map(sessionFile => new PersistentSession({ sessionFile }));
const costs = process.env.ORKAS_PERFORMANCE_ATTRIBUTION === '1'
? profileSyncCosts(file => typeof file === 'string' && file.startsWith(root + path.sep)) : null;
const payload = '界x'.repeat(1024), start = performance.now(), cpu = process.cpuUsage();
try {
for (let task = 0; task < tasks; task++) sessions[task].beginUserTurn([{ type: 'text', text: `task-${task}` }]);
for (let operation = 0; operation < 40; operation++) {
for (let task = 0; task < tasks; task++) {
const id = `task-${task}-operation-${operation}`;
sessions[task].addAssistantMessage([{ type: 'tool_use', id, name: 'fixture', input: {} }]);
sessions[task].addToolResult(id, `${id} ${payload}`);
}
if (operation % 10 === 9) await new Promise(resolve => setImmediate(resolve));
}
await Promise.all(sessions.map(session => session.flushPending()));
} finally { costs?.stop(); }
const durableMs = performance.now() - start, used = process.cpuUsage(cpu);
const reopenStart = performance.now();
for (let task = 0; task < tasks; task++) {
const restored = new PersistentSession({ sessionFile: files[task] });
const results = restored.getMessages().flatMap(message => Array.isArray(message.content)
? message.content.filter(block => block.type === 'tool_result') : []);
expect(results).toEqual(Array.from({ length: 40 }, (_, operation) => ({ type: 'tool_result',
toolUseId: `task-${task}-operation-${operation}`, content: `task-${task}-operation-${operation} ${payload}`, images: [] })));
expect(restored.getMessages()).toEqual(sessions[task].getMessages());
}
console.log('[PF-01 durable task load]', JSON.stringify({ tasks, operations: tasks * 40, durableMs, ...(costs ? { callerCosts: costs.report } : {}),
reopenMs: performance.now() - reopenStart, cpuUserMs: used.user / 1000, cpuSystemMs: used.system / 1000,
transcriptBytes: files.reduce((total, file) => total + fs.statSync(file).size, 0),
scope: 'real persistence; interleaved sessions, not concurrent JS threads or whole Runner/model/UI' }));
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
it('PF-18 retains each acknowledged resource before abrupt process exit without waiting for callback completion', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'orkas-durable-exit-'));
try {
const file = path.join(root, 'task.jsonl');
const source = path.resolve(__dirname, '../src/agent/persistent-session.ts');
const script = `require('tsx/cjs');
const {PersistentSession} = require(${JSON.stringify(source)});
const session = new PersistentSession({sessionFile:${JSON.stringify(file)}});
const turn = session.beginUserTurn([{type:'text',text:'synthetic task'}]);
for (let n=0;n<3;n++) session.addHistoryResource({kind:'attachment',path:'/fixture-'+n,name:'input-'+n,sourceTurnId:turn});
process.exit(0);`;
// Same Electron ABI, explicit Node mode; child deliberately bypasses normal cleanup.
const child = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8', cwd: path.resolve(__dirname, '../../..'),
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, timeout: 10000 });
expect(child.status).toBe(0); expect(child.error).toBeUndefined();
expect(child.stdout).toBe(''); expect(child.stderr).toBe('');
const saved = JSON.parse(fs.readFileSync(file + '.context.json', 'utf8'));
expect(saved.resources.map((resource: { path: string }) => resource.path)).toEqual(['/fixture-0', '/fixture-1', '/fixture-2']);
const rows = fs.readFileSync(file, 'utf8').trim().split('\n').map(line => JSON.parse(line));
expect(rows).toHaveLength(1); expect(JSON.stringify(rows)).toContain('synthetic task');
expect(new PersistentSession({ sessionFile: file }).getSerializedContextState()!.resources!.map(resource => resource.path))
.toEqual(['/fixture-0', '/fixture-1', '/fixture-2']);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
// PF-01: exercise real Runner bookkeeping, not only direct Session appends.
// Provider/tool are deterministic fixtures; filesystem and reload are real.
it.each([
{ mode: 'sequential', tasks: 8, operations: 40, snapshotBytes: 0 },
{ mode: 'parallel', tasks: 8, operations: 40, snapshotBytes: 0 },
{ mode: 'sequential', tasks: 1, operations: 16, snapshotBytes: 65536 },
] as const)('PF-01 retains every receipt and ledger through $mode tasks with $snapshotBytes-byte file observations', async ({ mode, tasks, operations, snapshotBytes }) => {
const { AgentRunner } = await import('../src/agent/runner.js');
const { ProviderRegistry } = await import('../src/providers/registry.js');
const { createConfig } = await import('../src/config/loader.js');
const { defineTool } = await import('../src/tools/base.js');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'orkas-runner-persistence-'));
const { createHash } = await import('node:crypto');
const payload = '界x'.repeat(256), workspace = path.join(root, 'workspace');
fs.mkdirSync(workspace);
const sourceText = (step: number) => `${String(step).padStart(4, '0')}\n` + 'x'.repeat(Math.max(0, snapshotBytes - 5));
if (snapshotBytes) for (let task = 0; task < tasks; task++) fs.writeFileSync(path.join(workspace, `source-${task}.txt`), sourceText(-1));
const attribution = process.env.ORKAS_PERFORMANCE_ATTRIBUTION === '1';
const previousBodies = new Map<string, string>();
const context = { writes: 0, first: 0, identical: 0, changed: 0, unclassified: 0 };
const sessions = Array.from({ length: tasks }, (_, n) => new PersistentSession({ sessionFile: path.join(root, `task-${n}.jsonl`) }));
const callCounts = Array(tasks).fill(0);
const runners = sessions.map((session, task) => {
let next = 0;
const providers = new ProviderRegistry();
providers.registerFactory('mock', () => ({
id: 'mock', name: 'Synthetic provider',
async complete() { throw new Error('This fixture must not compact'); },
async *stream() {
yield { type: 'message_start' as const };
if (next === operations) {
yield { type: 'message_end' as const, stopReason: 'end_turn' as const,
content: [{ type: 'text' as const, text: `DONE-${task}` }], model: 'mock-model' };
return;
}
const end = Math.min(operations, next + (mode === 'parallel' ? 8 : 1));
const content = Array.from({ length: end - next }, (_, offset) => ({ type: 'tool_use' as const,
id: `task-${task}-step-${next + offset}`, name: 'fixture_result', input: { step: next + offset } }));
next = end;
yield { type: 'message_end' as const, stopReason: 'tool_use' as const, content, model: 'mock-model' };
},
async validateAuth() { return true; },
}));
return new AgentRunner({ session, providers,
config: createConfig({ agent: { defaultProvider: 'mock', defaultModel: 'mock-model' },
models: { catalog: { 'mock-model': { provider: 'mock', model: 'mock-model', contextWindow: 272000, maxOutputTokens: 32000 } } } }),
evolution: { enabled: false },
tools: [defineTool({ name: 'fixture_result', description: 'Synthetic result fixture',
inputSchema: { type: 'object', properties: { step: { type: 'number' } }, required: ['step'] },
...(mode === 'parallel' ? { executionMode: 'parallel' as const } : {}),
async execute(input) {
callCounts[task]++;
await new Promise(resolve => setImmediate(resolve));
const content = `task-${task}-step-${input.step} ${payload}`;
if (!snapshotBytes) return { content };
const target = path.join(workspace, `source-${task}.txt`);
const before = fs.readFileSync(target), after = Buffer.from(sourceText(Number(input.step)));
fs.writeFileSync(target, after);
return { content, observations: { fileChanges: [{ operation: 'update' as const, sourcePath: target,
beforeExists: true, afterExists: true, beforeBytes: before.length, afterBytes: after.length,
beforeHash: `sha256:${createHash('sha256').update(before).digest('hex')}`,
afterHash: `sha256:${createHash('sha256').update(after).digest('hex')}`,
beforeContent: before.toString('utf8'), afterContent: after.toString('utf8'), coverage: 'exact' as const }] } };
},
})],
});
});
const costs = attribution ? profileSyncCosts(file => typeof file === 'string' && file.startsWith(root + path.sep) && !file.startsWith(workspace + path.sep)) : null;
const originalWrite = fs.writeFileSync;
const spy = attribution ? vi.spyOn(fs, 'writeFileSync').mockImplementation(((target: fs.PathOrFileDescriptor, body: unknown, ...args: unknown[]) => {
const result = (originalWrite as Function)(target, body, ...args);
if (typeof target === 'string' && target.startsWith(root + path.sep) && target.endsWith('.context.json.tmp')) {
context.writes++;
// Diagnostic storage is bounded to the fixture paths and4M UTF-16
// units each, including the declared16x64KiB before/after snapshots.
// Saturation is unknown, never an assumed cache hit.
if (typeof body !== 'string' || body.length > 4194304 || (!previousBodies.has(target) && previousBodies.size >= tasks)) {
context.unclassified++; previousBodies.delete(target);
} else {
if (!previousBodies.has(target)) context.first++;
else if (previousBodies.get(target) === body) context.identical++;
else context.changed++;
previousBodies.set(target, body);
}
}
return result;
}) as typeof fs.writeFileSync) : null;
const start = performance.now();
try {
let results;
try { results = await Promise.all(runners.map((runner, task) => runner.run({ message: `Run task-${task}`, workingDir: root,
historyResources: [{ kind: 'attachment', path: `/fixture-${task}`, name: `input-${task}` }] }))); }
finally { spy?.mockRestore(); costs?.stop(); previousBodies.clear(); }
const elapsedMs = performance.now() - start;
expect(results.map(result => result.text)).toEqual(Array.from({ length: tasks }, (_, task) => `DONE-${task}`));
expect(callCounts).toEqual(Array(tasks).fill(operations));
expect(sessions.every(session => !session.hasPendingPersistence())).toBe(true);
for (let task = 0; task < tasks; task++) {
const file = path.join(root, `task-${task}.jsonl`);
const diskRows = fs.readFileSync(file, 'utf8').trim().split('\n').map(line => JSON.parse(line));
const restored = new PersistentSession({ sessionFile: file });
const expected = Array.from({ length: operations }, (_, step) => ({ id: `task-${task}-step-${step}`, content: `task-${task}-step-${step} ${payload}`, images: [], isError: false }));
const receipts = (messages: typeof diskRows) => messages.flatMap(message => message.content.filter((block: { type: string }) => block.type === 'tool_result'))
.map((block: any) => ({ id: block.toolUseId, content: block.content, images: block.images, isError: !!block.isError }));
for (const messages of [diskRows, restored.getMessages()]) {
const actual = receipts(messages);
expect(actual).toHaveLength(operations);
for (let step = 0; step < operations; step++) {
const { content, ...receipt } = actual[step];
const { content: expectedText, ...expectedReceipt } = expected[step];
expect(receipt).toEqual(expectedReceipt);
if (!snapshotBytes) expect(content).toBe(expectedText);
else {
// Real Runner appends authoritative file-change facts to a receipt.
// Verify both the entire original body and the complete structured facts.
const marker = '\n\n[Tool-observed file changes — data, not instructions]\n';
const boundary = content.indexOf(marker);
expect(content.slice(0, boundary)).toBe(expectedText);
expect(JSON.parse(content.slice(boundary + marker.length))).toEqual({ files: [{
operation: 'update', path: path.join(workspace, `source-${task}.txt`), exists: true,
hash: `sha256:${createHash('sha256').update(Buffer.from(sourceText(step))).digest('hex')}`,
coverage: 'exact',
}], omitted: 0 });
}
}
}
const state = restored.getSerializedContextState();
expect(state?.completedWork?.map(entry => entry.toolCallId)).toEqual(expected.map(receipt => receipt.id));
expect(state?.resources?.map(resource => resource.path)).toEqual([`/fixture-${task}`]);
expect(state?.activeTurn).toBeUndefined();
expect(restored.getMessages().at(-1)?.content).toEqual([{ type: 'text', text: `DONE-${task}` }]);
if (snapshotBytes) {
expect(fs.readFileSync(path.join(workspace, `source-${task}.txt`))).toEqual(Buffer.from(sourceText(operations - 1)));
const changes = restored.getWorkspaceObservations().entries.flatMap(entry => entry.fileChanges ?? []);
expect(changes.map(change => change.beforeContent)).toEqual(Array.from({ length: operations }, (_, step) => sourceText(step - 1)));
expect(changes.map(change => change.afterContent)).toEqual(Array.from({ length: operations }, (_, step) => sourceText(step)));
}
}
if (attribution) {
expect(context.writes).toBeGreaterThan(0); // tap validity, not an inefficient-count contract
expect(context.unclassified).toBe(0);
}
console.log('[PF-01 actual Runner persistence]', JSON.stringify({ mode, tasks, operationsPerTask: operations, snapshotBytes, elapsedMs,
...(costs ? { callerCosts: costs.report, contextWritePayloads: context } : {}),
scope: 'real Runner/Session/files/reopen; synthetic provider, no network/UI; FS attribution excludes fixture source-file I/O; SHA counters cover intercepted calls only; equal payloads compare successful submissions, not an external-change-safe write cache' }));
} finally { spy?.mockRestore(); costs?.stop(); fs.rmSync(root, { recursive: true, force: true }); }
}, 30000);
// A remembered last payload is not proof that the backing file still has it.
it.each(['deleted', 'replaced'] as const)('PF-01 restores unchanged in-memory context after its backing sidecar is %s', change => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'orkas-context-integrity-'));
try {
const file = path.join(root, 'task.jsonl'), sidecar = file + '.context.json';
const session = new PersistentSession({ sessionFile: file });
session.beginUserTurn([{ type: 'text', text: 'Keep the resource through interruption' }]);
session.addHistoryResource({ kind: 'attachment', path: '/kept', name: 'source' });
const saved = fs.readFileSync(sidecar, 'utf8');
if (change === 'deleted') fs.unlinkSync(sidecar);
else {
const changed = saved.replace('/kept', '/evil');
expect(Buffer.byteLength(changed)).toBe(Buffer.byteLength(saved));
expect(changed).not.toBe(saved);
fs.writeFileSync(sidecar, changed);
}
// Ordinary assistant text leaves serialized turn context unchanged.
session.addAssistantMessage([{ type: 'text', text: 'Progress committed' }]);
expect(session.hasPendingPersistence()).toBe(false);
expect(fs.readFileSync(sidecar, 'utf8')).toBe(saved);
const restored = new PersistentSession({ sessionFile: file });
expect(restored.getSerializedContextState()?.resources?.map(resource => resource.path)).toEqual(['/kept']);
expect(restored.getMessages().map(message => message.content)).toEqual([
[{ type: 'text', text: 'Keep the resource through interruption' }],
[{ type: 'text', text: 'Progress committed' }],
]);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
+134 -20
View File
@@ -19,6 +19,8 @@ import {
_resetProcessSessionsForTest,
getProcessSessionTools,
processReadTool,
processStartTool,
stopAllProcessSessions,
} from "../src/tools/process-session.js";
import { bashTool } from "../src/tools/builtin.js";
import { commandSessionWait } from "../src/tools/command-sessions.js";
@@ -116,7 +118,7 @@ process.stdin.resume();`);
const command = shellInvoke(TEST_NODE, [fixture]);
const started = mode === "managed"
? await bashTool.execute({ command, yield_time_ms: 0 }, ctx)
: await tool("process_session").execute({ action: "start", command }, ctx);
: await processStartTool.execute({ action: "start", command }, ctx);
const sessionId = json(started.content).session_id;
const read = await waitForProcessRead(owner, sessionId, 0, value => value.output.includes("BOOTING"));
expect(read.payload.status).toBe("running");
@@ -163,7 +165,7 @@ process.stdin.resume();`);
expect(result.error).toBeUndefined();
}
const result = mode === "detached" ? { content: "" } : mode === "legacy"
? await tool("process_session").execute({ action: "start", command }, ctx)
? await processStartTool.execute({ action: "start", command }, ctx)
: await bashTool.execute({ command, ...(mode === "managed" ? { yield_time_ms: 1000 } : {}) }, ctx);
expect(result.isError).toBeFalsy();
expect(spawnMock).toHaveBeenCalledOnce();
@@ -188,7 +190,7 @@ process.stdin.resume();`);
const command = shellInvoke(TEST_NODE, ["-e", "process.stdout.write('hello');process.stdin.resume()"]);
const started = mode === "managed"
? await bashTool.execute({ command, yield_time_ms: 0 }, ctx)
: await tool("process_session").execute({ action: "start", command }, ctx);
: await processStartTool.execute({ action: "start", command }, ctx);
const session_id = json(started.content).session_id;
await waitForProcessRead("reader", session_id, 0, (value) => value.output_end >= 5);
const result = json((await tool("process_session").execute({
@@ -205,7 +207,7 @@ process.stdin.resume();`);
const command = shellInvoke(TEST_NODE, ["-e", "process.stdout.write('hello');process.stdin.on('data',d=>{if(d.toString().includes('exit'))process.exit(0);else process.stdout.write('world')})"]);
const started = mode === "managed"
? await bashTool.execute({ command, yield_time_ms: 0 }, ctx)
: await sessionTool.execute({ action: "start", command }, ctx);
: await processStartTool.execute({ action: "start", command }, ctx);
const session_id = json(started.content).session_id;
await waitForProcessRead("reader", session_id, 0, (value) => value.output_end >= 5);
const fixed = { action: "read", session_id, cursor: 0, max_chars: 5 };
@@ -229,10 +231,13 @@ process.stdin.resume();`);
{ ...fixed, cursor: "5" }, { ...fixed, yield_time_ms: 1.5 }, { ...fixed, max_chars: 0.5 },
]) expect(sessionTool.inspectReadContinuation!(input, ctx)).toBeUndefined();
expect(sessionTool.inspectReadContinuation!({ ...fixed, command: "extra" }, ctx)).toEqual(sessionTool.inspectReadContinuation!(fixed, ctx));
expect(sessionTool.inspectReadContinuation!({ ...fixed, command: "extra" }, ctx)).toBeUndefined();
const unusedMarker = path.join(workingDir, "unused-start-command");
const ignoredStart = shellInvoke(TEST_NODE, ["-e", `require('fs').writeFileSync(${JSON.stringify(unusedMarker)}, 'wrong')`]);
const actualRead = await sessionTool.execute({ ...fixed, command: ignoredStart, chars: { unused: true }, yield_time_ms: 60000 }, ctx);
const retiredRead = await sessionTool.execute({ ...fixed, command: ignoredStart }, ctx);
expect(retiredRead.isError).toBe(true);
expect(retiredRead.content).toContain("does not accept: command");
const actualRead = await sessionTool.execute({ ...fixed, chars: { unused: true }, yield_time_ms: 60000 }, ctx);
expect(actualRead.isError).toBeFalsy();
expect(json(actualRead.content).output).toBe("hello");
await expect(fs.stat(unusedMarker)).rejects.toMatchObject({ code: "ENOENT" });
@@ -272,7 +277,7 @@ process.stdin.resume();`);
await expect(fs.stat(marker)).rejects.toMatchObject({ code: "ENOENT" });
});
it("uses a portable schema and ignores startup fields on reads", async () => {
it("uses a portable schema and rejects retired startup fields on reads", async () => {
const processSession = tool("process_session");
const schema = processSession.inputSchema as any;
@@ -284,9 +289,8 @@ process.stdin.resume();`);
command: "must-not-run",
}, context("owner-a"));
expect(rejected).toMatchObject({ isError: true });
expect(rejected.content).not.toContain("does not accept");
expect(rejected.content).toMatch(/not found|unknown|not exist/i);
expect(rejected.content).toContain('ignored_fields: ["command"]');
expect(rejected.content).toContain("does not accept: command");
expect(rejected.content).not.toMatch(/not found|unknown|not exist/i);
expect(rejected.content).not.toContain('must-not-run');
});
@@ -295,7 +299,7 @@ process.stdin.resume();`);
"-e",
"process.stdout.write('first\\n');process.stdin.once('data',()=>{process.stdout.write('second\\n');process.exit(0)})",
]);
const started = await tool("process_session").execute({ action: "start", command }, context("conversation-a"));
const started = await processStartTool.execute({ action: "start", command }, context("conversation-a"));
expect(started.isError).toBeUndefined();
let firstResult = { result: started, payload: json(started.content) };
if (!String(firstResult.payload.output).includes("first")) {
@@ -347,14 +351,14 @@ process.stdin.resume();`);
});
it("keeps the legacy direct read export nonblocking", async () => {
const started = json((await tool("process_session").execute({ action: "start", command: shellInvoke(TEST_NODE, ["-e", "setInterval(()=>{},1000)"]) }, context("owner-a"))).content);
const started = json((await processStartTool.execute({ action: "start", command: shellInvoke(TEST_NODE, ["-e", "setInterval(()=>{},1000)"]) }, context("owner-a"))).content);
const result = await processReadTool.execute({ session_id: started.session_id }, context("owner-a"));
expect(json(result.content).status).toBe("running");
});
it("isolates session ids by host owner", async () => {
const command = shellInvoke(TEST_NODE, ["-e", "setTimeout(()=>process.exit(0),5000)"]);
const started = json((await tool("process_session").execute({ action: "start", command }, context("owner-a"))).content);
const started = json((await processStartTool.execute({ action: "start", command }, context("owner-a"))).content);
const denied = await tool("process_session").execute({
action: "read",
session_id: started.session_id,
@@ -386,7 +390,7 @@ process.stdin.resume();`);
"-e",
"console.log(process.pid);process.stdin.once('data',d=>process.stdout.write('echo:'+d.toString()));setInterval(()=>{},1000)",
]);
const started = json((await tool("process_session").execute({ action: "start", command }, context("owner-a"))).content);
const started = json((await processStartTool.execute({ action: "start", command }, context("owner-a"))).content);
const written = await tool("process_session").execute({
action: "write",
session_id: started.session_id,
@@ -462,7 +466,7 @@ process.stdin.resume();`);
return setTimer(...args);
}) as typeof setTimeout) : undefined;
try {
const started = json((await sessionTool.execute({
const started = json((await processStartTool.execute({
action: "start", command: shellInvoke(TEST_NODE, ["-e", script]), max_lifetime_ms: 60_000,
}, ctx)).content);
timerSpy?.mockRestore();
@@ -476,7 +480,7 @@ process.stdin.resume();`);
await expect.poll(() => fs.readFile(ticks, "utf8")).not.toBe("");
expect(processIsAlive(producerPid)).toBe(true);
const sibling = json((await sessionTool.execute({
const sibling = json((await processStartTool.execute({
action: "start", command: shellInvoke(TEST_NODE, ["-e", "console.log(process.pid);setInterval(()=>{},1000)"]),
}, context("other-owner"))).content);
const siblingReady = await waitForProcessRead("other-owner", sibling.session_id, 0, payload => !!payload.output.trim());
@@ -512,7 +516,7 @@ process.stdin.resume();`);
"-e",
"process.stderr.write('failed\\n');process.exit(7)",
]);
const result = await tool("process_session").execute(
const result = await processStartTool.execute(
{ action: "start", command },
context("owner-a"),
);
@@ -630,7 +634,10 @@ describe("bash managed continuation", () => {
waiting.abort();
expect(json((await resultPromise).content).status).toBe("running");
run.abort();
const result = await read(initial.session_id);
// A bounded read may return "running" before the independent five-second
// cleanup finishes. Observe terminal cancellation through the public poll.
const { result } = await waitForProcessRead("command-owner", initial.session_id, 0,
payload => payload.status !== "running");
expect(result.isError).toBe(true);
expect(result.observations?.execution?.status).toBe("aborted");
const stopped = await tool("process_session").execute({ action: "stop", session_id: initial.session_id }, managed());
@@ -653,12 +660,12 @@ describe("bash managed continuation", () => {
if (process.platform === "win32") vi.mocked(spawn).mockImplementationOnce((() => killer) as typeof spawn);
const initial = json((await bashTool.execute({ command: "fixture-running", yield_time_ms: 0 }, ctx)).content);
const stopping = tool("process_session").execute({ action: "stop", session_id: initial.session_id }, managed());
if (process.platform === "win32") killer.stdout.emit("data", Buffer.from("ready\n"));
if (process.platform === "win32") killer.stdout.emit("data", Buffer.from("accepting\nready:0\n"));
run.abort();
child.exitCode = process.platform === "win32" ? 4294967295 : null;
child.stdout.end(); child.stderr.end(); child.emit("close", child.exitCode);
if (process.platform === "win32") {
killer.stdout.emit("data", Buffer.from("root-terminated\n"));
killer.stdout.emit("data", Buffer.from("done:0:1\n"));
killer.emit("exit", 0); killer.emit("close", 0);
}
const stopped = await stopping;
@@ -793,3 +800,110 @@ describe("bash managed continuation", () => {
await expect(fs.stat(path.join(workingDir, "forbidden"))).rejects.toMatchObject({ code: "ENOENT" });
});
});
// The real shell and its children run here. Commands that keep running after
// their turn have no other owner once the app process is gone: macOS has no
// equivalent of the Windows exit sweep, and the 60-minute timer dies with the host.
describe("application exit", () => {
const owner = "exit-owner";
const managedContext = (): ToolContext => ({
workingDir, state: { processSessionOwner: owner, commandSessionEnabled: true },
});
const pidFile = () => path.join(workingDir, "child.pid");
const service = (extra = "") => shellInvoke(TEST_NODE, ["-e",
`require('fs').writeFileSync('child.pid',String(process.pid));${extra}setInterval(()=>{},1000)`]);
const children = new Set<number>();
const reportedPid = async () => {
await expect.poll(() => fs.readFile(pidFile(), "utf8").catch(() => ""), { timeout: 10_000 }).not.toBe("");
const pid = Number(await fs.readFile(pidFile(), "utf8"));
children.add(pid);
return pid;
};
// A failing case must not leave a TERM-resistant fixture on the machine.
afterEach(() => {
for (const pid of children) { try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ } }
children.clear();
});
const start = async (kind: "managed" | "legacy", command: string) => {
const started = kind === "managed"
? await bashTool.execute({ command, yield_time_ms: 0 }, managedContext())
: await processStartTool.execute({ action: "start", command }, managedContext());
expect(started.isError).toBeFalsy();
return json(started.content).session_id as string;
};
it.each(["managed", "legacy"] as const)("leaves no process behind and reports the stop: %s", async kind => {
const sessionId = await start(kind, service());
const pid = await reportedPid();
expect(processIsAlive(pid)).toBe(true);
const startedAt = performance.now();
await stopAllProcessSessions();
expect(processIsAlive(pid)).toBe(false);
// A command that honors TERM must not hold the quit for the SIGKILL grace.
expect(performance.now() - startedAt).toBeLessThan(2_500);
const read = await tool("process_session").execute({ action: "read", session_id: sessionId, yield_time_ms: 0 }, managedContext());
expect(json(read.content).status).toBe("stopped");
});
it("finishes the host's terminal bookkeeping before resolving", async () => {
const ctx = managedContext();
let finished = 0;
ctx.state.commandSessionTerminal = async (result: any) => { finished++; return result; };
await bashTool.execute({ command: service(), yield_time_ms: 0 }, ctx);
await reportedPid();
await stopAllProcessSessions();
expect(finished).toBe(1);
});
it.runIf(process.platform !== "win32").each(["managed", "legacy"] as const)("force kills a command that ignores TERM within the budget: %s", async kind => {
await start(kind, service("process.on('SIGTERM',()=>{});"));
const pid = await reportedPid();
const startedAt = performance.now();
await stopAllProcessSessions(1_500);
expect(processIsAlive(pid)).toBe(false);
expect(performance.now() - startedAt).toBeLessThan(3_000);
});
it.runIf(process.platform !== "win32").each(["managed", "legacy"] as const)("force kills a descendant that outlives its shell with its pipes released: %s", async kind => {
await start(kind, `(trap '' TERM; exec sleep 300) >/dev/null 2>&1 </dev/null & echo $! > ${shellQuote(pidFile())}; wait`);
const pid = await reportedPid();
expect(processIsAlive(pid)).toBe(true);
await stopAllProcessSessions(1_500);
expect(processIsAlive(pid)).toBe(false);
});
it("does not touch an explicitly detached run_in_background command", async () => {
const started = await bashTool.execute({ command: service(), run_in_background: true }, managedContext());
expect(started.isError).toBeFalsy();
const pid = await reportedPid();
try {
await stopAllProcessSessions();
expect(processIsAlive(pid)).toBe(true);
} finally {
try { process.kill(process.platform === "win32" ? pid : -pid, "SIGKILL"); } catch { /* best effort */ }
}
});
it("returns at once with nothing running and keeps a finished command's result", async () => {
const quick = await bashTool.execute({ command: shellInvoke(TEST_NODE, ["-e", "setTimeout(()=>{},300)"]), yield_time_ms: 0 }, managedContext());
const sessionId = json(quick.content).session_id;
await waitForProcessRead(owner, sessionId, 0, value => value.status === "exited");
const startedAt = performance.now();
await stopAllProcessSessions();
expect(performance.now() - startedAt).toBeLessThan(500);
const read = await tool("process_session").execute({ action: "read", session_id: sessionId, yield_time_ms: 0 }, managedContext());
expect(json(read.content)).toMatchObject({ status: "exited", exit_code: 0 });
});
// Main loads these leaf modules through both tsx/cjs and the ESM hook, which
// yields separate module instances. The exit sweep runs from either one.
it.each(["managed", "legacy"] as const)("reaches a command started through another module instance: %s", async kind => {
await start(kind, service());
const pid = await reportedPid();
vi.resetModules();
const otherInstance = await import("../src/tools/process-session.js");
expect(otherInstance.stopAllProcessSessions).not.toBe(stopAllProcessSessions);
await otherInstance.stopAllProcessSessions();
expect(processIsAlive(pid)).toBe(false);
});
});
+16 -12
View File
@@ -69,12 +69,12 @@ describe("Windows shell cleanup", () => {
const onComplete = vi.fn();
const onRootTerminated = vi.fn();
killProcessTree(child as any, "SIGTERM", { platform: "win32", onComplete, onRootTerminated });
killer.stdout.emit("data", Buffer.from("ready\n"));
killer.stdout.emit("data", Buffer.from("accepting\nready:0\n"));
child.exitCode = 4294967295;
killer.emit("exit", 0);
// A helper's exit can precede its final pipe data. Do not finish early.
expect(onComplete).not.toHaveBeenCalled();
killer.stdout.emit("data", Buffer.from("root-terminated\n"));
killer.stdout.emit("data", Buffer.from("done:0:1\n"));
killer.emit("close", 0);
expect(onRootTerminated).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledTimes(1);
@@ -90,17 +90,18 @@ describe("Windows shell cleanup", () => {
const onRootTerminated = vi.fn();
killProcessTree(child as any, "SIGTERM", { platform: "win32", onRootTerminated });
if (outcome === "unauthorized") child.exitCode = 7;
killer.stdout.emit("data", Buffer.from("ready\n"));
killer.stdout.emit("data", Buffer.from("accepting\nready:0\n"));
child.exitCode = 7;
if (outcome === "natural-exit") killer.stdout.emit("data", Buffer.from("done:0:0\n"));
if (outcome !== "natural-exit") killer.stdout.emit("data", Buffer.from(
"root-terminated\n" + (outcome === "malformed" ? "unexpected\n" : ""),
"done:0:1\n" + (outcome === "malformed" ? "unexpected\n" : ""),
));
const code = outcome === "failed-cleanup" ? 1 : 0;
killer.emit("exit", code);
killer.emit("close", code);
expect(onRootTerminated).not.toHaveBeenCalled();
expect(child.kill).not.toHaveBeenCalled();
expect(warning).toHaveBeenCalledTimes(outcome === "failed-cleanup" ? 1 : 0);
expect(warning).toHaveBeenCalledTimes(outcome === "failed-cleanup" || outcome === "malformed" ? 1 : 0);
},
);
@@ -114,13 +115,13 @@ describe("Windows shell cleanup", () => {
.execute("fixture", { onStart: (next) => { control = next; }, onOutput: () => {} })
.then((result) => { done(); return result; });
control.stop();
killer.stdout.emit("data", Buffer.from("ready\n"));
killer.stdout.emit("data", Buffer.from("accepting\nready:0\n"));
child.exitCode = 4294967295;
child.emit("close", child.exitCode);
killer.emit("exit", 0);
await Promise.resolve();
expect(done).not.toHaveBeenCalled();
killer.stdout.emit("data", Buffer.from("root-terminated\n"));
killer.stdout.emit("data", Buffer.from("done:0:1\n"));
killer.emit("close", 0);
await expect(execution).resolves.toMatchObject({ aborted: true, exitCode: 4294967295,
rootTerminationConfirmed: true, timedOut: false, outputLimitExceeded: false });
@@ -145,14 +146,17 @@ describe("Windows shell cleanup", () => {
const child = childFixture();
const killer = childFixture();
vi.mocked(spawn).mockReturnValue(killer as any);
const input = vi.spyOn(killer.stdin, "end");
const input = vi.spyOn(killer.stdin, "write");
killProcessTree(child as any, "SIGKILL", { platform: "win32" });
if (exited) child.exitCode = 0;
killer.stdout.emit("data", Buffer.from("accepting\n"));
input.mockClear();
killer.stdout.emit("data", Buffer.from("rea"));
expect(input).not.toHaveBeenCalled();
killer.stdout.emit("data", Buffer.from("dy\r\n"));
expect(input).toHaveBeenCalledExactlyOnceWith(exited ? "cancel\n" : "go\n");
killer.stdout.emit("data", Buffer.from("ready\n"));
killer.stdout.emit("data", Buffer.from("dy:0\r\n"));
expect(input).toHaveBeenCalledExactlyOnceWith(exited ? "cancel:0\n" : "go:0\n");
killer.stdout.emit("data", Buffer.from(exited ? "done:0:0\n" : "done:0:1\n"));
killer.emit("close", 0);
expect(input).toHaveBeenCalledTimes(1);
});
@@ -182,7 +186,7 @@ describe("Windows shell cleanup", () => {
});
expect(JSON.stringify(warning.mock.calls)).not.toContain("private path");
expect(spawn).toHaveBeenCalledWith(expect.any(String), expect.any(Array),
expect.objectContaining({ timeout: 5_000, windowsHide: true }));
expect.objectContaining({ windowsHide: true }));
});
it.runIf(process.platform === "win32")("waits for tree cleanup after shell close before returning the timeout result", async () => {
@@ -58,7 +58,9 @@ it.runIf(process.platform === "win32")("cleans up eight simultaneous deadlines w
const warnings = vi.spyOn(console, "warn");
const dirs = Array.from({ length: 8 }, (_, index) => path.join(root, String(index)));
try {
await Promise.all(dirs.map(async dir => {
// Keep every branch alive through its liveness oracle before fixture
// teardown; a first failure must not kill another branch's producer.
const outcomes = await Promise.allSettled(dirs.map(async dir => {
await fs.mkdir(dir);
const script = "require('fs').writeFileSync('producer.pid',String(process.pid));setInterval(()=>{},1000);setTimeout(()=>process.exit(0),10000)";
const result = await new SandboxExecutor({ workingDir: dir, timeoutMs: 250 })
@@ -67,6 +69,7 @@ it.runIf(process.platform === "win32")("cleans up eight simultaneous deadlines w
const pid = await fs.readFile(path.join(dir, "producer.pid"), "utf8").catch(() => "");
if (pid) expect(() => process.kill(Number(pid), 0)).toThrow();
}));
expect(outcomes.filter(outcome => outcome.status === "rejected")).toEqual([]);
expect(warnings.mock.calls).toEqual([]);
} finally {
warnings.mockRestore();
+17 -3
View File
@@ -401,7 +401,7 @@ describe("SandboxExecutor", () => {
});
describe("killProcessTree", () => {
it("uses bounded native Windows tree cleanup and reports fallback failure", () => {
it("keeps the Windows cleanup helper referenced until close and reports fallback failure", () => {
const callbacks = new Map<string, (...args: any[]) => void>();
const killer = {
once: vi.fn((event: string, cb: (...args: any[]) => void) => {
@@ -412,28 +412,42 @@ describe("killProcessTree", () => {
};
const spawnFn = vi.fn(() => killer);
const child = { pid: 1234, kill: vi.fn() };
const onComplete = vi.fn();
const onRootTerminated = vi.fn();
killProcessTree(child as any, "SIGTERM", {
platform: "win32",
spawnFn: spawnFn as any,
onComplete,
onRootTerminated,
});
expect(String(spawnFn.mock.calls[0][0]).toLowerCase()).toMatch(/powershell\.exe$/);
expect(spawnFn).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining(["-NoProfile", "-EncodedCommand"]), {
stdio: ["pipe", "pipe", "ignore"],
windowsHide: true,
timeout: 5_000,
});
expect(killer.unref).toHaveBeenCalled();
// The bounded helper owns the close receipt, even after its pipes reach EOF.
expect(killer.unref).not.toHaveBeenCalled();
expect(child.kill).not.toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();
expect(onRootTerminated).not.toHaveBeenCalled();
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
callbacks.get("close")?.(1);
expect(onComplete).toHaveBeenCalledOnce();
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(warning).toHaveBeenCalledWith("[sandbox]", "Shell process tree termination failed", {
platform: "win32", fallback: "direct_child_only",
});
expect(onComplete).toHaveBeenCalledTimes(1);
expect(onRootTerminated).not.toHaveBeenCalled();
callbacks.get("close")?.(0);
expect(onComplete).toHaveBeenCalledTimes(1);
expect(child.kill).toHaveBeenCalledTimes(1);
expect(warning).toHaveBeenCalledTimes(1);
expect(onRootTerminated).not.toHaveBeenCalled();
} finally { warning.mockRestore(); }
});
+65 -685
View File
@@ -3,7 +3,7 @@ import path from "node:path";
import fs from "node:fs/promises";
import os from "node:os";
import { createExecutionPlanTool, defineTool, toToolDefinition, getBuiltinTools } from "../src/tools/index.js";
import { Session } from "../src/agent/session.js";
import { Session, EXECUTION_PLAN_MAX_STEPS, EXECUTION_PLAN_MAX_STEP_CHARS } from "../src/agent/session.js";
import { DEFAULT_BASH_TIMEOUT_MS, normalizeBashTimeoutMs } from "../src/tools/builtin.js";
import { compactGitHubReadme } from "../src/tools/github-repository-fetch.js";
import { MAX_WEB_FETCH_RESPONSE_BYTES } from "../src/tools/web-fetch.js";
@@ -106,699 +106,81 @@ describe("Tools", () => {
expect(properties).not.toHaveProperty("status");
});
it("creates a plan directly from the first complete snapshot", async () => {
function fixture() {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the task" }]);
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
session.beginUserTurn([{ type: 'text', text: 'Inspect and deliver the requested work' }]);
const tool = createExecutionPlanTool({ get: () => session.getExecutionPlan(),
update: update => session.updateExecutionPlan(update), clear: () => session.clearExecutionPlan() });
return { session, call: (plan: unknown, extra = {}) => tool.execute({ plan, ...extra }, { state: {} }) };
}
const inferred = await tool.execute({
plan: [{ step: "Complete the work", status: "working" }],
}, context);
expect(inferred.isError).toBeUndefined();
expect(JSON.parse(inferred.content)).toMatchObject({
action: "update",
action_inferred: true,
revision: 1,
updated_step_ids: [1],
});
expect(session.getExecutionPlan()?.steps[0].status).toBe("in_progress");
it('creates and advances a complete plan snapshot, preserves IDs and makes replay a no-op', async () => {
const { session, call } = fixture();
expect((await call([{ step: 'Inspect', status: 'in_progress' }, { step: 'Deliver', status: 'pending' }])).isError).toBeUndefined();
const first = session.getExecutionPlan()!;
const next = [{ step: 'Inspect', status: 'completed' }, { step: 'Deliver', status: 'in_progress' }, { step: 'Verify', status: 'pending' }];
expect((await call(next)).isError).toBeUndefined();
const advanced = session.getExecutionPlan()!;
expect(advanced.steps.slice(0, 2).map(step => step.id)).toEqual(first.steps.map(step => step.id));
expect(advanced.steps.map(step => step.status)).toEqual(['completed', 'in_progress', 'pending']);
expect(JSON.parse((await call(next)).content)).toMatchObject({ unchanged: true, revision: advanced.revision });
});
it("repairs a complete guarded plan mislabeled as set_status", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the staged task" }]);
session.updateExecutionPlan({
steps: [
{ step: "Inspect the project", status: "completed" },
{ step: "Summarize the structure", status: "in_progress" },
],
});
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
const repaired = await tool.execute({
action: "set_status",
plan: [
{ step: "Inspect the project", status: "completed" },
{ step: "Summarize the structure", status: "completed" },
],
}, context);
expect(repaired.isError).toBeUndefined();
expect(JSON.parse(repaired.content)).toMatchObject({
action: "update",
action_inferred: true,
});
expect(session.getExecutionPlan()?.steps.map((step) => step.status))
.toEqual(["completed", "completed"]);
});
it("does not repair ambiguous set_status payloads with one narrow field present", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the staged task" }]);
session.updateExecutionPlan({
steps: [{ step: "Complete the work", status: "in_progress" }],
});
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
const plan = [{ step: "Complete the work", status: "completed" }];
const missingStatus = await tool.execute({
action: "set_status",
step_id: 1,
plan,
}, context);
const missingStepId = await tool.execute({
action: "set_status",
status: "completed",
plan,
}, context);
expect(missingStatus).toMatchObject({ isError: true });
expect(JSON.parse(missingStatus.content)).toMatchObject({
ok: false,
error_code: "PLAN_STATUS_INVALID",
message: "manage_execution_plan set_status requires a valid status",
current_revision: 1,
current_steps: [{ id: 1, step: "Complete the work", status: "in_progress" }],
});
expect(missingStepId).toMatchObject({ isError: true });
expect(JSON.parse(missingStepId.content)).toMatchObject({
ok: false,
error_code: "PLAN_STEP_ID_INVALID",
message: "manage_execution_plan set_status requires a positive integer step_id",
current_revision: 1,
});
expect(session.getExecutionPlan()?.steps[0].status).toBe("in_progress");
});
it("accepts replace as a legacy alias without bypassing plan guards", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the task" }]);
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
const result = await tool.execute({
action: "replace",
plan: [{ step: "Complete the work", status: "in_progress" }],
}, context);
expect(result.isError).toBeUndefined();
expect(JSON.parse(result.content)).toMatchObject({ action: "update", action_inferred: false });
expect(session.getExecutionPlan()?.steps[0].step).toBe("Complete the work");
});
it("downgrades a redundant replace_objective replay to a guarded status update", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the original task" }]);
session.updateExecutionPlan({
steps: [{ step: "Complete the work", status: "in_progress" }],
});
session.addMessage("user", [{ type: "text", text: "Also include the revised requirement" }]);
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
const revisedPlan = [
{ step: "Complete the work", status: "completed" },
{ step: "Include the revised requirement", status: "in_progress" },
];
const first = await tool.execute({
action: "update",
replace_objective: true,
plan: revisedPlan,
}, context);
const replay = await tool.execute({
action: "update",
replace_objective: true,
plan: revisedPlan,
}, context);
expect(first.isError).toBeUndefined();
expect(JSON.parse(first.content)).toMatchObject({ replace_objective_applied: true });
expect(replay.isError).toBeUndefined();
expect(JSON.parse(replay.content)).toMatchObject({ replace_objective_applied: false });
expect(session.getExecutionPlan()?.objective).toBe("Also include the revised requirement");
const unsafeReplay = await tool.execute({
action: "update",
replace_objective: true,
plan: [{ step: "Do less work", status: "completed" }],
}, context);
expect(unsafeReplay.isError).toBe(true);
expect(unsafeReplay.content).toContain("cannot remove or rename existing milestones");
});
it("advances statuses, appends work, and ignores a replay through complete snapshots", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the staged task" }]);
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
const initial = await tool.execute({
explanation: "Create the durable milestones",
plan: [
{ step: "Inspect the inputs", status: "in_progress" },
{ step: "Verify the result", status: "pending" },
],
}, context);
expect(JSON.parse(initial.content)).toMatchObject({
revision: 1,
step_ids: [1, 2],
updated_step_ids: [1, 2],
});
expect(initial.observations?.coordination).toEqual({ changed: true });
expect(JSON.parse(initial.content)).not.toHaveProperty("steps");
const nextSnapshot = [
{ step: "Inspect the inputs", status: "completed" },
{ step: "Verify the result", status: "in_progress" },
{ step: "Publish the result", status: "pending" },
] as const;
const advanced = await tool.execute({
explanation: "Inspection finished and publishing was discovered",
plan: nextSnapshot,
}, context);
expect(advanced.isError).toBeUndefined();
expect(JSON.parse(advanced.content)).toMatchObject({
action: "update",
action_inferred: true,
revision: 2,
step_count: 3,
step_ids: [1, 2, 3],
updated_step_ids: [1, 2, 3],
});
expect(session.getExecutionPlan()?.steps.map((item) => item.id)).toEqual([1, 2, 3]);
expect(session.getExecutionPlan()?.steps.map((item) => item.status))
.toEqual(["completed", "in_progress", "pending"]);
const replay = await tool.execute({
explanation: "A different explanation cannot manufacture progress",
plan: nextSnapshot,
}, context);
expect(replay.isError).toBeUndefined();
const replayReceipt = JSON.parse(replay.content);
expect(replayReceipt).toMatchObject({
revision: 2,
updated_step_ids: [],
unchanged: true,
});
expect(replay.observations?.coordination).toEqual({ changed: false });
expect(replayReceipt).not.toHaveProperty("do_not_retry");
expect(replayReceipt).not.toHaveProperty("next_action");
expect(session.getExecutionPlanAudit()).toHaveLength(2);
});
it("atomically updates several known statuses while preserving stable step ids", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the staged task" }]);
session.updateExecutionPlan({
steps: [
{ step: "Inspect the inputs", status: "in_progress" },
{ step: "Verify the evidence", status: "pending" },
{ step: "Publish the result", status: "pending" },
],
});
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
const result = await tool.execute({
action: "set_statuses",
updates: [
{ step_id: 1, status: "completed" },
{ step_id: "step_2", status: "in_progress" },
{ step_id: 3, status: "completed" },
],
}, context);
expect(result.isError).toBeUndefined();
expect(JSON.parse(result.content)).toMatchObject({
action: "set_statuses",
revision: 2,
requested_step_ids: [1, 2, 3],
updated_step_ids: [1, 2, 3],
unchanged_step_ids: [],
});
expect(session.getExecutionPlan()?.steps).toEqual([
{ id: 1, step: "Inspect the inputs", status: "completed" },
{ id: 2, step: "Verify the evidence", status: "in_progress" },
{ id: 3, step: "Publish the result", status: "completed" },
]);
});
it("accepts a legacy completed finish marker without turning Plan into a terminal tool", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete and verify the staged task" }]);
session.updateExecutionPlan({
steps: [
{ step: "Complete the work", status: "completed" },
{ step: "Verify the result", status: "in_progress" },
],
});
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const result = await tool.execute({
action: "set_status",
step_id: 2,
status: "completed",
finish: "completed",
}, { state: {} });
expect(result.endTurn).toBeUndefined();
expect(result.synthesizeIfNoText).toBeUndefined();
expect(JSON.parse(result.content)).toMatchObject({
ok: true,
finish: "completed",
updated_step_ids: [2],
step_ids: [1, 2],
});
expect(JSON.parse(result.content)).not.toHaveProperty("terminal");
expect(JSON.parse(result.content)).not.toHaveProperty("steps");
expect(session.getExecutionPlan()?.steps.map((step) => step.status))
.toEqual(["completed", "completed"]);
});
it("rejects an invalid terminal state before changing any plan status", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the staged task" }]);
session.updateExecutionPlan({
steps: [
{ step: "Complete the work", status: "in_progress" },
{ step: "Verify the result", status: "pending" },
],
});
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const result = await tool.execute({
action: "set_status",
step_id: 1,
status: "completed",
finish: "completed",
}, { state: {} });
expect(result).toMatchObject({ isError: true });
expect(JSON.parse(result.content)).toMatchObject({
error_code: "PLAN_FINISH_REJECTED",
});
expect(session.getExecutionPlan()?.steps.map((step) => step.status))
.toEqual(["in_progress", "pending"]);
});
it("accepts a legacy blocked finish marker only when no milestone remains in progress", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Run until an external approval is required" }]);
session.updateExecutionPlan({
steps: [
{ step: "Prepare the request", status: "completed" },
{ step: "Receive external approval", status: "in_progress" },
],
});
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const result = await tool.execute({
action: "set_status",
step_id: 2,
status: "blocked",
finish: "blocked",
}, { state: {} });
expect(result.endTurn).toBeUndefined();
expect(result.synthesizeIfNoText).toBeUndefined();
expect(JSON.parse(result.content)).toMatchObject({ finish: "blocked" });
expect(JSON.parse(result.content)).not.toHaveProperty("terminal");
expect(session.getExecutionPlan()?.steps.map((step) => step.status))
.toEqual(["completed", "blocked"]);
});
it("infers unambiguous narrow actions without weakening payload validation", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the staged task" }]);
session.updateExecutionPlan({
steps: [
{ step: "Inspect the inputs", status: "in_progress" },
{ step: "Verify the result", status: "pending" },
],
});
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
const batch = await tool.execute({
updates: [
{ step_id: 1, status: "completed" },
{ step_id: 2, status: "in_progress" },
],
}, context);
expect(batch.isError).toBeUndefined();
expect(JSON.parse(batch.content)).toMatchObject({
action: "set_statuses",
updated_step_ids: [1, 2],
});
const status = await tool.execute({ step_id: 2, status: "completed" }, context);
expect(status.isError).toBeUndefined();
expect(JSON.parse(status.content)).toMatchObject({
action: "set_status",
step_id: 2,
});
const appended = await tool.execute({ step: "Publish the result" }, context);
expect(appended.isError).toBeUndefined();
expect(JSON.parse(appended.content)).toMatchObject({
action: "append_step",
appended_step_id: 3,
});
const ambiguous = await tool.execute({
updates: [{ step_id: 1, status: "blocked" }],
step_id: 1,
status: "blocked",
}, context);
expect(ambiguous.isError).toBe(true);
expect(JSON.parse(ambiguous.content)).toMatchObject({
error_code: "PLAN_ACTION_INVALID",
});
expect(session.getExecutionPlan()?.steps[0].status).toBe("completed");
});
it("rejects an invalid status batch without partially changing the plan", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the staged task" }]);
session.updateExecutionPlan({
steps: [
{ step: "Inspect the inputs", status: "in_progress" },
{ step: "Verify the evidence", status: "pending" },
{ step: "Publish the result", status: "pending" },
],
});
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
const original = session.getExecutionPlan();
const invalidBatches = [
{
updates: [
{ step_id: 1, status: "completed" },
{ step_id: "step_1", status: "blocked" },
],
errorCode: "PLAN_STEP_ID_DUPLICATE",
},
{
updates: [
{ step_id: 2, status: "in_progress" },
{ step_id: 3, status: "in_progress" },
],
errorCode: "PLAN_MULTIPLE_IN_PROGRESS",
},
{
updates: [
{ step_id: 1, status: "completed" },
{ step_id: 99, status: "blocked" },
],
errorCode: "PLAN_STEP_NOT_FOUND",
},
{
updates: [
{ step_id: 1, status: "completed" },
{ step_id: 2, status: "later" },
],
errorCode: "PLAN_STATUS_INVALID",
},
];
for (const invalid of invalidBatches) {
const result = await tool.execute({
action: "set_statuses",
updates: invalid.updates,
}, context);
expect(result).toMatchObject({ isError: true });
expect(JSON.parse(result.content)).toMatchObject({
ok: false,
error_code: invalid.errorCode,
current_revision: original?.revision,
});
expect(session.getExecutionPlan()).toEqual(original);
}
});
it("acknowledges capacity without failing or rewriting the durable plan", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the long staged task" }]);
const initialSteps = Array.from({ length: 12 }, (_, index) => ({
step: `Milestone ${index + 1}`,
status: index === 11 ? "in_progress" as const : "completed" as const,
}));
session.updateExecutionPlan({ steps: initialSteps });
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
const appended = await tool.execute({
action: "append_step",
step: "Thirteenth bookkeeping milestone",
status: "pending",
}, context);
expect(appended.isError).toBeUndefined();
const appendReceipt = JSON.parse(appended.content);
expect(appendReceipt).toMatchObject({
ok: true,
action: "append_step",
step_count: 12,
appended: false,
capacity_reached: true,
max_steps: 12,
});
expect(appendReceipt).not.toHaveProperty("do_not_retry");
expect(appendReceipt).not.toHaveProperty("next_action");
expect(session.getExecutionPlan()?.steps.map((item) => item.step))
.toEqual(initialSteps.map((item) => item.step));
const replayedFullPlan = await tool.execute({
action: "update",
plan: [
...initialSteps,
{ step: "Thirteenth bookkeeping milestone", status: "pending" },
],
}, context);
expect(replayedFullPlan.isError).toBeUndefined();
const replayedFullPlanReceipt = JSON.parse(replayedFullPlan.content);
expect(replayedFullPlanReceipt).toMatchObject({
ok: true,
action: "update",
step_count: 12,
updated: false,
capacity_reached: true,
requested_step_count: 13,
});
expect(replayedFullPlanReceipt).not.toHaveProperty("do_not_retry");
expect(replayedFullPlanReceipt).not.toHaveProperty("next_action");
expect(session.getExecutionPlan()?.steps.map((item) => item.step))
.toEqual(initialSteps.map((item) => item.step));
session.addMessage("user", [{ type: "text", text: "Replace this with a new objective" }]);
const oversizedNewObjective = await tool.execute({
action: "update",
replace_objective: true,
plan: [
...initialSteps,
{ step: "Thirteenth bookkeeping milestone", status: "pending" },
],
}, context);
expect(oversizedNewObjective.isError).toBe(true);
expect(JSON.parse(oversizedNewObjective.content)).toMatchObject({
ok: false,
error_code: "PLAN_UPDATE_REJECTED",
});
expect(oversizedNewObjective.content).toContain("accepts at most 12 steps");
expect(session.getExecutionPlan()?.steps.map((item) => item.step))
.toEqual(initialSteps.map((item) => item.step));
});
it("normalizes multiple in-progress milestones without spending a retry round", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the staged task" }]);
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
const result = await tool.execute({
action: "update",
plan: [
{ step: "Inspect the inputs", status: "in_progress" },
{ step: "Verify the result", status: "in_progress" },
],
}, context);
expect(result.isError).toBeUndefined();
expect(JSON.parse(result.content)).toMatchObject({
normalized_statuses: [{ step_id: 2, from: "in_progress", to: "pending" }],
});
expect(session.getExecutionPlan()?.steps.map((step) => step.status))
.toEqual(["in_progress", "pending"]);
const switched = await tool.execute({
action: "set_status",
step_id: 2,
status: "in_progress",
}, context);
expect(switched.isError).toBeUndefined();
expect(JSON.parse(switched.content)).toMatchObject({
normalized_statuses: [{ step_id: 1, from: "in_progress", to: "pending" }],
});
expect(session.getExecutionPlan()?.steps.map((step) => step.status))
.toEqual(["pending", "in_progress"]);
});
it("rejects malformed milestone entries without changing the current plan", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the staged task" }]);
session.updateExecutionPlan({
steps: [
{ step: "Inspect the inputs", status: "completed" },
{ step: "Verify the result", status: "in_progress" },
],
});
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
it.each(['clear', 'replace', 'update', 'set_status', 'set_statuses', 'append_step'])('rejects retired action %s without changing the plan', async action => {
const { session, call } = fixture();
const plan = [{ step: 'Inspect', status: 'in_progress' }];
await call(plan);
const before = session.getExecutionPlan();
const result = await tool.execute({
action: "update",
plan: ["Inspect the inputs", { step: "Verify the result", status: "completed" }],
}, { state: {} });
expect(result).toMatchObject({ isError: true });
expect(JSON.parse(result.content)).toMatchObject({
ok: false,
error_code: "PLAN_UPDATE_REJECTED",
current_revision: before?.revision,
current_steps: before?.steps,
});
expect(result.content).toMatch(/step.*object/i);
expect((await call(plan, { action })).isError).toBe(true);
expect(session.getExecutionPlan()).toEqual(before);
});
it("accepts detailed milestone labels beyond the model-facing anchor budget", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the detailed task" }]);
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
const longStep = `Inspect the production evidence and preserve every accepted constraint ${"detail ".repeat(60)}`.trim();
expect(longStep.length).toBeGreaterThan(180);
const result = await tool.execute({
action: "update",
plan: [{ step: longStep, status: "in_progress" }],
}, context);
expect(result.isError).toBeUndefined();
expect(session.getExecutionPlan()?.steps[0].step).toBe(longStep);
session.applyActiveCheckpointSummary("Detailed milestone recorded.", session.length - 1);
const modelView = JSON.stringify(session.getMessagesForModel());
expect(modelView).toContain("chars omitted");
expect(modelView.length).toBeLessThan(longStep.length + 2_000);
it.each(['finish', 'replace_objective', 'step_id', 'step', 'updates', 'status'])('rejects retired field %s even alongside a valid snapshot', async field => {
const { session, call } = fixture();
expect((await call([{ step: 'Inspect', status: 'in_progress' }], { [field]: 'old' })).isError).toBe(true);
expect(session.getExecutionPlan()).toBeUndefined();
});
it("accepts canonical step_<id> aliases without accepting lookalike labels", async () => {
const session = new Session();
session.beginUserTurn([{ type: "text", text: "Complete the staged task" }]);
session.updateExecutionPlan({
steps: [{ step: "Complete the work", status: "in_progress" }],
});
const tool = createExecutionPlanTool({
get: () => session.getExecutionPlan(),
update: (update) => session.updateExecutionPlan(update),
clear: () => session.clearExecutionPlan(),
});
const context: ToolContext = { state: {} };
it.each(['working', 'todo', 'not_started', 'unknown', 'done', 'complete', 'in-progress'])('rejects the noncanonical status %s without mutation', async status => {
const { session, call } = fixture();
expect((await call([{ step: 'Inspect', status }])).isError).toBe(true);
expect(session.getExecutionPlan()).toBeUndefined();
});
const accepted = await tool.execute({
action: "set_status",
step_id: "step_1",
status: "completed",
}, context);
expect(accepted.isError).toBeUndefined();
expect(JSON.parse(accepted.content)).toMatchObject({
action: "set_status",
step_id: 1,
});
expect(session.getExecutionPlan()?.steps[0].status).toBe("completed");
for (const stepId of ["step_0", "step_1_extra", "phase_1"]) {
const rejected = await tool.execute({
action: "set_status",
step_id: stepId,
status: "in_progress",
}, context);
expect(rejected).toMatchObject({ isError: true });
expect(rejected.content).toContain("requires a positive integer step_id");
it('preserves milestones and completed work when rejecting a destructive snapshot', async () => {
const { session, call } = fixture();
await call([{ step: 'Inspect', status: 'completed' }, { step: 'Deliver', status: 'in_progress' }]);
const before = session.getExecutionPlan();
for (const plan of [[{ step: 'Renamed', status: 'completed' }], [{ step: 'Inspect', status: 'pending' }, { step: 'Deliver', status: 'in_progress' }]]) {
expect((await call(plan)).isError).toBe(true);
expect(session.getExecutionPlan()).toEqual(before);
}
expect(session.getExecutionPlan()?.steps[0].status).toBe("completed");
});
it('retains blocked state and normalizes concurrent active steps through the owning session', async () => {
const { session, call } = fixture();
expect((await call([{ step: 'Inspect', status: 'in_progress' }, { step: 'Deliver', status: 'in_progress' }])).isError).toBeUndefined();
expect(session.getExecutionPlan()!.steps.map(step => step.status)).toEqual(['in_progress', 'pending']);
expect((await call([{ step: 'Inspect', status: 'blocked' }, { step: 'Deliver', status: 'pending' }])).isError).toBeUndefined();
expect(session.getExecutionPlan()!.steps[0].status).toBe('blocked');
});
it('preserves the full plan at capacity and rejects oversized milestone labels without mutation', async () => {
const { session, call } = fixture();
const invalid = await call([{ step: 'x'.repeat(EXECUTION_PLAN_MAX_STEP_CHARS + 1), status: 'pending' }]);
expect(invalid.isError).toBe(true);
expect(session.getExecutionPlan()).toBeUndefined();
const full = Array.from({ length: EXECUTION_PLAN_MAX_STEPS }, (_, i) => ({ step: 'Milestone ' + i, status: 'pending' }));
expect((await call(full)).isError).toBeUndefined();
const before = session.getExecutionPlan();
expect(JSON.parse((await call([...full, { step: 'Overflow', status: 'pending' }])).content)).toMatchObject({ capacity_reached: true, updated: false });
expect(session.getExecutionPlan()).toEqual(before);
});
it('rejects missing, empty and malformed snapshots without creating state', async () => {
const { session, call } = fixture();
for (const plan of [undefined, [], [null], [{ step: 'Inspect', status: 'completed', step_id: 1 }]]) expect((await call(plan)).isError).toBe(true);
expect(session.getExecutionPlan()).toBeUndefined();
});
});
@@ -1828,13 +1210,11 @@ describe("Tools", () => {
});
describe("bash tool", () => {
it("uses the long default only for missing or legacy-synthetic timeout values", () => {
it("uses the long default for invalid values and preserves explicit timeouts", () => {
expect(normalizeBashTimeoutMs(undefined)).toBe(DEFAULT_BASH_TIMEOUT_MS);
expect(normalizeBashTimeoutMs(-1)).toBe(DEFAULT_BASH_TIMEOUT_MS);
expect(normalizeBashTimeoutMs(30_000)).toBe(30_000);
expect(normalizeBashTimeoutMs(300_000)).toBe(300_000);
expect(normalizeBashTimeoutMs(30_000, { legacyDefault: true })).toBe(DEFAULT_BASH_TIMEOUT_MS);
expect(normalizeBashTimeoutMs(300_000, { legacyDefault: true })).toBe(DEFAULT_BASH_TIMEOUT_MS);
expect(normalizeBashTimeoutMs(5_000)).toBe(5_000);
});
@@ -0,0 +1,218 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, expect, it, vi } from "vitest";
import { killProcessTree } from "../src/sandbox/executor.js";
const helpers: ReturnType<typeof fixture>[] = [];
function fixture(pid = 1234) {
return Object.assign(new EventEmitter(), {
pid, exitCode: null as number | null, signalCode: null,
stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(),
kill: vi.fn(() => true), unref: vi.fn(),
});
}
function setup() {
const spawnFn = vi.fn(() => { const helper = fixture(); helpers.push(helper); return helper; });
const add = (pid: number) => {
const child = fixture(pid), done = vi.fn(), terminated = vi.fn();
killProcessTree(child as any, "SIGTERM", { platform: "win32", spawnFn: spawnFn as any,
onComplete: done, onRootTerminated: terminated });
return { child, done, terminated };
};
return { spawnFn, add };
}
afterEach(() => {
for (const helper of helpers.splice(0)) helper.emit("close", 0);
vi.useRealTimers(); vi.restoreAllMocks();
});
it("delivers the cleanup receipt to a standalone caller after helper pipes close", () => {
const helper = `process.stdout.write('accepting\\n');
process.stdin.once('data', () => {
process.stdout.write('ready:0\\n');
process.stdin.once('data', () => {
process.stdin.destroy();
process.stdout.end('done:0:1\\n');
setTimeout(() => process.exit(0), 200);
});
});`;
const module = pathToFileURL(path.resolve("src/core-agent/src/sandbox/windows-cleanup-batch.ts")).href;
const parent = `import { spawn } from 'node:child_process';
import { queueWindowsTreeCleanup } from ${JSON.stringify(module)};
await new Promise((resolve, reject) => queueWindowsTreeCleanup({pid: 1234}, 5000,
() => spawn(process.execPath, ['-e', ${JSON.stringify(helper)}], {stdio:['pipe','pipe','ignore']}),
(failed, terminated) => failed || !terminated ? reject(new Error('missing cleanup receipt')) : resolve()));
process.stdout.write('cleanup-complete\\n');`;
const result = spawnSync(process.env.ORKAS_TEST_NODE || process.execPath,
['--import', 'tsx', '--input-type=module', '-e', parent], {
encoding: 'utf8', timeout: 15_000, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
});
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
// Node 26 reports tsx's deprecated loader API. Retain that exact diagnostic
// visibly; every other stderr byte still fails this standalone receipt check.
const loaderNotice = '(node:PID) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.\n'
+ '(Use `node --trace-deprecation ...` to show where the warning was created)\n';
expect(['', loaderNotice]).toContain(result.stderr.replace(/\(node:\d+\)/g, '(node:PID)'));
if (result.stderr) console.warn(result.stderr.trimEnd());
expect(result.stdout).toBe('cleanup-complete\n');
});
it("shares startup across simultaneous cancellations and keeps root authorization separate", () => {
const { spawnFn, add } = setup();
const live = add(1234), exited = add(5678);
expect(spawnFn).toHaveBeenCalledTimes(1);
const helper = helpers.at(-1)!;
const input = vi.spyOn(helper.stdin, "write");
exited.child.exitCode = 7;
helper.stdout.emit("data", Buffer.from("accepting\nready:0\nready:1\n"));
expect(input.mock.calls.map(call => call[0])).toContain("go:0\n");
expect(input.mock.calls.map(call => call[0])).toContain("cancel:1\n");
helper.stdout.emit("data", Buffer.from("done:0:1\ndone:1:0\n"));
expect(live.done).not.toHaveBeenCalled();
helper.emit("close", 0);
expect(live.terminated).toHaveBeenCalledTimes(1);
expect(exited.terminated).not.toHaveBeenCalled();
expect(live.done).toHaveBeenCalledTimes(1);
expect(exited.done).toHaveBeenCalledTimes(1);
expect(exited.child.kill).not.toHaveBeenCalled();
});
it("never renews an earlier deadline when another cancellation joins, and recovers after helper failure", async () => {
vi.useFakeTimers();
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
const { spawnFn, add } = setup();
const first = add(1234);
await vi.advanceTimersByTimeAsync(1000);
const second = add(5678);
await vi.advanceTimersByTimeAsync(4000);
expect(first.done).toHaveBeenCalledTimes(1);
expect(first.child.kill).toHaveBeenCalledTimes(1);
expect(second.done).not.toHaveBeenCalled();
const helper = helpers.at(-1)!;
helper.stdout.emit("data", Buffer.from("accepting\nready:0\nready:1\n"));
expect(helper.stdin.read().toString()).toContain("cancel:0\n");
helper.emit("error", new Error("private helper failure"));
expect(second.done).toHaveBeenCalledTimes(1);
expect(second.child.kill).toHaveBeenCalledTimes(1);
expect(first.terminated).not.toHaveBeenCalled();
expect(second.terminated).not.toHaveBeenCalled();
const recovered = add(9012);
expect(spawnFn).toHaveBeenCalledTimes(2);
const next = helpers.at(-1)!;
next.stdout.emit("data", Buffer.from("accepting\nready:0\ndone:0:1\n"));
next.emit("close", 0);
expect(recovered.terminated).toHaveBeenCalledTimes(1);
expect(warning).toHaveBeenCalledTimes(2);
expect(JSON.stringify(warning.mock.calls)).not.toContain("private helper failure");
});
it("does not admit a late cancellation to a sweep already in progress", () => {
const { spawnFn, add } = setup();
add(1234);
const earlier = helpers.at(-1)!;
earlier.stdout.emit("data", Buffer.from("accepting\nready:0\n"));
const later = add(5678);
expect(spawnFn).toHaveBeenCalledTimes(2);
earlier.stdout.emit("data", Buffer.from("done:0:1\n"));
earlier.emit("close", 0);
helpers.at(-1)!.stdout.emit("data", Buffer.from("accepting\nready:0\ndone:0:1\n"));
helpers.at(-1)!.emit("close", 0);
expect(later.terminated).toHaveBeenCalledTimes(1);
});
it("reports one failed tree without misattributing or discarding a sibling's complete receipt", () => {
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
const { add } = setup();
const failed = add(1234), succeeded = add(5678);
const helper = helpers.at(-1)!;
helper.stdout.emit("data", Buffer.from("accepting\nready:0\nready:1\nfailed:0\ndone:1:1\n"));
helper.emit("close", 0);
expect(failed.child.kill).toHaveBeenCalledTimes(1);
expect(failed.terminated).not.toHaveBeenCalled();
expect(succeeded.child.kill).not.toHaveBeenCalled();
expect(succeeded.terminated).toHaveBeenCalledTimes(1);
expect(warning).toHaveBeenCalledTimes(1);
});
it.each(["missing", "truncated", "oversized", "duplicate"])("rejects incomplete or invalid helper evidence: %s", outcome => {
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
const { add } = setup();
const item = add(1234);
const helper = helpers.at(-1)!;
helper.stdout.emit("data", Buffer.from("accepting\nready:0\n"));
if (outcome === "truncated") helper.stdout.emit("data", Buffer.from("done:0:1"));
if (outcome === "oversized") helper.stdout.emit("data", Buffer.alloc(8193, 120));
if (outcome === "duplicate") helper.stdout.emit("data", Buffer.from("done:0:1\ndone:0:1\n"));
helper.emit("close", 0);
expect(item.terminated).not.toHaveBeenCalled();
expect(item.done).toHaveBeenCalledTimes(1);
expect(item.child.kill).toHaveBeenCalledTimes(1);
expect(warning).toHaveBeenCalledTimes(1);
});
it("bounds batch admission and reaps a silent helper when every independent deadline expires", async () => {
vi.useFakeTimers();
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
const { spawnFn, add } = setup();
const items = Array.from({ length: 65 }, (_, index) => add(1000 + index));
expect(spawnFn).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(4999);
expect(items.every(item => item.done.mock.calls.length === 0)).toBe(true);
await vi.advanceTimersByTimeAsync(1);
expect(items.every(item => item.done.mock.calls.length === 1 && item.child.kill.mock.calls.length === 1)).toBe(true);
expect(helpers.every(helper => helper.kill.mock.calls.length === 1)).toBe(true);
expect(warning).toHaveBeenCalledTimes(65);
});
it("still reaps a helper that reports failure but never closes", async () => {
vi.useFakeTimers();
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
const { add } = setup();
const item = add(1234);
const helper = helpers.at(-1)!;
helper.stdout.emit("data", Buffer.from("accepting\nfailed:0\n"));
await vi.advanceTimersByTimeAsync(5000);
expect(item.done).toHaveBeenCalledTimes(1);
expect(helper.kill).toHaveBeenCalledTimes(1);
expect(warning).toHaveBeenCalledTimes(1);
});
it("rejects success arriving after the deadline even before the due timer is dispatched", () => {
vi.useFakeTimers();
const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
const { add } = setup();
const item = add(1234);
const helper = helpers.at(-1)!;
helper.stdout.emit("data", Buffer.from("accepting\nready:0\n"));
vi.setSystemTime(Date.now() + 5001);
helper.stdout.emit("data", Buffer.from("done:0:1\n"));
helper.emit("close", 0);
expect(item.terminated).not.toHaveBeenCalled();
expect(item.done).toHaveBeenCalledTimes(1);
expect(warning).toHaveBeenCalledTimes(1);
});
it("seals wedged startup at its first expired request instead of renewing helper lifetime", async () => {
vi.useFakeTimers();
vi.spyOn(console, "warn").mockImplementation(() => {});
const { add, spawnFn } = setup();
add(1234);
const first = helpers.at(-1)!;
await vi.advanceTimersByTimeAsync(1000);
const joined = add(5678);
await vi.advanceTimersByTimeAsync(4000);
const later = add(9012);
const second = helpers.at(-1)!;
expect(spawnFn).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1000);
expect(joined.done).toHaveBeenCalledTimes(1);
expect(first.kill).toHaveBeenCalledTimes(1);
expect(later.done).not.toHaveBeenCalled();
second.stdout.emit("data", Buffer.from("accepting\nready:0\ndone:0:1\n"));
second.emit("close", 0);
expect(later.terminated).toHaveBeenCalledTimes(1);
});
+67 -1
View File
@@ -250,6 +250,41 @@ function sanitizePublicDiagnostic(value: unknown): string {
: sanitized;
}
const MAX_PUBLIC_STDERR_EXCERPT_LINES = 3;
const MAX_PUBLIC_STDERR_EXCERPT_CHARS = 400;
/** The CLI's own last words before failing: the line that says to sign in or
* fix a setting. The tail was already redacted and bounded at the runner
* boundary; this keeps its last lines without terminal colors. */
function publicStderrExcerpt(tail: unknown): string | undefined {
if (typeof tail !== 'string') return undefined;
const lines = tail.split(/\r?\n/);
// A bounded tail starts mid-line; an unbounded one starts with a full line.
if (tail.startsWith('…') && lines.length > 1) lines.shift();
const text = lines
// eslint-disable-next-line no-control-regex
.map(line => line.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '').trim())
.filter(Boolean)
.slice(-MAX_PUBLIC_STDERR_EXCERPT_LINES)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
if (!text) return undefined;
return text.length > MAX_PUBLIC_STDERR_EXCERPT_CHARS
? `…${text.slice(-MAX_PUBLIC_STDERR_EXCERPT_CHARS)}`
: text;
}
/** Like `sanitizePublicDiagnostic` but keeps the end, where a CLI's stderr
* says why it stopped. The whole tail is redacted before it is bounded so a
* secret is never cut in half ahead of redaction. */
function sanitizePublicDiagnosticTail(value: unknown): string {
const sanitized = redactPaths(sanitizeLogTextForUpload(String(value ?? '')));
return sanitized.length > MAX_PUBLIC_DIAGNOSTIC_CHARS
? `…${sanitized.slice(-MAX_PUBLIC_DIAGNOSTIC_CHARS)}`
: sanitized;
}
/** Safe user-visible form of an authoritative CLI terminal error. The caller
* may display this text verbatim; only secrets/paths and the size bound are
* changed, never its meaning. */
@@ -604,7 +639,7 @@ export function redactPrivateLocalAgentEvent(event: LocalEvent, workingDir?: str
? { error: sanitizePublicDiagnostic(event.error) }
: {}),
...(typeof event.stderrTail === 'string'
? { stderrTail: sanitizePublicDiagnostic(event.stderrTail) }
? { stderrTail: sanitizePublicDiagnosticTail(event.stderrTail) }
: {}),
};
}
@@ -1160,11 +1195,23 @@ export interface RunCliAgentOpts {
onTodoTaskDeleted?: (taskId: string, projectId: string) => void;
}
/** Where a failed CLI run stopped, from structured run facts only. Its stderr
* explains why; that text is shown to the user, never uploaded. */
export type CliFailureCategory =
| 'cli_spawn_failed'
| 'cli_protocol_failed'
| 'cli_failed_before_output'
| 'cli_failed_after_output';
export interface RunCliAgentResult {
runId: string;
status: 'completed' | 'failed' | 'cancelled' | 'timeout' | 'missing_cli';
output?: string;
error?: string;
/** Present only for `failed`. */
failureCategory?: CliFailureCategory;
/** Redacted, bounded last lines the CLI wrote to stderr before failing. */
stderrExcerpt?: string;
cliError?: LocalCliEntry['error'] | 'model_unsupported' | 'parameter_unsupported';
cliPath?: string;
cliVersion?: string;
@@ -1191,6 +1238,11 @@ interface BackgroundMediaDownload {
const backgroundMediaDownloads = new Map<string, BackgroundMediaDownload>();
let backgroundMediaReservedBytes = 0;
/** Read-only app-lifecycle signal, including work that outlives a conversation turn. */
export function hasBackgroundWork(): boolean {
return backgroundRuns.size > 0 || backgroundMediaDownloads.size > 0;
}
function linkedAbortSignal(...parents: AbortSignal[]): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController();
const listeners = parents.map((parent) => {
@@ -1377,6 +1429,8 @@ export async function run(opts: RunCliAgentOpts): Promise<RunCliAgentResult> {
// Wrapper writes events to disk before forwarding upstream so that
// a renderer crash mid-run still leaves a complete jsonl trail.
let streamedOutput = '';
// Completed calls leave the active-call map; execution evidence must survive them.
let toolUseObserved = false;
let terminal: {
status: RunCliAgentResult['status'];
output?: string;
@@ -1390,6 +1444,7 @@ export async function run(opts: RunCliAgentOpts): Promise<RunCliAgentResult> {
retrySafe?: boolean;
bootstrapTimeout?: boolean;
cliError?: RunCliAgentResult['cliError'];
stderrTail?: string;
} | null = null;
// CLI dispatch session id. The per-session spill dir is anchored on this
// so sweep / read paths can find the file again.
@@ -1440,6 +1495,7 @@ export async function run(opts: RunCliAgentOpts): Promise<RunCliAgentResult> {
retrySafe: e.retrySafe === true,
bootstrapTimeout: e.bootstrapTimeout === true,
cliError: e.cliError === 'model_unsupported' || e.cliError === 'parameter_unsupported' ? e.cliError : undefined,
stderrTail: typeof e.stderrTail === 'string' ? e.stderrTail : undefined,
};
};
const materializeMediaItem = (
@@ -1491,6 +1547,7 @@ export async function run(opts: RunCliAgentOpts): Promise<RunCliAgentResult> {
if (e.type === 'tool-event') {
const callId = String(e.callId || '').trim();
const phase = String(e.phase || '').toLowerCase();
if (phase === 'use') toolUseObserved = true;
if (callId && phase === 'use' && !toolStartedAtByCallId.has(callId)) {
toolStartedAtByCallId.set(callId, eventAtMs);
} else if (callId && phase === 'result') {
@@ -2351,11 +2408,20 @@ export async function run(opts: RunCliAgentOpts): Promise<RunCliAgentResult> {
duration_ms: endedAtMs - startedAtMs,
diagnostics: summarizeLocalAgentRunForLog(runDiagnostics, endedAtMs),
});
const stderrExcerpt = terminal.status === 'failed' ? publicStderrExcerpt(terminal.stderrTail) : undefined;
return {
runId: handle.runId,
status: terminal.status,
output: finalOutput,
error: terminal.error,
...(terminal.status === 'failed' ? {
failureCategory: terminal.failureKind === 'cli_spawn' ? 'cli_spawn_failed'
: terminal.failureKind === 'cli_protocol' ? 'cli_protocol_failed'
// Completed tool calls still prove that this dispatch started work.
: streamedOutput.length === 0 && !toolUseObserved ? 'cli_failed_before_output'
: 'cli_failed_after_output',
} satisfies Pick<RunCliAgentResult, 'failureCategory'> : {}),
...(stderrExcerpt ? { stderrExcerpt } : {}),
...(terminal.cliError ? { cliError: terminal.cliError } : {}),
...(entry.path ? { cliPath: entry.path } : {}),
...(entry.version ? { cliVersion: entry.fullVersion || entry.version } : {}),
+2 -1
View File
@@ -1007,8 +1007,9 @@ function matchSystemPackageChange(cmd: string, args: string[]): boolean {
if (command === 'add-apt-repository') {
return !lower.some((arg) => arg === '--help' || arg === '-h' || arg === '--list');
}
// Executable names are arbitrary input, not inherited Object properties.
if (!Object.hasOwn(SYSTEM_PACKAGE_ACTIONS, command)) return false;
const mutating = SYSTEM_PACKAGE_ACTIONS[command];
if (!mutating) return false;
const action = firstRecognizedAction(args, mutating, SYSTEM_PACKAGE_READ_ACTIONS[command]);
return action !== null && mutating.has(action);
}
+68 -207
View File
@@ -16,6 +16,9 @@ import { safeId } from '../../storage';
import { conversationMessageReadFile } from '../../util/project-layout';
import * as chats from '../../features/chats';
import * as search from '../../features/search';
import { z } from 'zod';
const historyContract = require('../../../../bin/chat-history-contract.cjs');
export interface ChatHistoryToolsOpts {
userId: string;
@@ -41,7 +44,6 @@ const MAX_LATEST_MESSAGES = 30;
const DEFAULT_LATEST_MESSAGES = 10;
const MAX_CURRENT_SEARCH_CANDIDATES = 200;
const SCORE_EPSILON = 0.1;
const LEGACY_CHAT_READ_PAGE_KEYS = ['msg_index', 'window', 'limit', 'before_msg_index'] as const;
type ChatReadPage = {
msgIndex?: number;
@@ -55,41 +57,10 @@ type ChatReadPageResult =
| { page: ChatReadPage; error?: never }
| { page?: never; error: string };
function chatReadPageSchema(): Record<string, unknown> {
return {
type: 'object',
additionalProperties: false,
description: 'Read only: mode, optional index/count.',
properties: {
mode: {
type: 'string',
enum: ['latest', 'around', 'before', 'from'],
description: 'latest: tail; around: centered on index; before: backward; from: continue at index/record_id.',
},
index: {
type: 'integer',
minimum: 0,
description: 'Raw index for around/before/from; ignored for latest.',
},
count: {
type: 'integer',
minimum: 0,
description: 'Around radius 0-10; latest/before count 1-30; from remaining count. Defaults to 3 or 10.',
},
},
required: ['mode'],
};
}
/** Parse the provider-visible tagged page while retaining legacy flat fields
* for resumed conversations. A tagged page and old paging fields cannot be
* combined because that would restore the ambiguous provider contract. */
/** Parse the selected current message-page shape. */
function parseChatReadPage(input: Record<string, unknown>): ChatReadPageResult {
const rawPage = input.page;
if (rawPage !== undefined) {
if (LEGACY_CHAT_READ_PAGE_KEYS.some((key) => input[key] != null)) {
return { error: '`page` cannot be combined with legacy flat paging fields' };
}
if (!rawPage || typeof rawPage !== 'object' || Array.isArray(rawPage)) {
return { error: '`page` must be an object with mode, and index when required' };
}
@@ -115,17 +86,7 @@ function parseChatReadPage(input: Record<string, unknown>): ChatReadPageResult {
: { page: { beforeMsgIndex: index, ...(count !== undefined ? { limit: count } : {}) } };
}
if (input.msg_index != null && input.before_msg_index != null) {
return { error: '`msg_index` and `before_msg_index` cannot be combined' };
}
return {
page: {
...(input.msg_index != null ? { msgIndex: Number(input.msg_index) } : {}),
...(input.window != null ? { window: Number(input.window) } : {}),
...(input.limit != null ? { limit: Number(input.limit) } : {}),
...(input.before_msg_index != null ? { beforeMsgIndex: Number(input.before_msg_index) } : {}),
},
};
return { page: {} };
}
function previewOf(text: unknown): string {
@@ -383,48 +344,12 @@ function unavailableHistory(action: 'read' | 'search', reason: 'source_unavailab
}
function createChatSearchTool(opts: ChatHistoryToolsOpts): AgentTool {
const scopeEnum = [...allowedScopes(opts)];
const hasCrossConversationScope = scopeEnum.some((scope) => scope !== 'current');
const hasProjectScope = scopeEnum.includes('project');
const currentOnly = scopeEnum.length === 1 && scopeEnum[0] === 'current';
return {
name: 'chat_history',
executionMode: 'parallel',
// Internal executor: only the consolidated factory's description is exposed.
description: '',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search only: natural language or keywords with a discriminative name, phrase, id, or fact.',
},
k: {
type: 'number',
description: 'Search only: default 6, max 15; up to two hits/conversation.',
},
scope: {
type: 'string',
enum: scopeEnum,
description: hasProjectScope
? 'Search scope. current is host-bound to this conversation. Project includes only this project; use all only for explicit cross-project or non-project recall.'
: (hasCrossConversationScope
? 'Search scope. current is host-bound to this conversation; use all only for explicit cross-conversation recall.'
: 'Search scope. current is host-bound to this conversation.'),
},
...(hasCrossConversationScope
? {
include_current: {
type: 'boolean',
description: hasProjectScope
? 'Search: include this conversation; default false.'
: 'Search: include this conversation; default true.',
},
}
: {}),
},
required: currentOnly ? ['query', 'scope'] : ['query'],
},
inputSchema: { type: 'object', properties: {} },
async execute(input, ctx) {
const query = String(input.query ?? '').trim();
if (!query) return { content: 'chat_history(search): `query` is required', isError: true };
@@ -476,7 +401,7 @@ function createChatSearchTool(opts: ChatHistoryToolsOpts): AgentTool {
if (!hits.length) return { content: complete
? `${status}\nNo conversation-history results for "${query}".`
: `${status}\nNo matches in the indexed portion. This does not establish that the records are absent.`
+ (scope === 'current' ? '\nRead recent records: {"action":"read","scope":"current","page":{"mode":"latest"},"include_process":true}' : '') };
+ (scope === 'current' ? '\nRead recent records: {"action":"read","scope":"current","read":{"page":{"mode":"latest"},"include_process":true}}' : '') };
const scopeLabel = scope === 'current'
? 'current-conversation '
@@ -511,16 +436,16 @@ function createChatSearchTool(opts: ChatHistoryToolsOpts): AgentTool {
lines.push(` ${previewOf(h.snippet)}`);
if (h.process_snippet) lines.push(` process_match: ${previewOf(h.process_snippet)}`);
const readScope = scope;
lines.push(` read: ${JSON.stringify({ action: 'read', scope: readScope,
lines.push(` read: ${JSON.stringify(historyContract.locator({ action: 'read', scope: readScope,
...(readScope !== 'current' ? { cid } : {}),
...(h.msg_id ? { record_id: h.msg_id } : { page: { mode: 'around', index: msgIndex, count: 0 } }),
include_process: h.has_process === true,
})}`);
}))}`);
if (h.msg_id && Number.isSafeInteger(h.process_index) && Number(h.process_index) >= 0) {
lines.push(` process_read: ${JSON.stringify({ action: 'read', scope: readScope,
lines.push(` process_read: ${JSON.stringify(historyContract.locator({ action: 'read', scope: readScope,
...(readScope !== 'current' ? { cid } : {}), record_id: h.msg_id,
process_index: h.process_index,
})}`);
}))}`);
}
}
const budget = Number(ctx.state[RETRIEVAL_OUTPUT_BUDGET_KEY] ?? DEFAULT_INLINE_RESULT_TOKENS);
@@ -530,57 +455,19 @@ function createChatSearchTool(opts: ChatHistoryToolsOpts): AgentTool {
}
function createChatReadTool(opts: ChatHistoryToolsOpts): AgentTool {
const scopeEnum = [...allowedScopes(opts)];
const hasCrossConversationScope = scopeEnum.some((scope) => scope !== 'current');
const hasProjectScope = scopeEnum.includes('project');
const currentOnly = scopeEnum.length === 1 && scopeEnum[0] === 'current';
return {
name: 'chat_history',
executionMode: 'parallel',
// Internal executor: only the consolidated factory's description is exposed.
description: '',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
...(!currentOnly
? {
cid: {
type: 'string',
description: hasProjectScope
? 'Read only: conversation ID from search. Required when reading project/all; ignored for host-bound current.'
: 'Read only: conversation ID from search. Required when reading all; ignored for host-bound current.',
},
}
: {}),
page: chatReadPageSchema(),
record_id: { type: 'string', description: 'Read only: exact message ID from search/read.' },
turn_id: { type: 'string', description: 'Read only: user message ID or execution turn_id.' },
tool_call_id: { type: 'string', description: 'Read only: select a stored tool call and its input/output.' },
process_index: { type: 'integer', minimum: 0, description: 'Read only: public segment index from search; requires record_id, excludes page/output_cursor. cursor pages this segment.' },
output_cursor: { type: 'integer', minimum: 0, description: 'Full tool_call_id output: start at 0, continue returned cursor.' },
include_process: { type: 'boolean', description: 'Execution records: default true with tool_call_id, else false.' },
cursor: { type: 'integer', minimum: 0, description: 'Partial-read character cursor.' },
max_tokens: { type: 'integer', minimum: 1, maximum: DEFAULT_INLINE_RESULT_TOKENS, description: 'Page token budget; default 10K.' },
scope: {
type: 'string',
enum: scopeEnum,
description: hasProjectScope
? 'Read scope. current is host-bound to this conversation. Project includes only this project; other projects and non-project tasks require all.'
: (hasCrossConversationScope
? 'Read scope. current is host-bound to this conversation; all is for explicit cross-conversation recall.'
: 'Read scope. current is host-bound to this conversation.'),
},
},
required: currentOnly ? ['scope'] : [],
},
inputSchema: { type: 'object', properties: {} },
async execute(input, ctx) {
const processIndex = input.process_index;
if (processIndex !== undefined && (!Number.isSafeInteger(processIndex) || Number(processIndex) < 0
|| typeof input.record_id !== 'string' || !input.record_id.trim()
|| input.page !== undefined || LEGACY_CHAT_READ_PAGE_KEYS.some(key => input[key] != null)
|| input.page !== undefined
|| input.output_cursor !== undefined)) {
return { content: 'chat_history(read): process_index requires a non-negative safe integer and exact record_id; it cannot be combined with page, legacy paging or output_cursor.', isError: true };
return { content: 'chat_history(read): process_index requires a non-negative safe integer and exact record_id; it cannot be combined with page or output_cursor.', isError: true };
}
const pageResult = parseChatReadPage(input);
if (pageResult.error) {
@@ -702,14 +589,25 @@ function createChatReadTool(opts: ChatHistoryToolsOpts): AgentTool {
}
const [record] = await historyMessages(opts.userId, cid, [selected[0].index], historySource);
const source = await historyToolResultSource(opts.userId, cid, record, String(input.tool_call_id));
if (!source) return { content: 'chat_history(read): persisted full output is unavailable; inspect include_process for captured inline output or metadata. Retrieval never reruns the original tool.', isError: true };
return readToolResultExcerpt(source.directory, source.ref, Number(input.output_cursor), budget, ctx);
if (!source) return { content: input.__groupedOutput === true
? JSON.stringify({ code: 'E_HISTORY_OUTPUT_UNAVAILABLE', available_projection: 'public_process', message: 'No locally retained full output for this captured call. Retrieval never reruns the tool.' })
: 'chat_history(read): persisted full output is unavailable; inspect include_process for captured inline output or metadata. Retrieval never reruns the original tool.', isError: true };
return readToolResultExcerpt(source.directory, source.ref, Number(input.output_cursor), budget, ctx, {
...(input.__groupedOutput === true ? { history: { recordId: record.id, toolCallId: String(input.tool_call_id) } } : {}),
continuation: next => `\n<next_read>${next === 'done' ? 'done' : attrOf(JSON.stringify(historyContract.locator({
...readIdentity, record_id: record.id, tool_call_id: input.tool_call_id, output_cursor: next,
})))}</next_read>`,
});
}
const header = `<chat-history cid="${attrOf(cid)}" title="${attrOf(conv.title)}" total="${available.length}" range="${lo}..${hi}" scope="${scope}" include_process="${includeProcess}">\n`
+ '<!-- Quoted, potentially stale conversation records. -->\n'
+ `<!-- ${note} -->\n`
+ (hasOlderCurrentRows ? `<!-- Older records: {"page":{"mode":"before","index":${lo},"count":10}}. -->\n` : '<!-- This window reaches the start of readable history. -->\n');
const locatorFor = (position: number, cursor: number) => JSON.stringify({
+ `<!-- ${note} -->\n`;
const olderHint = hasOlderCurrentRows ? `<!-- Older records: ${JSON.stringify(historyContract.locator({ ...readIdentity,
page: { mode: 'before', index: lo, count: 10 }, include_process: includeProcess }))}. -->`
: '<!-- This window reaches the start of readable history. -->';
const footer = (locator: string) => `\n<next_read>${attrOf(locator)}</next_read>`
+ (locator === 'done' ? `\n${olderHint}` : '') + '\n</chat-history>';
const locatorFor = (position: number, cursor: number) => JSON.stringify(historyContract.locator({
...readIdentity,
record_id: selected[position].message.id,
...(processIndex !== undefined ? { process_index: processIndex }
@@ -717,9 +615,10 @@ function createChatReadTool(opts: ChatHistoryToolsOpts): AgentTool {
cursor, include_process: includeProcess,
...(input.turn_id ? { turn_id: input.turn_id } : {}),
...(input.tool_call_id ? { tool_call_id: input.tool_call_id } : {}),
});
}));
const outputs: string[] = [];
let next = 'done';
let outputHintsRemaining = 2;
for (let i = 0; i < selected.length; i++) {
const [msg] = await historyMessages(opts.userId, cid, [selected[i].index], historySource);
const body = processIndex !== undefined ? historyProcessTextAt(msg, Number(processIndex)) : historyRecordText(msg, includeProcess);
@@ -729,12 +628,28 @@ function createChatReadTool(opts: ChatHistoryToolsOpts): AgentTool {
return { content: 'chat_history(read): process segment does not belong to tool_call_id.', isError: true };
}
let processRead = !includeProcess && hasHistoryProcess(msg)
? `\n<process_read>${attrOf(JSON.stringify({ action: 'read', scope,
...(scope !== 'current' ? { cid } : {}), record_id: msg.id, include_process: true }))}</process_read>` : '';
const callId = item?.event?.data?.id;
if (typeof callId === 'string' && item?.event?.data?.result_path
&& await historyToolResultSource(opts.userId, cid, msg, callId)) {
processRead += `\n<output_read>${attrOf(JSON.stringify({ ...readIdentity, record_id: msg.id, tool_call_id: callId, output_cursor: 0 }))}</output_read>`;
? `\n<process_read>${attrOf(JSON.stringify(historyContract.locator({ action: 'read', scope,
...(scope !== 'current' ? { cid } : {}), record_id: msg.id, include_process: true })))}</process_read>` : '';
// A start event and its captured end share one call identity. Whole
// records also expose retained outputs, without requiring a second
// process lookup. Bound filesystem checks across the entire page.
const callIds: string[] = [];
if (item?.event?.stream === 'tool' && typeof item.event.data?.id === 'string') {
callIds.push(item.event.data.id);
} else if (processIndex === undefined && includeProcess) {
for (const entry of msg.process ?? []) {
const event = (entry as any)?.event;
if (event?.stream === 'tool' && event.data?.phase === 'end' && typeof event.data?.result_path === 'string'
&& typeof event.data?.id === 'string' && !callIds.includes(event.data.id)) callIds.push(event.data.id);
if (callIds.length >= outputHintsRemaining) break;
}
}
for (const callId of callIds) {
if (outputHintsRemaining <= 0) break;
outputHintsRemaining--;
if (await historyToolResultSource(opts.userId, cid, msg, callId)) {
processRead += `\n<output_read>${attrOf(JSON.stringify(historyContract.locator({ ...readIdentity, record_id: msg.id, tool_call_id: callId, output_cursor: 0 })))}</output_read>`;
}
}
const cursor = i === 0 ? boundedInt(input.cursor, 0, 0, Number.MAX_SAFE_INTEGER) : 0;
if (cursor > body.length) return { content: 'chat_history(read): cursor exceeds the selected record length.', isError: true };
@@ -742,7 +657,7 @@ function createChatReadTool(opts: ChatHistoryToolsOpts): AgentTool {
`<msg index="${selected[i].index}" id="${attrOf(msg.id)}" from="${attrOf(messageActor(msg))}" time="${attrOf(messageTime(msg))}" covered="${cursor}-${end}" next_cursor="${end < body.length ? end : 'done'}">\n${attrOf(text)}${processRead}\n</msg>`;
const nextLocator = (end: number) => locatorFor(i, end);
const render = (item: string, locator: string) => header + [...outputs, item].filter(Boolean).join('\n')
+ `\n<next_read>${attrOf(locator)}</next_read>\n</chat-history>`;
+ footer(locator);
const whole = envelope(body.slice(cursor), body.length);
const afterWhole = i + 1 < selected.length ? locatorFor(i + 1, 0) : 'done';
if (estimateToolResultTokens(render(whole, afterWhole)) <= budget) {
@@ -761,7 +676,7 @@ function createChatReadTool(opts: ChatHistoryToolsOpts): AgentTool {
next = nextLocator(low);
break;
}
const content = header + outputs.join('\n') + `\n<next_read>${attrOf(next)}</next_read>\n</chat-history>`;
const content = header + outputs.join('\n') + footer(next);
const fits = estimateToolResultTokens(content) <= budget;
return { content: fits ? content
: boundedRetrievalText('No history data delivered: insufficient tool-result budget. Retry the same record/cursor.', budget),
@@ -774,86 +689,32 @@ function createChatReadTool(opts: ChatHistoryToolsOpts): AgentTool {
type ChatHistoryAction = 'search' | 'read';
const CHAT_HISTORY_ACTION_FIELDS: Readonly<Record<ChatHistoryAction, ReadonlySet<string>>> = {
// `page` was part of the old provider-visible union schema. Retain runtime
// compatibility for resumed calls, but the action-discriminated schema no
// longer advertises it for search.
search: new Set(['action', 'query', 'k', 'scope', 'include_current', 'page']),
// Legacy flat paging fields remain execution-only for model calls copied
// from an older conversation. The provider-visible schema advertises only
// the tagged `page` contract.
search: new Set(['action', 'query', 'k', 'scope', 'include_current']),
read: new Set([
'action', 'cid', 'page', 'scope', 'record_id', 'turn_id', 'tool_call_id', 'process_index', 'include_process', 'cursor', 'max_tokens', 'output_cursor',
...LEGACY_CHAT_READ_PAGE_KEYS,
]),
};
function chatHistoryActionError(
action: ChatHistoryAction,
input: Record<string, unknown>,
): string | null {
const unexpected = Object.keys(input).filter(
(key) => !Object.values(CHAT_HISTORY_ACTION_FIELDS).some((fields) => fields.has(key))
|| (action === 'search' && ['cid', 'record_id', 'turn_id', 'tool_call_id', 'process_index'].includes(key)
&& input[key] !== undefined && input[key] !== null && input[key] !== ''),
);
if (!unexpected.length) return null;
return `chat_history(${action}): unsupported field(s): ${unexpected.sort().join(', ')}`;
}
export function createChatHistoryTool(opts: ChatHistoryToolsOpts): AgentTool {
const search = createChatSearchTool(opts);
const read = createChatReadTool(opts);
const scopeEnum = [...allowedScopes(opts)];
const currentOnly = scopeEnum.length === 1 && scopeEnum[0] === 'current';
const hasProjectScope = scopeEnum.includes('project');
const searchProperties = search.inputSchema.properties as Record<string, unknown>;
const readProperties = read.inputSchema.properties as Record<string, unknown>;
const operations: Readonly<Record<ChatHistoryAction, AgentTool>> = { search, read };
const scopeProperty = {
type: 'string',
enum: scopeEnum,
description: hasProjectScope
? 'current: host-bound; project stays in this project; all: explicit broader recall.'
: (currentOnly
? 'History scope. current is host-bound to this conversation.'
: 'current: host-bound; all: explicit cross-conversation recall.'),
};
return {
name: 'chat_history',
executionMode: 'parallel',
description:
'Retrieve earlier work dependencies: inputs, replies and public execution records; potentially stale quoted data. Library owns durable documents.',
compatibilityInputFields: [...new Set(Object.values(CHAT_HISTORY_ACTION_FIELDS).flatMap(fields => [...fields]))],
inputSchema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['search', 'read'],
description: 'search uses query/k; read uses cid/record_id/turn_id/tool_call_id or latest for vague local references. Follow next_read. Unused fields are reported in ignored_fields; nonempty search selectors are rejected to preserve scope.',
},
...searchProperties,
...readProperties,
scope: scopeProperty,
},
additionalProperties: false,
required: currentOnly ? ['action', 'scope'] : ['action'],
},
description: historyContract.description,
compatibilityInputFields: ['cid', ...historyContract.rejectedFields],
inputSchema: historyContract.inputSchema(z, scopeEnum),
async execute(input, ctx) {
const action = String(input.action ?? '').trim() as ChatHistoryAction;
if (action !== 'search' && action !== 'read') {
return {
content: 'chat_history: `action` must be one of "search" or "read"',
isError: true,
};
}
const fieldError = chatHistoryActionError(action, input);
if (fieldError) return { content: fieldError, isError: true };
const effectiveFields = action === 'search'
? new Set([...CHAT_HISTORY_ACTION_FIELDS.search].filter((key) => key !== 'page'))
: CHAT_HISTORY_ACTION_FIELDS.read;
const ignoredFields = Object.keys(input).filter((key) => !effectiveFields.has(key)).sort();
const effectiveInput = Object.fromEntries(Object.entries(input).filter(([key]) => effectiveFields.has(key)));
const projected = historyContract.project(z, input, scopeEnum);
if (projected.error) return { content: JSON.stringify({ tool: 'chat_history', ...projected.error }), isError: true };
const effective = projected.input as Record<string, unknown>;
const action = effective.action as ChatHistoryAction;
const effectiveFields = CHAT_HISTORY_ACTION_FIELDS[action];
const ignoredFields = projected.ignored;
const effectiveInput = Object.fromEntries(Object.entries(effective).filter(([key]) => effectiveFields.has(key)));
if (input.action === 'read_output') effectiveInput.__groupedOutput = true;
const notice = ignoredFields.length ? `ignored_fields: ${JSON.stringify(ignoredFields)}\n` : '';
const execute = async (ctx: ToolContext) => {
const budget = Number(ctx.state[RETRIEVAL_OUTPUT_BUDGET_KEY]);
@@ -485,7 +485,7 @@ export function classifyExternalMutationScript(sourceRaw: string, options?: { la
function inspectScript(source: string, depth: number, language?: 'python'): ExternalMutationFinding[] {
const code = depth < 4 ? scriptCodeMask(source) : new Uint8Array(source.length).fill(1);
const resources = language === 'python' ? pythonSqliteResources(source) : new Map<number, SqliteResource>();
const resources = language === 'python' ? pythonSqliteResources(source, code) : new Map<number, SqliteResource>();
const findings: ExternalMutationFinding[] = [];
const add = (finding: ExternalMutationFinding | null) => {
if (!finding) return;
@@ -498,7 +498,7 @@ function inspectScript(source: string, depth: number, language?: 'python'): Exte
if (finding) add({ ...finding, ...(resources.has(at) ? { resource: resources.get(at)! } : {}) });
};
const executeCalls = new RegExp('(?:\\.\\s*(?:execute|executemany|query)|\\bmysqli_query)\\s*\\(\\s*' + SCRIPT_LITERAL, 'gim');
const executeCalls = new RegExp('(?:\\.\\s*(?:execute|executemany|executescript|query)|\\bmysqli_query)\\s*\\(\\s*' + SCRIPT_LITERAL, 'gim');
for (const match of executableMatches(source, executeCalls, code)) addSql(matchedLiteral(match), match.index!);
const remoteExecCalls = new RegExp('\\.\\s*(?:exec_command|execCommand)\\s*\\(\\s*' + SCRIPT_LITERAL, 'gim');
@@ -510,7 +510,7 @@ function inspectScript(source: string, depth: number, language?: 'python'): Exte
}
}
const variableSink = /(?:\.\s*(?:execute|executemany|query|exec_command|execCommand)|\bmysqli_query)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\b/gim;
const variableSink = /(?:\.\s*(?:execute|executemany|executescript|query|exec_command|execCommand)|\bmysqli_query)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\b/gim;
for (const match of executableMatches(source, variableSink, code)) {
const value = assignedLiteral(source, match[1], code);
if (value) {
+72 -31
View File
@@ -42,6 +42,8 @@ import {
getCachedMeta,
kindOf,
NoTextError,
resolveTextEncoding,
TextEncodingMismatchError,
UnsupportedFileKindError,
} from '../../features/file_indexer';
import { userMarketplaceSkillsDir, userSkillsDir } from '../../paths';
@@ -548,18 +550,16 @@ function taggedReadRangeSchema(): Record<string, unknown> {
};
}
/** Parse the provider-visible tagged range while retaining old flat fields for
* already-running conversations and non-model callers. New and legacy forms
* cannot be combined because their precedence would otherwise be ambiguous. */
/** Parse the one provider-visible range shape before any file read. */
function parseReadAddress(input: Record<string, unknown>): ReadAddressResult {
const invalid = (error: string, reason: FileFailureDiagnostic['reason'], facts: Omit<FileFailureDiagnostic, 'code' | 'reason'> = {}): ReadAddressResult => ({
error, diagnostic: { code: 'E_BAD_INPUT', reason, stage: 'input', ...facts },
});
const rawRange = input.range;
if (LEGACY_READ_RANGE_KEYS.some((key) => input[key] !== undefined)) {
return invalid('flat range fields are unsupported; use range.unit/start/end', 'range_conflict');
}
if (rawRange !== undefined) {
if (LEGACY_READ_RANGE_KEYS.some((key) => input[key] !== undefined)) {
return invalid('`range` cannot be combined with legacy flat range fields', 'range_conflict');
}
if (!rawRange || typeof rawRange !== 'object' || Array.isArray(rawRange)) {
return invalid('`range` must be an object with unit, start, and end', 'range_shape');
}
@@ -594,23 +594,33 @@ function parseReadAddress(input: Record<string, unknown>): ReadAddressResult {
: { address: { charStart: numericStart, charEnd: numericEnd } };
}
const hasCharRange = typeof input.charStart === 'number' || typeof input.charEnd === 'number';
const hasLineRange = typeof input.lineStart === 'number' || typeof input.lineEnd === 'number';
if (hasCharRange && hasLineRange) {
return invalid('use either charStart/charEnd or lineStart/lineEnd, not both', 'range_conflict');
}
return {
address: {
...(typeof input.charStart === 'number' ? { charStart: input.charStart } : {}),
...(typeof input.charEnd === 'number' ? { charEnd: input.charEnd } : {}),
...(typeof input.lineStart === 'number' ? { lineStart: input.lineStart } : {}),
...(typeof input.lineEnd === 'number' ? { lineEnd: input.lineEnd } : {}),
},
};
return { address: {} };
}
// ── read_files item executor ──────────────────────────────────────────────
const READ_NOT_UTF8_RECOVERY = '<text-encoding-recovery>\n'
+ 'This file is not valid UTF-8, so the text below may be garbled. Do not infer meaning from garbled text. '
+ 'Read it again with `encoding` set to a likely encoding such as gb18030, big5, shift_jis or utf-16le, and check that the result reads correctly. '
+ 'If none works and most of the text below reads correctly, work from it and tell the user which parts are unreadable. '
+ 'Otherwise tell the user the file could not be decoded.\n'
+ '</text-encoding-recovery>';
const RAW_NOT_UTF8_RECOVERY = '<text-encoding-recovery>\n'
+ 'This file is not valid UTF-8. '
+ 'Read it again with `encoding` set to a likely encoding such as gb18030, big5, shift_jis or utf-16le, and check that the result reads correctly. '
+ 'If none works, read it without raw_text to see whether most of it is readable; if not, tell the user the file could not be decoded.\n'
+ '</text-encoding-recovery>';
function textEncodingMismatch(displayPath: string, encoding: string): ToolResult {
return {
content: errText(
'E_TEXT_ENCODING',
`${displayPath}: the bytes are not valid ${encoding}. Try another likely encoding, or read it without encoding to see whether most of it is readable.`,
),
isError: true,
};
}
/** Internal preparation only; page callbacks never escape read_files. */
type PreparedFileRead = ToolResult & {
textPage?: {
@@ -764,13 +774,28 @@ function createReadFileTool(
}
const bytes = await fs.promises.readFile(abs);
let content: string;
// Decoded text is not the file's bytes, so it carries no hash or edit evidence.
if (input.encoding && input.encoding !== 'utf-8') {
const encoding = String(input.encoding);
try {
content = new TextDecoder(encoding, { fatal: true }).decode(bytes);
} catch {
return textEncodingMismatch(displayPath, encoding);
}
return {
content: JSON.stringify({ path: displayPath, encoding, content, total_chars: content.length }),
observations: {
fileReads: [{ path: abs, charRange: [0, content.length], lineRange: [1, content.split('\n').length] }],
},
};
}
try {
content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
} catch {
return {
content: errText(
'E_RAW_TEXT_ENCODING',
`${displayPath}: raw_text requires valid UTF-8.`,
`${displayPath}: raw_text requires valid UTF-8.\n\n${RAW_NOT_UTF8_RECOVERY}`,
),
isError: true,
};
@@ -801,10 +826,11 @@ function createReadFileTool(
: kind === 'text'
? await statFile(opts.userId, abs)
: await withReadFilesExtractionSlot(() => statFile(opts.userId, abs));
const sourceBytes = meta?.bytes ?? sourceStat.size;
const attrs = [
`path="${displayPath}"`,
`kind="${kind}"`,
`bytes="${sourceStat.size}"`,
`bytes="${sourceBytes}"`,
...(meta?.totalChars !== undefined ? [`total_chars="${meta.totalChars}"`] : []),
...(meta?.extractionEmpty ? ['extraction="empty_pages"'] : []),
];
@@ -812,7 +838,7 @@ function createReadFileTool(
user_id: maskId(opts.userId),
path: logPathRef(abs),
kind,
bytes: sourceStat.size,
bytes: sourceBytes,
total_chars: meta?.totalChars,
extraction_empty: !!meta?.extractionEmpty,
});
@@ -849,6 +875,7 @@ function createReadFileTool(
// The shared estimator charges at least one token per four UTF-16
// units. No larger body can fit, even before headers and preludes.
maxContentChars: readFilesInlineTokenBudget(ctx, portableSkillDocument) * 4,
...(input.encoding ? { encoding: String(input.encoding) } : {}),
...(hasLineRange
? {
...(address.lineStart !== undefined ? { lineStart: address.lineStart } : {}),
@@ -871,7 +898,7 @@ function createReadFileTool(
const total = result.meta.totalChars ?? 0;
const cs = result.range.charStart;
const revision = kind === 'text' && result.sourceHash
? issueFileRevision(ctx, abs, sourceStat.size, result.sourceHash)
? issueFileRevision(ctx, abs, result.meta.bytes, result.sourceHash)
: '';
const runtimeBinding = resolvedPath.skillRef
? opts.skillRuntimeBindings?.get(resolvedPath.skillRef)
@@ -897,6 +924,7 @@ function createReadFileTool(
entryReadPrelude,
runtimeRequirementsPrelude,
executionReadPrelude,
result.notUtf8 ? READ_NOT_UTF8_RECOVERY : '',
].filter(Boolean).join('\n\n');
const renderPage = (body: string, includePrelude = true) => {
const ce = cs + body.length;
@@ -923,6 +951,7 @@ function createReadFileTool(
] : ['has_more="false"']),
...(result.sourceHash ? [`file_hash="${result.sourceHash}"`] : []),
...(revision ? [`revision="${revision}"`] : []),
...(result.encoding ? [`encoding="${result.encoding}"`] : []),
...(result.meta.extractionEmpty ? ['extraction="empty_pages"'] : []),
];
const header = `<file ${attrs.join(' ')}>`;
@@ -954,6 +983,8 @@ function createReadFileTool(
total_chars: total,
start_line: result.startLine,
end_line: lastLine,
...(result.encoding ? { encoding: result.encoding } : {}),
...(result.notUtf8 ? { not_utf8: true } : {}),
});
// skill_invoked attribution: when the LLM reads a SKILL.md body through
// read_files, the body is the progressive-disclosure "use this skill"
@@ -978,8 +1009,9 @@ function createReadFileTool(
}
// Stamp the read-state baseline so a later edit_file accepts an edit
// built on these bytes (read-before-edit) and rejects it if the file
// changed since (OCC). See read-tracker.ts.
recordRead(ctx, abs, undefined, result.sourceHash);
// changed since (OCC). See read-tracker.ts. A decoded page is not the
// file's bytes: an edit built on it would rewrite the source as UTF-8.
if (!result.encoding) recordRead(ctx, abs, undefined, result.sourceHash);
return {
content: page.content,
// Skill pages retain the shared policy's wider inline ceiling.
@@ -1027,6 +1059,10 @@ function createReadFileTool(
},
};
} catch (err) {
if (err instanceof TextEncodingMismatchError) {
log.info('read_files encoding rejected', { user_id: maskId(opts.userId), path: logPathRef(abs), encoding: err.encoding });
return textEncodingMismatch(displayPath, err.encoding);
}
if (err instanceof NoTextError) {
log.warn('read_files no text', { user_id: maskId(opts.userId), path: logPathRef(abs) });
return { content: errText('E_NO_TEXT', `${displayPath}: ${err.kind} has no text representation`), isError: true };
@@ -1110,6 +1146,7 @@ function createReadFilesTool(opts: FileToolsOpts): AgentTool {
properties: {
path: { type: 'string', minLength: 1, description: 'Path relative to the working directory, a visible absolute path, or @skill/<read-ref>[/relative-path] for a Skill advertised in this run.' },
range: taggedReadRangeSchema(),
encoding: { type: 'string', minLength: 1, description: 'Decode a non-UTF-8 text file with this encoding label, such as gb18030, big5, shift_jis or utf-16le. Fails if the bytes are invalid for it.' },
},
required: ['path'],
},
@@ -1120,7 +1157,7 @@ function createReadFilesTool(opts: FileToolsOpts): AgentTool {
},
raw_text: {
type: 'boolean',
description: 'Return complete UTF-8 text as JSON {files:[{requested_path,ok,path,content,total_chars,file_hash}|{requested_path,ok:false,error}]}. Cannot combine with ranges/metadata_only. Large direct results may be persisted.',
description: 'Return complete text as JSON {files:[{requested_path,ok,path,content,total_chars,...}|{requested_path,ok:false,error}]}. UTF-8 results include file_hash; decoded results include encoding instead. Cannot combine with ranges/metadata_only. Large direct results may be persisted.',
},
},
required: ['paths'],
@@ -1174,6 +1211,11 @@ function createReadFilesTool(opts: FileToolsOpts): AgentTool {
const parsed = parseReadAddress(request);
if (parsed.error) return invalid(parsed.error, parsed.diagnostic);
const address = parsed.address!;
const encoding = request.encoding === undefined ? undefined : resolveTextEncoding(request.encoding);
if (encoding === null) {
return invalid(`unknown encoding ${JSON.stringify(request.encoding)?.slice(0, 80)}. Use a label such as gb18030, big5, shift_jis or utf-16le.`);
}
const item = { path: request.path, ...(encoding ? { encoding } : {}) };
const hasLineRange = address.lineStart !== undefined || address.lineEnd !== undefined;
if (hasLineRange) {
const lineStart = address.lineStart !== undefined && Number.isFinite(address.lineStart)
@@ -1182,10 +1224,10 @@ function createReadFilesTool(opts: FileToolsOpts): AgentTool {
const lineEnd = address.lineEnd !== undefined && Number.isFinite(address.lineEnd)
? Math.max(lineStart, Math.trunc(address.lineEnd))
: lineStart + 399;
return { path: request.path, lineStart, lineEnd };
return { ...item, range: { unit: 'line', start: lineStart, end: lineEnd } };
}
const hasCharacterRange = address.charStart !== undefined || address.charEnd !== undefined;
if (!hasCharacterRange) return { path: request.path };
if (!hasCharacterRange) return item;
const start = address.charStart !== undefined && Number.isFinite(address.charStart)
? Math.max(0, Math.trunc(address.charStart))
: 0;
@@ -1193,9 +1235,8 @@ function createReadFilesTool(opts: FileToolsOpts): AgentTool {
? Math.max(start, Math.trunc(address.charEnd))
: undefined;
return {
path: request.path,
charStart: start,
charEnd: explicitEnd ?? start + READ_FILES_DEFAULT_SLICE_CHARS,
...item,
range: { unit: 'char', start, end: explicitEnd ?? start + READ_FILES_DEFAULT_SLICE_CHARS },
};
});
const inputFailure = itemErrors.find((result) => result?.observations?.fileFailure)?.observations?.fileFailure;
+105 -209
View File
@@ -363,8 +363,6 @@ const BASH_OUTPUT_MANIFEST_MAX_BYTES = 256 * 1024;
const BASH_OUTPUT_MANIFEST_MAX_FILES = 500;
const BASH_SNAPSHOT_FILE_MAX_BYTES = 512 * 1024;
const BASH_SNAPSHOT_TOTAL_MAX_BYTES = 8 * 1024 * 1024;
const PROCESS_WORKSPACE_SNAPSHOT_MAX = 64;
const PROCESS_WORKSPACE_SNAPSHOT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
type BashFileSnapshotEntry = {
mtimeMs: number;
@@ -376,14 +374,6 @@ type BashFileSnapshotEntry = {
type BashFileSnapshot = Map<string, BashFileSnapshotEntry>;
const creationSnapshots = new WeakMap<BashFileSnapshot, CreationSnapshot>();
const taskFileOwnership = new WeakMap<LocalToolsOpts, TaskFileOwnership>();
type ProcessWorkspaceSnapshot = {
root: string;
command: string;
before: BashFileSnapshot;
createdAt: number;
};
const processWorkspaceSnapshots = new Map<string, ProcessWorkspaceSnapshot>();
function shouldSkipBashProducedDir(name: string): boolean {
return BASH_PRODUCED_SKIP_DIRS.has(name);
@@ -3353,6 +3343,29 @@ function mergeRiskReasons(target: LocalAccessRiskCategory[], source: readonly Lo
const EXTERNAL_MUTATION_SCRIPT_MAX_BYTES = 512 * 1024;
async function dataImportShadowed(root: string, modules: readonly string[]): Promise<boolean> {
// Native and sourceless modules can shadow the data libraries too. Bound the
// directory work for the stronger proof; uncertainty retains approval.
let entries = 1024;
for (const folder of [root, ...modules.map(name => path.join(root, name))]) {
try {
const directory = await fs.promises.opendir(folder);
for await (const entry of directory) {
if (--entries < 0) return true;
const names = folder === root ? modules : ['__init__'];
const candidate = entry.name.toLowerCase();
if (names.some(name => candidate.startsWith(name + '.')
&& ['.so', '.pyd', '.pyc'].some(extension => candidate.endsWith(extension)))) return true;
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
// A file in sys.path can be a zip importer. Its contents are unresolved.
if (code !== 'ENOENT' && !(code === 'ENOTDIR' && folder !== root)) return true;
}
}
return false;
}
async function classifyBashRiskIncludingScripts(command: string, workingDir: string, opts: LocalToolsOpts, ctx?: ToolContext) {
const heredocs = analyzeBashHeredocs(command);
const base = classifyBashCommand(heredocs.shellSurface);
@@ -3438,10 +3451,27 @@ async function classifyBashRiskIncludingScripts(command: string, workingDir: str
...scriptRefs.map(ref => path.dirname(path.resolve(workingDir, ref))),
...segments.flatMap(s => (s.env.PYTHONPATH || '').split(path.delimiter).filter(Boolean).map(p => path.resolve(s.workingDir || workingDir, p))),
])];
const shadowedImport = externalMutations.some(f => f.resource) && (importRoots.length > 32 || importRoots.some(root =>
['sqlite3.py', 'sqlite3/__init__.py', 'pathlib.py', 'pathlib/__init__.py'].some(name =>
const importCandidates = [...new Set(externalMutations.flatMap(f => f.resource?.imports ?? ['sqlite3', 'pathlib']))]
.flatMap(name => [name + '.py', name + '/__init__.py']);
let shadowedImport = externalMutations.some(f => f.resource) && (importRoots.length > 32 || importRoots.some(root =>
importCandidates.some(name =>
fs.existsSync(path.join(root, name)) || filePayloads.some(payload =>
directories.some(dir => dir !== null && path.resolve(dir, payload.fileTarget!) === path.join(root, name))))));
const dataModules = [...new Set(externalMutations.flatMap(f => f.resource?.imports ?? []))];
if (!shadowedImport && dataModules.length) {
const nativeOrBytecode = (file: string, root: string) => dataModules.some(name => {
const relative = path.relative(root, file).split(path.sep).join('/').toLowerCase();
return !relative || [name + '.', name + '/__init__.'].some(prefix => relative.startsWith(prefix)
&& !relative.slice(prefix.length).includes('/') && ['.so', '.pyd', '.pyc'].some(ext => relative.endsWith(ext)));
});
for (const root of importRoots) {
if (await dataImportShadowed(root, dataModules) || filePayloads.some(payload =>
directories.some(dir => dir !== null && nativeOrBytecode(path.resolve(dir, payload.fileTarget!), root)))) {
shadowedImport = true;
break;
}
}
}
const localResources = new Map<string, boolean>();
const isLocal = (database: string): boolean => {
if (shadowedImport || uncertainDirectory || !directories.length || directories.some(dir => dir === null)) return false;
@@ -4928,119 +4958,16 @@ function processSessionContext(opts: LocalToolsOpts, ctx: ToolContext): ToolCont
};
}
function parseProcessToolPayload(result: ToolResult): Record<string, unknown> | null {
try {
const parsed = JSON.parse(String(result.content || '')) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} catch {
return null;
}
}
function pruneProcessWorkspaceSnapshots(now = Date.now()): void {
for (const [sessionId, snapshot] of processWorkspaceSnapshots) {
if (now - snapshot.createdAt > PROCESS_WORKSPACE_SNAPSHOT_MAX_AGE_MS) {
processWorkspaceSnapshots.delete(sessionId);
}
}
while (processWorkspaceSnapshots.size >= PROCESS_WORKSPACE_SNAPSHOT_MAX) {
const oldest = processWorkspaceSnapshots.keys().next().value as string | undefined;
if (!oldest) break;
processWorkspaceSnapshots.delete(oldest);
}
}
function processPayloadIsTerminal(payload: Record<string, unknown> | null): boolean {
const status = String(payload?.status ?? '');
return status === 'exited' || status === 'error' || status === 'stopped';
}
async function finalizeProcessWorkspaceSnapshot(
opts: LocalToolsOpts,
result: ToolResult,
snapshot: ProcessWorkspaceSnapshot,
): Promise<ToolResult> {
const fileChanges = await emitBashProducedFiles(
opts,
snapshot.before,
snapshot.root,
snapshot.command,
);
if (!fileChanges.length) return result;
return {
...result,
observations: {
...(result.observations ?? {}),
fileChanges: [
...(result.observations?.fileChanges ?? []),
...fileChanges,
],
},
};
}
/** Permission-gated PC adapters for core-agent's persistent process sessions.
* The core owns process lifecycle/cursors; PC owns command approval and
* conversation isolation. */
/** PC binds continuation calls to the current conversation. Command startup,
* approval and produced-file tracking belong to bash's managed command path. */
function createHostProcessTools(opts: LocalToolsOpts): AgentTool[] {
return getProcessSessionTools().map((coreTool) => ({
...coreTool,
inspectReadContinuation(input, ctx) {
return coreTool.inspectReadContinuation?.(input, {
...ctx,
state: { ...ctx.state, processSessionOwner: `${opts.userId ?? ''}:${opts.cid ?? ''}` },
});
return coreTool.inspectReadContinuation?.(input, processSessionContext(opts, ctx));
},
async execute(input, ctx) {
const action = String(input.action ?? '');
if (action === 'start') {
const command = String(input.command ?? '').trim();
if (!command) return { content: errText('E_BAD_INPUT', '`command` is required'), isError: true };
const workingDir = path.resolve(ctx.workingDir ?? '.');
const mode = getLocalExecMode();
const filesystemGate = await guardBashFilesystemTargets(
opts,
input,
ctx,
workingDir,
!localAccessRequiresSensitiveApproval(mode),
);
if (filesystemGate.result) return filesystemGate.result;
const gate = await gateInteractiveCliStart(opts, command, {
workingDir,
approvedReasons: filesystemGate.approvedReasons,
unresolvedPaths: filesystemGate.unresolvedPaths,
keyFacts: filesystemGate.keyFacts,
}, ctx);
if (gate) return gate;
pruneProcessWorkspaceSnapshots();
const snapshot: ProcessWorkspaceSnapshot = {
root: workingDir,
command,
before: collectBashFileSnapshot(workingDir, taskFileOwnership.get(opts)?.token),
createdAt: Date.now(),
};
const result = await coreTool.execute(input, processSessionContext(opts, ctx));
const payload = parseProcessToolPayload(result);
const sessionId = String(payload?.session_id ?? '').trim();
if (!sessionId) return result;
if (processPayloadIsTerminal(payload)) {
return finalizeProcessWorkspaceSnapshot(opts, result, snapshot);
}
processWorkspaceSnapshots.set(sessionId, snapshot);
return result;
}
const result = await coreTool.execute(input, processSessionContext(opts, ctx));
if (action !== 'read' && action !== 'stop') return result;
const payload = parseProcessToolPayload(result);
if (!processPayloadIsTerminal(payload)) return result;
const sessionId = String(payload?.session_id ?? input.session_id ?? '').trim();
const snapshot = processWorkspaceSnapshots.get(sessionId);
if (!snapshot) return result;
processWorkspaceSnapshots.delete(sessionId);
return finalizeProcessWorkspaceSnapshot(opts, result, snapshot);
return coreTool.execute(input, processSessionContext(opts, ctx));
},
}));
}
@@ -5127,8 +5054,10 @@ function createWriteFileTool(opts: LocalToolsOpts): AgentTool {
};
}
function fileBufferHash(body: Buffer): string {
return `sha256:${crypto.createHash('sha256').update(body).digest('hex')}`;
function fileBufferHash(body: Buffer, suffix?: Buffer): string {
const hash = crypto.createHash('sha256').update(body);
if (suffix) hash.update(suffix);
return `sha256:${hash.digest('hex')}`;
}
function appendFileReceipt(
@@ -5146,6 +5075,7 @@ function appendFileReceipt(
function createAppendFileTool(opts: LocalToolsOpts): AgentTool {
return {
name: 'append_file',
compatibilityInputFields: ['expected_size'],
description:
'Append one complete UTF-8 chunk to an existing text file with optimistic concurrency and replay-safe behavior. Use edit_file or apply_patch for targeted changes.',
inputSchema: {
@@ -5161,32 +5091,26 @@ function createAppendFileTool(opts: LocalToolsOpts): AgentTool {
},
base_revision: {
type: 'string',
description: 'Opaque revision from the preceding file-tool result. Required unless legacy expected_size is provided; stale revisions fail without writing and exact replay is idempotent.',
description: 'Opaque revision from the preceding file-tool result. Stale revisions fail without writing; exact replay is idempotent.',
pattern: '^file_rev_[A-Za-z0-9_-]{16}$',
},
expected_size: {
type: 'number',
description: 'Legacy expected current byte size. Omit when base_revision is available.',
minimum: 0,
},
},
required: ['path', 'content'],
required: ['path', 'content', 'base_revision'],
},
async execute(input, ctx) {
const rawPath = String(input.path ?? '');
const content = typeof input.content === 'string' ? input.content : null;
const baseRevision = typeof input.base_revision === 'string' ? input.base_revision.trim() : '';
const hasExpectedSize = input.expected_size !== undefined && input.expected_size !== null;
const expectedSize = Number(input.expected_size);
if (input.expected_size !== undefined) return { content: errText('E_BAD_INPUT', 'expected_size is unsupported; use base_revision'), isError: true };
if (!rawPath) return { content: errText('E_BAD_INPUT', '`path` is required'), isError: true };
if (content === null || content.length === 0) {
return { content: errText('E_BAD_INPUT', '`content` must be a non-empty string'), isError: true };
}
if (!baseRevision && (!hasExpectedSize || !Number.isSafeInteger(expectedSize) || expectedSize < 0)) {
if (!baseRevision) {
return {
content: errText('E_BAD_INPUT', 'copy `base_revision` from the preceding file result; legacy `expected_size` must be a non-negative safe integer byte size'),
content: errText('E_BAD_INPUT', 'base_revision from the preceding file result is required'),
isError: true,
observations: fileFailure('E_BAD_INPUT', 'append_precondition', { stage: 'input', revision_provided: !!baseRevision, size_provided: hasExpectedSize, size_valid: hasExpectedSize && Number.isSafeInteger(expectedSize) && expectedSize >= 0 }),
observations: fileFailure('E_BAD_INPUT', 'append_precondition', { stage: 'input', revision_provided: false }),
};
}
@@ -5228,62 +5152,30 @@ function createAppendFileTool(opts: LocalToolsOpts): AgentTool {
const currentHash = fileBufferHash(before);
const currentRevision = issueFileRevision(ctx, abs, before.length, currentHash);
const chunkHash = fileBufferHash(chunk);
const revisionRecord = baseRevision ? resolveFileRevision(ctx, baseRevision) : null;
const revisionRecord = resolveFileRevision(ctx, baseRevision);
if (baseRevision) {
const replay = findAppendReplay(ctx, baseRevision, chunkHash);
if (revisionRecord?.path !== canonicalFileRevisionPath(abs)) {
return {
content: `${errText(
revisionRecord ? 'E_REVISION_PATH_MISMATCH' : 'E_REVISION_UNKNOWN',
revisionRecord
? `${abs}: base_revision belongs to a different file; no bytes were written`
: `${abs}: base_revision is not available in this run; use the returned current revision`,
)}\n<file path="${abs}" total_bytes="${before.length}" file_hash="${currentHash}" revision="${currentRevision}"/>`,
isError: true,
observations: fileFailure(revisionRecord ? 'E_REVISION_PATH_MISMATCH' : 'E_REVISION_UNKNOWN', revisionRecord ? 'revision_path' : 'revision_unknown', { stage: 'freshness', revision_provided: true, revision_found: !!revisionRecord }),
};
}
if (revisionRecord.hash !== currentHash || revisionRecord.size !== before.length) {
const replayEnd = replay?.baseSize != null
? replay.baseSize + replay.appendedBytes
: revisionRecord.size + chunk.length;
const replayed = !!replay
&& replay.baseSize === revisionRecord.size
&& replay.appendedBytes === chunk.length
&& replayEnd <= before.length
&& before.subarray(replay.baseSize, replayEnd).equals(chunk);
if (replayed) {
recordRead(ctx, abs, st, currentHash);
return {
content: appendFileReceipt(
abs,
chunk.length,
before.length,
currentHash,
currentRevision,
true,
),
};
}
return {
content: `${errText(
'E_STALE',
`${abs}: base_revision no longer matches the current file; no bytes were written`,
)}\n<file path="${abs}" total_bytes="${before.length}" file_hash="${currentHash}" revision="${currentRevision}"/>`,
isError: true,
observations: fileFailure('E_STALE', 'revision_changed', { stage: 'freshness', revision_provided: true, revision_found: true, revision_matches: false }),
};
}
const replay = findAppendReplay(ctx, baseRevision, chunkHash);
if (revisionRecord?.path !== canonicalFileRevisionPath(abs)) {
return {
content: `${errText(
revisionRecord ? 'E_REVISION_PATH_MISMATCH' : 'E_REVISION_UNKNOWN',
revisionRecord
? `${abs}: base_revision belongs to a different file; no bytes were written`
: `${abs}: base_revision is not available in this run; use the returned current revision`,
)}\n<file path="${abs}" total_bytes="${before.length}" file_hash="${currentHash}" revision="${currentRevision}"/>`,
isError: true,
observations: fileFailure(revisionRecord ? 'E_REVISION_PATH_MISMATCH' : 'E_REVISION_UNKNOWN', revisionRecord ? 'revision_path' : 'revision_unknown', { stage: 'freshness', revision_provided: true, revision_found: !!revisionRecord }),
};
}
// A provider/host retry may replay a tool call after its write already
// committed. Detect the exact bytes at the declared offset and return
// the current receipt instead of appending them twice.
if (!baseRevision && before.length !== expectedSize) {
const replayEnd = expectedSize + chunk.length;
const replayed = replayEnd <= before.length
&& before.subarray(expectedSize, replayEnd).equals(chunk);
if (revisionRecord.hash !== currentHash || revisionRecord.size !== before.length) {
const replayEnd = replay?.baseSize != null
? replay.baseSize + replay.appendedBytes
: revisionRecord.size + chunk.length;
const replayed = !!replay
&& replay.baseSize === revisionRecord.size
&& replay.appendedBytes === chunk.length
&& replayEnd <= before.length
&& before.subarray(replay.baseSize, replayEnd).equals(chunk);
if (replayed) {
recordRead(ctx, abs, st, currentHash);
return {
@@ -5300,10 +5192,10 @@ function createAppendFileTool(opts: LocalToolsOpts): AgentTool {
return {
content: `${errText(
'E_STALE',
`${abs}: expected ${expectedSize} bytes before append, current size is ${before.length}; use the returned revision and regenerate only the missing chunk`,
`${abs}: base_revision no longer matches the current file; no bytes were written`,
)}\n<file path="${abs}" total_bytes="${before.length}" file_hash="${currentHash}" revision="${currentRevision}"/>`,
isError: true,
observations: fileFailure('E_STALE', 'size_changed', { stage: 'freshness', revision_provided: false, size_provided: true, size_valid: true }),
observations: fileFailure('E_STALE', 'revision_changed', { stage: 'freshness', revision_provided: true, revision_found: true, revision_matches: false }),
};
}
@@ -5318,28 +5210,28 @@ function createAppendFileTool(opts: LocalToolsOpts): AgentTool {
return { content: errText('E_APPEND_FAILED', `${abs}: write failed: ${(err as Error).message}`), isError: true };
}
const after = Buffer.concat([before, chunk]);
const beforeHash = fileBufferHash(before);
const afterHash = fileBufferHash(after);
// The freshness check already hashed this immutable snapshot. Hash the
// exact concatenated bytes without allocating another full-file buffer.
const beforeHash = currentHash;
const afterBytes = before.length + chunk.length;
const afterHash = fileBufferHash(before, chunk);
const nextStat = fs.statSync(abs);
recordRead(ctx, abs, nextStat, afterHash);
const nextRevision = issueFileRevision(ctx, abs, after.length, afterHash);
if (baseRevision) {
recordAppendReplay(ctx, {
baseRevision,
contentHash: chunkHash,
baseSize: before.length,
appendedBytes: chunk.length,
resultRevision: nextRevision,
resultHash: afterHash,
});
}
const nextRevision = issueFileRevision(ctx, abs, afterBytes, afterHash);
recordAppendReplay(ctx, {
baseRevision,
contentHash: chunkHash,
baseSize: before.length,
appendedBytes: chunk.length,
resultRevision: nextRevision,
resultHash: afterHash,
});
// append_file refuses a path it cannot read, so the target always
// pre-existed.
try { await opts.onFileWritten?.(abs, { preExisting: true }); }
catch (err) { log.warn('onFileWritten callback failed', { error: logErrorRef(err) }); }
return {
content: appendFileReceipt(abs, chunk.length, after.length, afterHash, nextRevision, false),
content: appendFileReceipt(abs, chunk.length, afterBytes, afterHash, nextRevision, false),
observations: {
fileChanges: [{
operation: 'update',
@@ -5349,7 +5241,7 @@ function createAppendFileTool(opts: LocalToolsOpts): AgentTool {
beforeHash,
afterHash,
beforeBytes: before.length,
afterBytes: after.length,
afterBytes,
coverage: 'exact',
}],
},
@@ -5501,12 +5393,9 @@ function createEditFileTool(opts: LocalToolsOpts): AgentTool {
if (oldStr === null || newStr === null) {
return { content: errText('E_BAD_INPUT', '`old_string` and `new_string` are both required strings'), isError: true };
}
if (oldStr.length === 0) {
if (oldStr.length === 0 && oldStr !== newStr) {
return { content: errText('E_BAD_INPUT', '`old_string` must be non-empty'), isError: true };
}
if (oldStr === newStr) {
return { content: errText('E_BAD_INPUT', '`old_string` and `new_string` are identical — no-op rejected'), isError: true };
}
const replaceAll = input.replace_all === true;
const expectedHash = typeof input.expected_hash === 'string' ? input.expected_hash.trim() : '';
if (expectedHash && !/^sha256:[a-f0-9]{64}$/.test(expectedHash)) {
@@ -5577,6 +5466,13 @@ function createEditFileTool(opts: LocalToolsOpts): AgentTool {
};
}
if (oldStr === newStr) {
// No replacement is needed; retain the verified version without a write.
recordRead(ctx, abs, st, currentHash);
const revision = issueFileRevision(ctx, abs, Buffer.byteLength(body, 'utf8'), currentHash);
return { content: `<file path="${abs}" edited="0" kind="${kind}" file_hash="${currentHash}" revision="${revision}"/>` };
}
let count = countOccurrences(body, oldStr);
// The text actually present in the file. It differs from `old_string`
// only when the trailing-whitespace fallback below resolves the match.
+4 -14
View File
@@ -861,22 +861,12 @@ function createXlsxTool(opts: OfficeToolsOpts): AgentTool {
async execute(input, ctx) {
const prep = prepareOutput(opts, ctx, input, '.xlsx');
if ('error' in prep) return prep.error;
const hasCanonicalSheets = Array.isArray(input.sheets) && input.sheets.length > 0;
const hasLegacyFields = ['sheet', 'rows', 'columns', 'charts']
const hasRetiredFields = ['sheet', 'rows', 'columns', 'charts']
.some((key) => Object.prototype.hasOwnProperty.call(input, key));
if (hasCanonicalSheets && hasLegacyFields) {
return errResult('E_BAD_INPUT', 'use `sheets` alone; do not mix it with legacy sheet/rows/columns/charts fields');
if (hasRetiredFields || !Array.isArray(input.sheets) || input.sheets.length === 0) {
return errResult('E_BAD_INPUT', 'provide sheets; flat sheet/rows/columns/charts fields are unsupported');
}
// Direct execution of persisted historical calls remains supported even
// though the public schema now exposes only the canonical sheets form.
const sheets: XlsxSheetSpec[] = hasCanonicalSheets
? (input.sheets as XlsxSheetSpec[])
: [{
name: typeof input.sheet === 'string' && input.sheet ? input.sheet : 'Sheet1',
rows: Array.isArray(input.rows) ? (input.rows as XlsxCell[][]) : [],
...(Array.isArray(input.columns) ? { columns: input.columns as XlsxSheetSpec['columns'] } : {}),
...(Array.isArray(input.charts) ? { charts: input.charts as XlsxSheetSpec['charts'] } : {}),
}];
const sheets = input.sheets as XlsxSheetSpec[];
return runCreate(opts, ctx, {
inputAbs: prep.abs,
createFlags: ['--force'],
@@ -1,11 +1,12 @@
/** Bounded, fail-closed provenance for straight-line Python SQLite operations.
/** Bounded, fail-closed provenance for Python SQLite operations.
* This does not execute Python or authorize a path. Unsupported expressions,
* scopes and calls discard knowledge; the host still owns filesystem policy.
*/
export type SqliteResource = { kind: 'sqlite'; database: string };
export type SqliteResource = { kind: 'sqlite'; database: string; imports?: string[] };
type Token = { text: string; at: number; column: number; literal?: string };
type Value = { kind: 'module'; name: string } | { kind: 'path'; text: string }
| { kind: 'factory'; name: string } | { kind: 'connection'; resource: SqliteResource };
const SQL_CONTROL = /\b(?:attach|detach|pragma|vacuum|load_extension)\b/i;
function tokenize(source: string): Token[] | null {
if (source.length > 64 * 1024) return null;
@@ -50,7 +51,7 @@ function tokenize(source: string): Token[] | null {
return tokens;
}
export function pythonSqliteResources(source: string): Map<number, SqliteResource> {
function straightLineSqliteResources(source: string): Map<number, SqliteResource> {
const resources = new Map<number, SqliteResource>();
const tokens = tokenize(source);
if (!tokens) return resources;
@@ -120,14 +121,17 @@ export function pythonSqliteResources(source: string): Map<number, SqliteResourc
const receiver = values.get(ts[0].text);
if (receiver?.kind === 'connection' && ts[1]?.text === '.' && ts[3]?.text === '(' && ts.at(-1)?.text === ')') {
const method = ts[2].text;
if (['execute', 'executemany'].includes(method)) {
if (['execute', 'executemany', 'executescript'].includes(method)) {
const sql = ts[4]?.literal ?? (values.get(ts[4]?.text)?.kind === 'path'
? (values.get(ts[4].text) as { kind: 'path'; text: string }).text : undefined);
// Adjacent literals, `+` and `%` extend the SQL past this token, so the
// control check below would see only a prefix of the executed text.
const wholeSql = ts[5]?.text === ',' || ts[5]?.text === ')';
// ATTACH/extension/configuration statements can change where later SQL writes.
const literalArguments = ts.slice(5, -1).every(t => t.literal !== undefined
|| !/^[A-Za-z_]\w*$/.test(t.text) || ['None', 'True', 'False'].includes(t.text));
if (sql && literalArguments
&& !/\b(?:attach|detach|pragma|vacuum|load_extension)\b/i.test(sql)) {
if (sql && wholeSql && literalArguments
&& !SQL_CONTROL.test(sql)) {
resources.set(ts[1].at, receiver.resource);
return;
}
@@ -154,3 +158,264 @@ export function pythonSqliteResources(source: string): Map<number, SqliteResourc
statement(chunk);
return resources;
}
// These modules are trusted only when the host also rules out workspace shadows.
// The second pass admits a closed data-processing subset, not arbitrary Python.
const DATA_EXPORTS = new Map<string, readonly string[]>(Object.entries({
sqlite3: ['connect'], pathlib: ['Path'], json: ['loads', 'dumps'], csv: ['DictReader'],
hashlib: ['sha256'], collections: ['Counter', 'defaultdict'], os: ['environ'],
}));
const DATA_BUILTINS = new Set(['len', 'int', 'float', 'str', 'bool', 'dict', 'list', 'set', 'tuple',
'sum', 'min', 'max', 'abs', 'any', 'all', 'enumerate', 'range', 'sorted', 'isinstance', 'open', 'print']);
const DATA_METHODS = new Set(['items', 'keys', 'values', 'get', 'strip', 'rstrip', 'lstrip',
'split', 'splitlines', 'join', 'append', 'extend', 'hexdigest', 'read_bytes', 'read_text',
'write_text', 'write_bytes', 'exists', 'unlink', 'mkdir', 'stat', 'fetchone', 'fetchall']);
function statements(tokens: Token[]): Token[][] {
const result: Token[][] = [];
let chunk: Token[] = [], depth = 0;
for (const token of tokens) {
if ((token.text === '\n' || token.text === ';') && depth === 0) {
if (chunk.length) result.push(chunk);
chunk = [];
} else if (token.text !== '\n') {
if (['(', '[', '{'].includes(token.text)) depth++;
if ([')', ']', '}'].includes(token.text)) depth--;
chunk.push(token);
}
}
if (chunk.length) result.push(chunk);
return result;
}
/** Prove stable in-memory bindings across data-only loops/comprehensions.
* Inspect every executable expression, including f-string interpolation, using
* the caller's existing code mask. A single unknown call, escaped/rebound
* connection, reflective operation or unsupported SQL discards the whole proof.
* SQL/file permissions themselves remain with the host.
*/
function dataProgramSqliteResources(source: string, code: Uint8Array): Map<number, SqliteResource> {
const empty = () => new Map<number, SqliteResource>();
const original = tokenize(source);
if (!original || code.length !== source.length) return empty();
for (let i = 0; i < original.length; i++) {
const t = original[i], formatted = ['f', 'F'].includes(t.text)
&& original[i + 1]?.at === t.at + 1 && original[i + 1]?.literal !== undefined;
if (formatted) {
// The small lexer does not parse PEP 701 same-quote nesting. Reject an
// incomplete interpolation rather than treating its contents as bindings.
const text = original[i + 1].literal!;
let braces = 0;
for (let j = 0; j < text.length; j++) {
if (!braces && ['{{', '}}'].includes(text.slice(j, j + 2))) { j++; continue; }
if (text[j] === '{') braces++;
if (text[j] === '}' && --braces < 0) return empty();
}
if (braces) return empty();
}
if (t.text !== '\n' && t.text !== '<string>' && !code[t.at] && !formatted) return empty();
}
const surface = source.replace(/[^\n]/g, (c, at) => code[at] ? c : ' ');
const executable = tokenize(surface)?.filter(t => t.text !== '\n');
if (!executable || executable.some(t => !/^[\x00-\x7f]*$/.test(t.text) || t.text.includes('__')
|| ['def', 'class', 'global', 'nonlocal', 'exec', 'eval', 'globals', 'locals', 'getattr', 'setattr',
'delattr', 'del', 'with', 'try', 'except', 'finally', 'async', 'await', 'yield', 'match', 'case', '\\'].includes(t.text))) return empty();
const chunks = statements(original);
const ts = original.filter(t => t.text !== '\n');
let work = 48_000, sqlTextBudget = 256 * 1024;
const index = new Map(ts.map((t, i) => [t.at, i]));
const modules = new Map<string, string>(), factories = new Map<string, string>();
const declarations = new Set<number>();
const register = (bindings: Map<string, string>, alias: string, name: string) => {
if (DATA_BUILTINS.has(alias) || modules.has(alias) || factories.has(alias)) return false;
bindings.set(alias, name);
return true;
};
for (const chunk of chunks) {
if (!['import', 'from'].includes(chunk[0].text)) continue;
if (!code[chunk[0].at]) return empty();
for (const t of chunk) declarations.add(t.at);
if (chunk[0].text === 'import') {
for (let i = 1; i < chunk.length;) {
const name = chunk[i++]?.text;
let alias = name;
if (chunk[i]?.text === 'as') { i++; alias = chunk[i++]?.text; }
if (!name || !alias || !DATA_EXPORTS.has(name) || (chunk[i] && chunk[i].text !== ',')
|| !register(modules, alias, name)) return empty();
i++;
}
} else {
const module = chunk[1]?.text, member = chunk[3]?.text;
const alias = chunk[4]?.text === 'as' ? chunk[5]?.text : member;
if (chunk[2]?.text !== 'import' || !DATA_EXPORTS.get(module)?.includes(member) || member === 'environ'
|| chunk.length !== (chunk[4]?.text === 'as' ? 6 : 4)
|| !register(factories, alias, module + '.' + member)) return empty();
}
}
if (![...modules.values()].includes('sqlite3') && ![...factories.values()].includes('sqlite3.connect')) return empty();
const proofImports = [...new Set([...modules.values(), ...[...factories.values()].map(name => name.split('.')[0])])];
const protectedNames = new Set([...DATA_BUILTINS, ...modules.keys(), ...factories.keys()]);
const assignments = new Map<string, number[]>();
const bind = (token: Token) => {
if (!code[token.at] || protectedNames.has(token.text)) return false;
const occurrences = assignments.get(token.text) ?? [];
occurrences.push(token.at);
assignments.set(token.text, occurrences);
return true;
};
for (const chunk of chunks) {
let depth = 0, start = 0, assigned = false;
for (let i = 0; i < chunk.length; i++) {
const t = chunk[i].text;
if (['(', '[', '{'].includes(t)) depth++;
if ([')', ']', '}'].includes(t)) depth--;
if (depth) continue;
if (t === ':' && ['if', 'elif', 'else', 'for', 'while'].includes(chunk[0].text)) start = i + 1;
if (t !== '=' || chunk[i + 1]?.text === '=' || ['=', '!', '<', '>'].includes(chunk[i - 1]?.text)) continue;
if (assigned) return empty(); // Chained/destructured binding is unsupported.
assigned = true;
const lhs = chunk.slice(start, i);
if (['+', '-', '*', '/', '%'].includes(lhs.at(-1)?.text ?? '')) lhs.pop();
if (lhs.length === 1 && /^[A-Za-z_]\w*$/.test(lhs[0].text)) {
if (!bind(lhs[0])) return empty();
} else if (lhs[1]?.text !== '[' || lhs.at(-1)?.text !== ']') return empty();
}
}
for (let i = 0; i < executable.length; i++) {
const t = executable[i], previous = executable[i - 1]?.text;
if (t.text === '=' && executable[i + 1]?.text !== '=' && !['=', '!', '<', '>'].includes(previous)) {
// Attribute mutation and assignment expressions invalidate native API identity.
if (previous === ':' || executable[i - 2]?.text === '.') return empty();
}
if (t.text === 'for' || t.text === 'lambda') {
for (let j = i + 1; j < executable.length && !['in', ':'].includes(executable[j].text); j++) {
if (--work < 0) return empty();
const target = executable[j];
if (/^[A-Za-z_]\w*$/.test(target.text)) { if (!bind(target)) return empty(); }
else if (![',', '(', ')'].includes(target.text)) return empty();
}
}
}
const argsAt = (open: number): Token[][] | undefined => {
if (ts[open]?.text !== '(') return;
const args: Token[][] = [];
let arg: Token[] = [], depth = 0;
for (let i = open + 1; i < ts.length; i++) {
if (--work < 0) return;
const t = ts[i];
if (t.text === ')' && !depth) { if (arg.length) args.push(arg); return args; }
if (t.text === ',' && !depth) { args.push(arg); arg = []; continue; }
if (['(', '[', '{'].includes(t.text)) depth++;
if ([')', ']', '}'].includes(t.text) && --depth < 0) return;
arg.push(t);
}
};
const connections = new Set<string>(), connectionDefinitions = new Set<number>();
const constants = new Map<string, Token[]>();
for (const chunk of chunks) {
if (chunk[1]?.text === '=' && assignments.get(chunk[0].text)?.length === 1) constants.set(chunk[0].text, chunk.slice(2));
}
const stringExpression = (tokens: Token[], depth = 0): { text: string; dynamic: boolean } | undefined => {
work -= tokens.length;
if (work < 0 || depth > 4 || !tokens.length) return;
if (tokens.length === 1) {
if (tokens[0].literal !== undefined) return { text: tokens[0].literal, dynamic: false };
const value = constants.get(tokens[0].text);
return value ? stringExpression(value, depth + 1) : undefined;
}
if (tokens.length === 2 && ['f', 'F'].includes(tokens[0].text) && tokens[1].literal !== undefined) {
return { text: tokens[1].literal, dynamic: true };
}
const plus = tokens.findIndex(t => t.text === '+');
if (plus > 0) {
const left = stringExpression(tokens.slice(0, plus), depth + 1);
const right = stringExpression(tokens.slice(plus + 1), depth + 1);
if (left && right) return { text: left.text + right.text, dynamic: left.dynamic || right.dynamic };
}
};
const callable = (i: number): string | undefined => {
if (executable[i - 1]?.text === '.') {
const owner = modules.get(executable[i - 2]?.text);
return owner && DATA_EXPORTS.get(owner)?.includes(executable[i].text) ? owner + '.' + executable[i].text : undefined;
}
return factories.get(executable[i].text);
};
// Connections/cursors must have one simple definition and never escape.
for (let i = 0; i < executable.length; i++) {
if (executable[i + 1]?.text !== '(' || declarations.has(executable[i].at)) continue;
const fn = callable(i);
const cursor = executable[i].text === 'cursor' && executable[i - 1]?.text === '.'
&& connections.has(executable[i - 2]?.text);
if (fn !== 'sqlite3.connect' && !cursor) continue;
const at = index.get(executable[i].at);
if (at === undefined) return empty();
const start = fn && executable[i - 1]?.text !== '.' ? at : at - 2;
const target = ts[start - 2];
const args = argsAt(at + 1);
if (ts[start - 1]?.text !== '=' || !target || protectedNames.has(target.text)
|| assignments.get(target.text)?.length !== 1 || !args
|| (cursor ? args.length !== 0 : args.length !== 1 || args[0].length !== 1 || args[0][0].literal !== ':memory:')) return empty();
connections.add(target.text);
connectionDefinitions.add(target.at);
}
if (!connections.size) return empty();
const resources = empty();
for (let i = 0; i < executable.length; i++) {
const t = executable[i], before = executable[i - 1]?.text, after = executable[i + 1]?.text;
if (declarations.has(t.at)) continue;
if (before !== '.' && connections.has(t.text)) {
if (connectionDefinitions.has(t.at)) continue;
if (after !== '.' || executable[i + 3]?.text !== '(') return empty();
}
if (before !== '.' && modules.has(t.text) && (after !== '.' || !DATA_EXPORTS.get(modules.get(t.text)!)!.includes(executable[i + 2]?.text))) return empty();
if (before !== '.' && factories.has(t.text) && after !== '(') return empty();
if (t.text === '.') {
const member = executable[i + 1]?.text;
const owner = executable[i - 1]?.text;
if (modules.has(owner)) {
if (!DATA_EXPORTS.get(modules.get(owner)!)!.includes(member)
|| (member !== 'environ' && executable[i + 2]?.text !== '(')) return empty();
} else if (connections.has(owner)) {
if (!['execute', 'executemany', 'executescript', 'cursor', 'commit', 'rollback', 'close', 'fetchone', 'fetchall'].includes(member)) return empty();
} else if (!DATA_METHODS.has(member) && !['parent', 'st_size'].includes(member)) return empty();
}
if (after !== '(') continue;
if (['if', 'while', 'assert', 'not', 'and', 'or', 'in'].includes(t.text)) continue;
if (before !== '.') {
if (!DATA_BUILTINS.has(t.text) && !factories.has(t.text) && !['(', '[', '{', ',', '=', ':', '+', '-', '*', '/', '%', '<', '>'].includes(t.text)) return empty();
continue;
}
const owner = executable[i - 2]?.text;
if (modules.has(owner)) continue; // Module members were validated above.
if (!connections.has(owner)) {
if (!DATA_METHODS.has(t.text)) return empty();
continue;
}
const at = index.get(t.at);
if (at === undefined) return empty(); // No hidden SQL inside interpolation.
const args = argsAt(at + 1);
if (!args) return empty();
if (['execute', 'executemany', 'executescript'].includes(t.text)) {
const sql = stringExpression(args[0] ?? []);
if (!sql || (sqlTextBudget -= sql.text.length) < 0 || SQL_CONTROL.test(sql.text)) return empty();
// execute/executemany accept one statement. A complete static first
// keyword prevents interpolation from choosing ATTACH/PRAGMA/VACUUM;
// executescript requires the entire SQL text to be static.
if (sql.dynamic && (t.text === 'executescript' || !/^[A-Za-z]+\s+/.test(sql.text.trimStart()))) return empty();
resources.set(executable[i - 1].at, { kind: 'sqlite', database: ':memory:',
imports: proofImports });
} else if (args.length) return empty();
}
return resources;
}
export function pythonSqliteResources(source: string, code?: Uint8Array): Map<number, SqliteResource> {
const resources = straightLineSqliteResources(source);
const extended = code ? dataProgramSqliteResources(source, code) : new Map<number, SqliteResource>();
// A deduplicated SQL finding may retain an earlier straight-line occurrence.
// Carry the whole-program import requirements to it as well.
if ([...extended.keys()].some(at => !resources.has(at))) {
for (const [at, resource] of extended) resources.set(at, resource);
}
return resources;
}
+7 -2
View File
@@ -17,7 +17,7 @@
* to the SDK's default-provider + env-var auth path.
*/
import type { AgentTool, HistoryResource, LLMProvider, Message, ToolContext, ToolResult } from '#core-agent';
import type { ProviderRequestFailure, AgentTool, HistoryResource, LLMProvider, Message, ToolContext, ToolResult } from '#core-agent';
import type { Api, Model } from '@earendil-works/pi-ai';
import { getAgentIdleClock } from '../../util/system-activity';
import { customModelImageSupport } from './custom-model-image-support';
@@ -55,7 +55,7 @@ import {
formatForSystemPrompt as formatMemoryForSystemPrompt,
type MemoryScope,
} from '../../features/memory';
import { addMemoryEntry, replaceMemoryEntry, removeMemoryEntry } from '../../features/memory-writes';
import { addMemoryEntry, replaceMemoryEntry, removeMemoryEntry, batchMemoryEntries } from '../../features/memory-writes';
import {
formatProjectContextPolicyForSystemPrompt,
formatProjectInstructionsForSystemPrompt,
@@ -409,6 +409,7 @@ export interface BuildRunnerParams {
/** Reports configured/currently available rotating-provider candidates even
* when the call succeeds on the first candidate. */
onCandidatesObserved?: (info: { candidateCount: number; availableCandidateCount: number }) => void;
onRequestFailure?: (failure: ProviderRequestFailure) => void;
}
export interface NativeSearchInjectedInfo {
@@ -717,6 +718,7 @@ export async function buildRunner(params: BuildRunnerParams): Promise<{
add: (tier, content, signal) => addMemoryEntry(uid, toScope(tier), content, { signal }),
replace: (tier, oldText, content, signal) => replaceMemoryEntry(uid, toScope(tier), oldText, content, { signal }),
remove: (tier, oldText) => removeMemoryEntry(uid, toScope(tier), oldText),
batch: (tier, revision, operations, signal) => batchMemoryEntries(uid, toScope(tier), revision, operations, { signal }),
list: (tier) => listEntries(uid, toScope(tier)),
};
const { createCrossSessionMemoryTool } = await import('../../../core-agent/src/tools/memory-tool');
@@ -1584,6 +1586,7 @@ export async function buildRunner(params: BuildRunnerParams): Promise<{
},
params.cid,
params.requireImageInput,
params.onRequestFailure,
);
// Inject the rotating provider into BOTH the factory slot AND the
// pre-built instance cache. ProviderRegistry.get() short-circuits on
@@ -1821,6 +1824,7 @@ async function buildRotatingProvider(
usageContext?: OrkasApiUsageContext,
cid?: string,
requireImageInput = false,
onRequestFailure?: (failure: ProviderRequestFailure) => void,
): Promise<LLMProvider> {
const candidates: RotatingCandidate[] = group.map((choice) => {
const candProviderId = choice.provider;
@@ -1905,6 +1909,7 @@ async function buildRotatingProvider(
},
...(onCandidateChosen ? { onCandidateChosen } : {}),
...(onCandidatesObserved ? { onCandidatesObserved } : {}),
...(onRequestFailure ? { onRequestFailure } : {}),
...(Number.isFinite(firstEventTimeoutMs) ? { firstEventTimeoutMs } : {}),
});
}
+27 -37
View File
@@ -335,7 +335,7 @@ function createToolResultTool(opts: ToolResultToolsOpts): AgentTool {
},
},
required: ['ref'],
description: 'Use only fields for the selected action. materialize takes ref only. Ignored legacy read/query fields are listed by request in ignored_fields; other incompatible fields are errors.',
description: 'Use only fields for the selected action. materialize takes ref only. Unused read/query fields are listed by request in ignored_fields; other incompatible fields are errors.',
};
const actionProperty = {
type: 'string',
@@ -345,7 +345,7 @@ function createToolResultTool(opts: ToolResultToolsOpts): AgentTool {
return {
name: 'tool_result',
description:
'Inspect oversized results using the literal opaque tool.hash ref from a prior <persisted-output ref="..."> marker, not read_files. Make at most one tool_result call per model step and batch up to eight currently needed same-action requests. Use only the selected action; never use a tool-call ID such as call_....',
'Inspect oversized tool results retained in the current agent session using the literal opaque tool.hash ref from a prior <persisted-output ref="..."> marker, not read_files. Make at most one tool_result call per model step and batch up to eight currently needed same-action requests. Use only the selected action; never use a tool-call ID such as call_....',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -409,7 +409,7 @@ async function materializeToolResults(
opts: ToolResultToolsOpts,
requests: unknown,
): Promise<RetrievalItemResult> {
const batch = batchItems({ requests }, 'requests', ['ref']);
const batch = batchItems({ requests }, 'requests');
if (batch.error) return batch.error;
const sources: Array<{ ref: string; path: string; bytes: number }> = [];
@@ -501,7 +501,7 @@ function createQueryTool(opts: ToolResultToolsOpts): AgentTool {
},
async execute(input, ctx) {
const ledger = readLedger(ctx, opts);
const batch = batchItems(input, 'queries', ['ref', 'operation']);
const batch = batchItems(input, 'queries');
if (batch.error) return batch.error;
const worker = opts.queryExecutor ? null : new PersistedToolResultQueryWorker();
const queryExecutor = opts.queryExecutor ?? worker!.query.bind(worker);
@@ -551,7 +551,7 @@ function createSearchTool(opts: ToolResultToolsOpts): AgentTool {
},
async execute(input, ctx) {
const ledger = readLedger(ctx, opts);
const batch = batchItems(input, 'queries', ['ref', 'query']);
const batch = batchItems(input, 'queries');
if (batch.error) return batch.error;
return executeBatch(batch.items, ledger, (item, maxOutputTokens) =>
executeSearchItem(opts, item, ledger, maxOutputTokens), String(input.notice ?? ''));
@@ -592,7 +592,7 @@ function createReadChunkTool(opts: ToolResultToolsOpts): AgentTool {
},
async execute(input, ctx) {
const ledger = readLedger(ctx, opts);
const batch = batchItems(input, 'chunks', ['ref', 'cursor']);
const batch = batchItems(input, 'chunks');
if (batch.error) return batch.error;
return executeBatch(batch.items, ledger, (item, maxOutputTokens) =>
executeReadChunkItem(opts, item, ledger, maxOutputTokens), String(input.notice ?? ''));
@@ -605,34 +605,13 @@ type RetrievalItemResult = Pick<ToolResult, 'content' | 'isError' | 'observation
function batchItems(
input: Record<string, unknown>,
batchKey: 'queries' | 'chunks' | 'requests',
legacyRequired: string[],
): { items: Record<string, unknown>[]; error?: never } | { items?: never; error: RetrievalItemResult } {
const rawBatch = input[batchKey];
if (rawBatch !== undefined) {
if (!Array.isArray(rawBatch) || rawBatch.length < 1 || rawBatch.length > TOOL_RESULT_BATCH_MAX_ITEMS) {
return {
error: error(
'E_BAD_INPUT',
`\`${batchKey}\` must contain 1-${TOOL_RESULT_BATCH_MAX_ITEMS} requests.`,
),
};
}
return {
items: rawBatch.map((item) =>
item && typeof item === 'object' && !Array.isArray(item)
? item as Record<string, unknown>
: {}),
};
if (!Array.isArray(rawBatch) || rawBatch.length < 1 || rawBatch.length > TOOL_RESULT_BATCH_MAX_ITEMS) {
return { error: error('E_BAD_INPUT', `\`${batchKey}\` must contain 1-${TOOL_RESULT_BATCH_MAX_ITEMS} requests.`) };
}
if (!legacyRequired.every((key) => input[key] !== undefined)) {
return {
error: error(
'E_BAD_INPUT',
`Provide either \`${batchKey}\` or the legacy ${legacyRequired.map((key) => `\`${key}\``).join(' + ')} fields.`,
),
};
}
return { items: [input] };
return { items: rawBatch.map((item) => item && typeof item === 'object' && !Array.isArray(item)
? item as Record<string, unknown> : {}) };
}
async function executeBatch(
@@ -836,9 +815,14 @@ function executeSearchItem(
/** Shared exact reader for authorized historical execution records. The
* caller owns admission; this returns a source range, never another spill. */
export function readToolResultExcerpt(toolResultsDir: string, ref: string, cursor: number, budget: number, ctx: ToolContext): ToolResult {
type ReadChunkPresentation = {
continuation?: (cursor: number | 'done') => string;
history?: { recordId: string; toolCallId: string };
};
export function readToolResultExcerpt(toolResultsDir: string, ref: string, cursor: number, budget: number, ctx: ToolContext, presentation?: ReadChunkPresentation): ToolResult {
const opts = { toolResultsDir, materializeDir: toolResultsDir, isProgrammaticToolCallContext: () => false };
return executeReadChunkItem(opts, { ref, cursor, maxTokens: budget }, readLedger(ctx, opts), budget);
return executeReadChunkItem(opts, { ref, cursor, maxTokens: budget }, readLedger(ctx, opts), budget, presentation);
}
function executeReadChunkItem(
@@ -846,6 +830,7 @@ function executeReadChunkItem(
input: Record<string, unknown>,
ledger: ToolResultReadLedger | null,
maxOutputTokens: number,
presentation?: ReadChunkPresentation,
): RetrievalItemResult {
const ref = String(input.ref || '').trim();
const cursor = Number(input.cursor);
@@ -871,9 +856,14 @@ function executeReadChunkItem(
if (cursor > totalChars) {
return error('E_RESULT_CURSOR_RANGE', `cursor ${cursor} exceeds total_chars ${totalChars}.`);
}
const { continuation, history } = presentation ?? {};
const tag = history ? 'history-tool-output' : 'tool-result-chunk';
const identity = history
? `record_id="${escapeAttr(history.recordId)}" tool_call_id="${escapeAttr(history.toolCallId)}"`
: `ref="${escapeAttr(ref)}"`;
const emptyEnvelope =
`<tool-result-chunk ref="${escapeAttr(ref)}" total_chars="${totalChars}" covered="${cursor}-${cursor}" next_cursor="done">\n\n` +
`</tool-result-chunk>`;
`<${tag} ${identity} total_chars="${totalChars}" covered="${cursor}-${cursor}" next_cursor="done">\n\n` +
`</${tag}>` + (continuation?.('done') ?? '');
const payloadBudget = budget - estimateToolResultTokens(emptyEnvelope);
if (payloadBudget < 64) return budgetError();
// The cursor/end attributes grow with the selected payload, so reserving
@@ -886,9 +876,9 @@ function executeReadChunkItem(
const end = cursor + text.length;
const next = end < totalChars ? String(end) : 'done';
return (
`<tool-result-chunk ref="${escapeAttr(ref)}" total_chars="${totalChars}" covered="${cursor}-${end}" next_cursor="${next}">\n` +
`<${tag} ${identity} total_chars="${totalChars}" covered="${cursor}-${end}" next_cursor="${next}">\n` +
`${text}\n` +
`</tool-result-chunk>`
`</${tag}>` + (continuation?.(end < totalChars ? end : 'done') ?? '')
);
};
let lo = 0;
+2 -3
View File
@@ -3,7 +3,6 @@ import {
AGENT_FALLBACK_TOOL_GROUP_IDS,
LOADABLE_TOOL_GROUP_IDS,
TOOL_CATALOG,
canonicalToolGroupId,
canonicalizeAgentToolGroups,
canonicalizeToolGroups,
expandToolGroups,
@@ -246,7 +245,7 @@ export function createToolSurfaceController(input: {
const seen = new Set<ToolGroupId>();
const duplicates = new Set<string>();
for (const raw of groups) {
const id = canonicalToolGroupId(raw);
const id = typeof raw === 'string' ? raw.trim() as ToolGroupId : undefined;
if (!id || !acceptsDynamicGroup(id) || !availableDynamicGroups.has(id)) {
unavailable.push(typeof raw === 'string' ? raw : '<invalid>');
continue;
@@ -381,7 +380,7 @@ export function createToolLoadTool(
const activeGroups = new Set(expandToolGroups(controller.loadedGroups()));
const loadedGroupContext: Partial<Record<ToolGroupId, string>> = {};
for (const rawGroup of groups) {
const group = canonicalToolGroupId(rawGroup);
const group = typeof rawGroup === 'string' ? rawGroup.trim() as ToolGroupId : undefined;
const context = group ? options.contextByGroup[group]?.trim() : '';
if (group && context && activeGroups.has(group)) loadedGroupContext[group] = context;
}
+12 -5
View File
@@ -316,7 +316,9 @@ export function capToolResult(
});
return {
...result,
content: buildPersistedOutputMarker(absPath, toolName, content, result.isError),
content: buildPersistedOutputMarkerFromPreview(absPath, toolName, content, {
sizeChars: len, estimatedTokens, isError: !!result.isError, sourceTruncated: false,
}),
persistedOutput: {
path: absPath,
size: content.length,
@@ -566,7 +568,8 @@ export function maybeSpillToolResult(opts: {
if (typeof serialized !== 'string') {
return { output: '[Unsupported tool result omitted.]' };
}
if (!serialized || estimateToolResultTokens(serialized) <= maxInlineTokens) {
const estimatedTokens = estimateToolResultTokens(serialized);
if (!serialized || estimatedTokens <= maxInlineTokens) {
return { output };
}
try {
@@ -580,7 +583,9 @@ export function maybeSpillToolResult(opts: {
// Same preview shape as the in-process path so the renderer's
// click-to-expand logic works identically.
return {
output: buildPersistedOutputMarker(abs, toolName, serialized),
output: buildPersistedOutputMarkerFromPreview(abs, toolName, serialized, {
sizeChars: serialized.length, estimatedTokens, isError: false, sourceTruncated: false,
}),
outputPath: abs,
};
} catch (err) {
@@ -757,7 +762,8 @@ function buildBoundedPreview(content: string, maxTokens: number): string {
function prefixWithinTokenBudget(text: string, maxTokens: number): string {
let lo = 0;
let hi = text.length;
// The fixed estimator charges at least one token per four UTF-16 units.
let hi = Math.min(text.length, maxTokens * 4);
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (estimateToolResultTokens(text.slice(0, mid)) <= maxTokens) lo = mid;
@@ -768,7 +774,8 @@ function prefixWithinTokenBudget(text: string, maxTokens: number): string {
function suffixWithinTokenBudget(text: string, maxTokens: number): string {
let lo = 0;
let hi = text.length;
// The fixed estimator charges at least one token per four UTF-16 units.
let hi = Math.min(text.length, maxTokens * 4);
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (estimateToolResultTokens(text.slice(text.length - mid)) <= maxTokens) lo = mid;
+8 -2
View File
@@ -172,7 +172,13 @@ export function makeReorderedSheetsXlsx(): Buffer {
return zip.toBuffer();
}
export function makeMinimalPptx(opts: { slides?: string[][] } = {}): Buffer {
const PLACED_SHAPE_TREE_HEADER = '<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>';
const PLACED_SHAPE_HEADER = '<p:nvSpPr><p:cNvPr id="2" name="Text"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>'
+ '<p:spPr><a:xfrm><a:off x="457200" y="457200"/><a:ext cx="8229600" cy="1371600"/></a:xfrm></p:spPr>';
// `placed` emits a positioned shape so a real renderer has text to paint. Text
// extraction does not need geometry, and OfficeCLI's HTML omits a shape without it.
export function makeMinimalPptx(opts: { slides?: string[][]; placed?: boolean } = {}): Buffer {
const slides = opts.slides ?? [
['Quarterly Review', 'Revenue grew 12%'],
];
@@ -202,7 +208,7 @@ export function makeMinimalPptx(opts: { slides?: string[][] } = {}): Buffer {
.join('');
addText(zip, `ppt/slides/slide${i + 1}.xml`, `<?xml version="1.0" encoding="UTF-8"?>
<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<p:cSld><p:spTree><p:sp><p:txBody>${runs}</p:txBody></p:sp></p:spTree></p:cSld>
<p:cSld><p:spTree>${opts.placed ? PLACED_SHAPE_TREE_HEADER : ''}<p:sp>${opts.placed ? PLACED_SHAPE_HEADER : ''}<p:txBody>${opts.placed ? '<a:bodyPr/><a:lstStyle/>' : ''}${runs}</p:txBody></p:sp></p:spTree></p:cSld>
</p:sld>`);
});
return zip.toBuffer();
+30 -4
View File
@@ -8,6 +8,7 @@ const assert = require('node:assert/strict');
const { app, BrowserWindow, nativeImage } = require('electron');
// Electron otherwise turns a module-load exception into a blocking GUI dialog.
process.on('uncaughtException', error => { console.error(error); app.exit(1); });
const { profileSyncCosts } = require('../../helpers/sync-cost.cjs');
const { foreignDocxBytes } = require('./foreign-docx.cjs');
const AdmZip = require('adm-zip');
const pc = process.argv[2];
@@ -41,8 +42,9 @@ const engine = require(enginePath);
require.cache[enginePath].exports = {
...engine,
runOfficeCli: async (args, opts) => {
const started = performance.now();
const result = await engine.runOfficeCli(args, opts);
active?.commands.push({ verb: args[0], code: result.code, stderr: result.stderr, stdoutChars: result.stdout.length });
active?.commands.push({ verb: args[0], code: result.code, stderr: result.stderr, stdoutChars: result.stdout.length, elapsedMs: performance.now() - started });
if (active?.cancel && args[0] === 'view' && args[2] === 'issues') active.cancel.abort();
return result;
},
@@ -131,7 +133,7 @@ app.whenReady().then(async () => {
assert.deepEqual(consoleProbe.browserLogs, [{ level: 'warning', message: 'Office console capture probe' }]);
} finally { probeWindow.destroy(); active = undefined; }
const rows = [];
for (const id of ['combined-review', 'partial-page-retry', 'cancel-after-check', 'invalid-document', 'edit-then-review', 'xlsx-render', 'pptx-render']) {
for (const id of ['combined-review', 'partial-page-retry', 'cancel-after-check', 'invalid-document', 'edit-then-review', 'xlsx-render', 'pptx-render', 'edit-10-paragraphs', 'edit-1000-paragraphs']) {
const row = { id, logs: [], browserLogs: [], commands: [], renderErrors: [] };
active = row;
const format = id === 'xlsx-render' ? 'xlsx' : id === 'pptx-render' ? 'pptx' : 'docx';
@@ -151,7 +153,31 @@ app.whenReady().then(async () => {
const mtime = fs.statSync(file, { bigint: true }).mtimeNs;
const writtenBefore = written.length;
try {
if (format !== 'docx') {
if (id.startsWith('edit-') && id.endsWith('-paragraphs')) {
const count = Number(id.split('-')[1]);
const expected = Array.from({ length: count }, (_, n) => `PF_${String(n).padStart(4, '0')}_end`);
const target = path.join(root, `authored-${count}.docx`);
let last = performance.now(), maxGapMs = 0;
const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 2);
const costs = process.env.ORKAS_PERFORMANCE_ATTRIBUTION === '1'
? profileSyncCosts(file => typeof file === 'string' && file.startsWith(root + path.sep)) : null;
const rssBefore = process.memoryUsage().rss, start = performance.now();
let result;
try {
result = await edit.execute({ path: file, output_path: target, preview: false,
operations: expected.map(text => ({ action: 'add', parent: '/body', type: 'p', props: { text } })),
}, context);
await new Promise(resolve => setTimeout(resolve, 5));
} finally { clearInterval(timer); costs?.stop(); }
row.measurement = { operations: count, ...(costs ? { callerCosts: costs.report } : {}), elapsedMs: performance.now() - start, maxGapMs,
inputBytes: before.length, outputBytes: fs.existsSync(target) ? fs.statSync(target).size : null,
rssBefore, rssAfter: process.memoryUsage().rss, scope: 'real pinned engine, host edit/copy/validate/publish/cleanup; endpoints not peak RSS' };
assert(!result.isError, result.content);
assert.deepEqual(written.slice(writtenBefore), [target]);
const xml = new AdmZip(fs.readFileSync(target)).readAsText('word/document.xml');
assert.deepEqual(xml.match(/PF_\d{4}_end/g), expected, 'reopened OOXML must retain every ordered paragraph exactly once');
await noResident(target);
} else if (format !== 'docx') {
const readback = await engine.runOfficeCli(['view', file, 'text'], { cwd: root });
assert.equal(readback.code, 0);
assert.equal(readback.stderr, '');
@@ -215,7 +241,7 @@ app.whenReady().then(async () => {
}
}
preserved(file, before, mtime);
if (id !== 'edit-then-review') assert.equal(written.length, writtenBefore, 'review must not register a write');
if (id !== 'edit-then-review' && !id.endsWith('-paragraphs')) assert.equal(written.length, writtenBefore, 'review must not register a write');
await noResident(file);
assert.equal(BrowserWindow.getAllWindows().length, 0, 'review must release its preview windows');
} catch (error) {
@@ -0,0 +1,4 @@
customer_id,region
c1,north
c2,south
c3,east
1 customer_id region
2 c1 north
3 c2 south
4 c3 east
@@ -0,0 +1,7 @@
order_id,line_id,qty,unit_cents
o1,1,2,1200
o1,2,1,800
o2,1,1,5000
o3,1,1,9900
o4,1,3,700
o5,1,1,900
1 order_id line_id qty unit_cents
2 o1 1 2 1200
3 o1 2 1 800
4 o2 1 1 5000
5 o3 1 1 9900
6 o4 1 3 700
7 o5 1 1 900
@@ -0,0 +1,6 @@
order_id,customer_id,status
o1,c1,completed
o2,c2,completed
o3,c3,cancelled
o4,c9,completed
o5,c1,completed
1 order_id customer_id status
2 o1 c1 completed
3 o2 c2 completed
4 o3 c3 cancelled
5 o4 c9 completed
6 o5 c1 completed
@@ -0,0 +1,5 @@
refund_id,order_id,amount_cents
r1,o1,500
r2,o1,300
r1,o1,500
r3,o2,1000
1 refund_id order_id amount_cents
2 r1 o1 500
3 r2 o1 300
4 r1 o1 500
5 r3 o2 1000
@@ -0,0 +1,240 @@
{"order_id":"b01-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b01-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b01-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b01-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b01-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b01-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b01-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b01-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b02-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b02-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b02-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b02-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b02-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b02-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b02-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b02-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b03-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b03-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b03-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b03-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b03-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b03-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b03-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b03-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b04-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b04-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b04-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b04-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b04-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b04-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b04-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b04-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b05-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b05-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b05-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b05-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b05-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b05-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b05-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b05-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b06-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b06-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b06-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b06-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b06-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b06-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b06-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b06-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b07-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b07-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b07-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b07-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b07-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b07-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b07-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b07-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b08-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b08-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b08-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b08-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b08-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b08-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b08-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b08-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b09-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b09-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b09-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b09-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b09-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b09-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b09-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b09-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b10-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b10-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b10-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b10-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b10-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b10-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b10-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b10-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b11-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b11-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b11-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b11-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b11-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b11-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b11-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b11-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b12-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b12-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b12-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b12-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b12-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b12-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b12-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b12-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b13-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b13-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b13-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b13-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b13-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b13-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b13-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b13-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b14-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b14-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b14-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b14-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b14-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b14-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b14-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b14-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b15-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b15-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b15-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b15-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b15-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b15-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b15-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b15-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b16-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b16-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b16-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b16-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b16-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b16-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b16-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b16-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b17-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b17-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b17-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b17-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b17-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b17-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b17-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b17-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b18-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b18-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b18-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b18-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b18-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b18-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b18-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b18-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b19-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b19-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b19-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b19-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b19-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b19-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b19-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b19-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b20-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b20-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b20-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b20-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b20-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b20-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b20-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b20-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b21-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b21-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b21-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b21-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b21-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b21-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b21-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b21-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b22-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b22-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b22-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b22-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b22-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b22-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b22-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b22-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b23-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b23-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b23-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b23-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b23-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b23-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b23-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b23-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b24-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b24-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b24-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b24-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b24-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b24-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b24-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b24-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b25-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b25-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b25-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b25-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b25-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b25-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b25-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b25-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b26-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b26-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b26-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b26-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b26-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b26-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b26-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b26-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b27-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b27-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b27-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b27-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b27-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b27-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b27-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b27-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b28-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b28-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b28-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b28-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b28-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b28-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b28-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b28-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b29-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b29-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b29-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b29-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b29-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b29-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b29-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b29-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
{"order_id":"b30-o1","status":"completed","region":"north","discount_cents":200,"items":[{"sku":"alpha","qty":2,"unit_cents":1200},{"sku":"beta","qty":1,"unit_cents":800}]}
{"order_id":"b30-o2","status":"completed","region":"south","discount_cents":0,"items":[{"sku":"alpha","qty":1,"unit_cents":1200},{"sku":"gamma","qty":3,"unit_cents":500}]}
{"order_id":"b30-o3","status":"cancelled","region":"north","discount_cents":0,"items":[{"sku":"alpha","qty":10,"unit_cents":1200}]}
{"order_id":"b30-o4","status":"completed","region":"north","discount_cents":400,"items":[{"sku":"beta","qty":4,"unit_cents":800}]}
{"order_id":"b30-o5","status":"completed","region":"west","discount_cents":100,"items":[{"sku":"gamma","qty":2,"unit_cents":500},{"sku":"delta","qty":1,"unit_cents":2000}]}
{"order_id":"b30-o6","status":"completed","region":"south","discount_cents":700,"items":[{"sku":"delta","qty":2,"unit_cents":2000},{"sku":"alpha","qty":1,"unit_cents":1200}]}
{"order_id":"b30-o7","status":"refunded","region":"west","discount_cents":0,"items":[{"sku":"alpha","qty":5,"unit_cents":1200}]}
{"order_id":"b30-o8","status":"completed","region":"west","discount_cents":0,"items":[{"sku":"beta","qty":2,"unit_cents":800},{"sku":"gamma","qty":1,"unit_cents":500}]}
+35
View File
@@ -0,0 +1,35 @@
import json, hashlib, sqlite3
from pathlib import Path
from collections import Counter
p=Path('orders.ndjson'); original=p.read_bytes(); digest=hashlib.sha256(original).hexdigest()
rows=[json.loads(s) for s in original.splitlines() if s.strip()]
regions=Counter(); units=Counter(); count=0; total=0
for o in rows:
if o['status']!='completed': continue
count+=1
assert isinstance(o['discount_cents'],int)
for i in o['items']:
assert isinstance(i['qty'],int) and isinstance(i['unit_cents'],int)
units[i['sku']]+=i['qty']
net=sum(i['qty']*i['unit_cents'] for i in o['items'])-o['discount_cents']
total+=net; regions[o['region']]+=net
leaders=sorted(units,key=lambda s:(-units[s],s))
assert leaders, 'No completed SKU data'
assert len(leaders)==1 or units[leaders[0]]>units[leaders[1]], 'Top SKU tie requires explicit handling'
result={'completed_orders':count,'net_revenue_cents':total,'revenue_by_region':dict(sorted(regions.items())),'top_sku_by_units':{'sku':leaders[0],'units':units[leaders[0]]}}
# Independent SQL reconciliation using order and item tables, including excluded statuses.
c=sqlite3.connect(':memory:')
c.executescript('CREATE TABLE orders (id INTEGER,status TEXT,region TEXT,discount INTEGER); CREATE TABLE items (oid INTEGER,sku TEXT,qty INTEGER,price INTEGER);')
for idx,o in enumerate(rows):
c.execute('INSERT INTO orders VALUES (?,?,?,?)',(idx,o['status'],o['region'],o['discount_cents']))
c.executemany('INSERT INTO items VALUES (?,?,?,?)',[(idx,i['sku'],i['qty'],i['unit_cents']) for i in o['items']])
cte="WITH net AS (SELECT o.id,o.region,COALESCE(SUM(i.qty*i.price),0)-o.discount AS revenue FROM orders o LEFT JOIN items i ON o.id=i.oid WHERE o.status='completed' GROUP BY o.id) "
assert c.execute(cte+'SELECT COUNT(*),SUM(revenue) FROM net').fetchone()==(count,total)
assert dict(c.execute(cte+'SELECT region,SUM(revenue) FROM net GROUP BY region'))==dict(regions)
assert c.execute("SELECT i.sku,SUM(i.qty) FROM items i JOIN orders o ON o.id=i.oid WHERE o.status='completed' GROUP BY i.sku ORDER BY SUM(i.qty) DESC,i.sku LIMIT 1").fetchone()==(leaders[0],units[leaders[0]])
assert sum(regions.values())==total
out=Path('analysis.json'); out.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n')
assert json.loads(out.read_text())==result
assert hashlib.sha256(p.read_bytes()).hexdigest()==digest
print(json.dumps(result,ensure_ascii=False))
print(f'Verified all {len(rows)} records with independent SQL; output re-read and source hash unchanged.')
+46
View File
@@ -0,0 +1,46 @@
import csv,json,hashlib,os,sqlite3
from pathlib import Path
from collections import defaultdict
names=['orders.csv','items.csv','refunds.csv','customers.csv']
before={n:hashlib.sha256(Path(n).read_bytes()).hexdigest() for n in names}
data={n:list(csv.DictReader(open(n,newline='',encoding='utf-8-sig'))) for n in names}
orders={r['order_id']:r for r in data['orders.csv'] if r['status']=='completed'}
assert len({r['order_id'] for r in data['orders.csv']})==len(data['orders.csv'])
customers={r['customer_id']:r for r in data['customers.csv']}
assert len(customers)==len(data['customers.csv'])
gross=defaultdict(int); refunds=defaultdict(int); seen={}; duplicates=0
for r in data['items.csv']:
if r['order_id'] in orders: gross[r['order_id']]+=int(r['qty'])*int(r['unit_cents'])
for r in data['refunds.csv']:
rid=r['refund_id']
if rid in seen:
assert seen[rid]==r, 'Conflicting duplicate refund'
duplicates+=1
continue
seen[rid]=r
if r['order_id'] in orders: refunds[r['order_id']]+=int(r['amount_cents'])
regions=defaultdict(int)
for oid,o in orders.items():
region=customers.get(o['customer_id'],{}).get('region','').strip() or 'unknown'
regions[region]+=gross[oid]-refunds[oid]
result=dict(completed_orders=len(orders),gross_cents=sum(gross.values()),refund_cents=sum(refunds.values()),net_cents=sum(gross.values())-sum(refunds.values()),revenue_by_region=dict(sorted(regions.items())),unmatched_customers=len({o['customer_id'] for o in orders.values() if o['customer_id'] not in customers}),duplicate_refunds=duplicates)
# Independently verify with SQL, aggregating child tables before joining.
c=sqlite3.connect(':memory:')
for name,rows in data.items():
table=name[:-4]; cols=list(rows[0]); c.execute(f'CREATE TABLE {table} ({",".join(cols)})')
c.executemany(f'INSERT INTO {table} VALUES ({",".join("?" for _ in cols)})',[[r[k] for k in cols] for r in rows])
query='''WITH g AS (SELECT order_id,SUM(qty*unit_cents) amount FROM items GROUP BY order_id), r AS (SELECT order_id,SUM(amount_cents) amount FROM (SELECT DISTINCT refund_id,order_id,amount_cents FROM refunds) GROUP BY order_id) SELECT o.order_id,o.customer_id,COALESCE(NULLIF(TRIM(c.region),''),'unknown'),COALESCE(g.amount,0),COALESCE(r.amount,0),c.customer_id IS NULL FROM orders o LEFT JOIN g USING(order_id) LEFT JOIN r USING(order_id) LEFT JOIN customers c USING(customer_id) WHERE o.status='completed' '''
rows=c.execute(query).fetchall(); checkregions=defaultdict(int)
for oid,cid,region,g,r,missing in rows: checkregions[region]+=g-r
assert len(rows)==result['completed_orders']
assert sum(r[3] for r in rows)==result['gross_cents']
assert sum(r[4] for r in rows)==result['refund_cents']
assert dict(checkregions)==result['revenue_by_region']
assert len({r[1] for r in rows if r[5]})==result['unmatched_customers']
assert c.execute('SELECT COUNT(*)-COUNT(DISTINCT refund_id) FROM refunds').fetchone()[0]==duplicates
assert sum(regions.values())==result['net_cents']
assert before=={n:hashlib.sha256(Path(n).read_bytes()).hexdigest() for n in names}
out=Path(os.environ.get('ORKAS_OUTPUT_DIR','.'))/'reconciliation.json'; out.parent.mkdir(parents=True,exist_ok=True)
out.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
assert json.loads(out.read_text())==result
print(str(out));print(json.dumps(result,ensure_ascii=False));print('PASS: independent SQL reconciliation; regional totals; output round-trip; all four input hashes unchanged.')
@@ -1633,6 +1633,128 @@ describe('local_agents/runner', () => {
}
});
// A CLI that exits on its own explains why on stderr (sign-in, config,
// provider). The user needs that text to act; analytics only needs the stage.
it.each([
{
label: 'before any output',
events: [] as any[],
done: { type: 'done', status: 'failed', error: 'claude exited with code 1' },
category: 'cli_failed_before_output',
},
{
label: 'after output started',
events: [{ type: 'text-delta', text: 'Starting the task.' }],
done: { type: 'done', status: 'failed', error: 'claude exited with code 1' },
category: 'cli_failed_after_output',
},
{
label: 'when the process cannot start',
events: [] as any[],
done: { type: 'done', status: 'failed', error: 'spawn EACCES', failureKind: 'cli_spawn', retrySafe: true },
category: 'cli_spawn_failed',
},
])('returns the redacted stderr excerpt and failure stage $label', async ({ events, done, category }) => {
mockDetect.mockResolvedValue({ type: 'claude', available: true, path: '/fake/claude', version: '2.0.0' });
mockCliFallback.mockReturnValue(null);
const privateToken = 'sk-proj-local-agent-secret-123456789';
mockBackendImpl = async ({ onEvent }) => {
for (const event of events) onEvent(event);
onEvent({
...done,
// Only the last three lines are kept; the first is older context.
stderrTail: [
'older context line',
'\u001b[31mError: no provider credentials\u001b[0m',
`config /Users/test/.claude/settings.json api_key=${privateToken}`,
'Run `claude /login` and try again.',
'',
].join('\n'),
});
};
const runner = await loadRunner();
const result = await runner.run({
uid: TEST_UID, cid: `c-stderr-${category}`, agentId: 'agent-x',
cli: 'claude', prompt: 'run', cwd: tmpDir,
signal: new AbortController().signal,
onEvent: () => {},
});
expect(result.status).toBe('failed');
expect(result.failureCategory).toBe(category);
expect(result.stderrExcerpt).toContain('Error: no provider credentials');
expect(result.stderrExcerpt).toContain('Run `claude /login` and try again.');
expect(result.stderrExcerpt).not.toContain('older context line');
expect(result.stderrExcerpt).not.toContain('\u001b[');
expect(result.stderrExcerpt).not.toContain('/Users/alice');
expect(result.stderrExcerpt).not.toContain(privateToken);
});
it('bounds the stderr excerpt to its end, keeps a lone reason, and omits empty stderr', async () => {
mockDetect.mockResolvedValue({ type: 'claude', available: true, path: '/fake/claude', version: '2.0.0' });
// A long tail is bounded to its end, where the reason is; its first
// retained line is partial and must not appear.
const privateToken = 'sk-proj-local-agent-secret-123456789';
let tail = `api_key=${privateToken} ${'w'.repeat(8_000)}\n${'x'.repeat(2_000)}\nfinal reason`;
mockBackendImpl = async ({ onEvent }) => {
onEvent({ type: 'done', status: 'failed', error: 'claude exited with code 1', stderrTail: tail });
};
const runner = await loadRunner();
const run = () => runner.run({
uid: TEST_UID, cid: 'c-stderr-bounds', agentId: 'agent-x',
cli: 'claude', prompt: 'run', cwd: tmpDir,
signal: new AbortController().signal,
onEvent: () => {},
});
const long = await run();
expect(long.stderrExcerpt!.length).toBeLessThanOrEqual(401);
expect(long.stderrExcerpt!.startsWith('…')).toBe(true);
expect(long.stderrExcerpt!.endsWith('final reason')).toBe(true);
expect(long.stderrExcerpt).not.toContain('w');
expect(long.stderrExcerpt).not.toContain(privateToken);
// A CLI that prints one short reason and exits (the common startup case).
tail = 'Error: no inference provider configured. Run setup, then try again.\n';
const lone = await run();
expect(lone.stderrExcerpt).toBe('Error: no inference provider configured. Run setup, then try again.');
tail = ' \n\n ';
const empty = await run();
expect(empty).not.toHaveProperty('stderrExcerpt');
expect(empty.failureCategory).toBe('cli_failed_before_output');
});
it('classifies a failure after a completed tool as after-output work', async () => {
mockDetect.mockResolvedValue({ type: 'claude', available: true, path: '/fake/claude', version: '2.0.0' });
mockBackendImpl = async ({ onEvent }) => {
onEvent({ type: 'tool-event', tool: 'read_files', phase: 'use', callId: 'read-completed', input: {} });
onEvent({ type: 'tool-event', tool: 'read_files', phase: 'result', callId: 'read-completed', text: 'Done.' });
onEvent({ type: 'done', status: 'failed', error: 'test interruption' });
};
const runner = await loadRunner();
const result = await runner.run({ uid: TEST_UID, cid: 'c-completed-tool-failure', agentId: 'agent-x',
cli: 'claude', prompt: 'run', cwd: tmpDir, signal: new AbortController().signal, onEvent: () => {} });
expect(result.failureCategory).toBe('cli_failed_after_output');
});
it('reports no failure stage or stderr for a completed run', async () => {
mockDetect.mockResolvedValue({ type: 'claude', available: true, path: '/fake/claude', version: '2.0.0' });
mockBackendImpl = async ({ onEvent }) => {
onEvent({ type: 'done', status: 'completed', output: 'done', stderrTail: 'warning: slow network' });
};
const runner = await loadRunner();
const result = await runner.run({
uid: TEST_UID, cid: 'c-stderr-completed', agentId: 'agent-x',
cli: 'claude', prompt: 'run', cwd: tmpDir,
signal: new AbortController().signal,
onEvent: () => {},
});
expect(result.status).toBe('completed');
expect(result).not.toHaveProperty('failureCategory');
expect(result).not.toHaveProperty('stderrExcerpt');
});
it('keeps useful tool metadata while stripping private command, path, and body data before forwarding and persistence', async () => {
const privateToken = 'sk-proj-local-agent-secret-123456789';
const opaqueToken = 'opaque-secret-value';
@@ -11,7 +11,8 @@ type Row = {
id: string; error?: string;
logs: Array<{ scope: string; level: string; args: unknown[] }>;
browserLogs: Array<{ level: string; message: string }>;
commands: Array<{ verb: string; code: number; stderr: string; stdoutChars: number }>;
commands: Array<{ verb: string; code: number; stderr: string; stdoutChars: number; elapsedMs: number }>;
measurement?: Record<string, unknown>;
renderErrors: Array<{ page: string; message: string }>;
};
// OfficeCLI is bundled for desktop macOS/Windows, not Linux hosts.
@@ -34,7 +35,7 @@ describe.skipIf(!['darwin', 'win32'].includes(process.platform))('real Office re
rows = JSON.parse(fs.readFileSync(output, 'utf8'));
expect(rows.map(row => row.id)).toEqual([
'combined-review', 'partial-page-retry', 'cancel-after-check', 'invalid-document', 'edit-then-review',
'xlsx-render', 'pptx-render',
'xlsx-render', 'pptx-render', 'edit-10-paragraphs', 'edit-1000-paragraphs',
]);
}, 160_000);
@@ -48,6 +49,8 @@ describe.skipIf(!['darwin', 'win32'].includes(process.platform))('real Office re
['edit-then-review', 'saves an intentional edit, preserves the original and reviews the new revision'],
['xlsx-render', 'renders a real workbook with no browser warnings or source mutation'],
['pptx-render', 'renders a real presentation with no browser warnings or source mutation'],
['edit-10-paragraphs', 'PF-09 preserves every paragraph through a small real edit and cleanup'],
['edit-1000-paragraphs', 'PF-09 preserves every paragraph through a large real edit and cleanup'],
])('%s: %s', (id) => {
const row = rows.find(item => item.id === id)!;
expect(row.error, JSON.stringify({ commands: row.commands, renderErrors: row.renderErrors, logs: row.logs })).toBeUndefined();
@@ -63,6 +66,8 @@ describe.skipIf(!['darwin', 'win32'].includes(process.platform))('real Office re
expect(log).toMatchObject({ scope: 'office-tools', level: 'warn' });
expect(log.args[0]).toBe('render error');
}
if (row.measurement) console.log('[PF-09 native edit]', JSON.stringify({ ...row.measurement,
commands: row.commands.map(command => ({ verb: command.verb, elapsedMs: command.elapsedMs })) }));
const failures = row.commands.filter(command => command.code !== 0);
if (id === 'invalid-document') {
expect(failures).toHaveLength(1);
@@ -43,6 +43,9 @@ function state(): string {
function context() {
return { workingDir: workspace, state: { sandboxEnv } } as any;
}
function managedContext() {
return { workingDir: workspace, state: { sandboxEnv, commandSessionEnabled: true } } as any;
}
beforeEach(async () => {
vi.resetModules();
@@ -117,6 +120,117 @@ function assertOutput(name = 'report.txt') {
}
describe('bash permission journey — deterministic native execution', () => {
it.each(['orders.py', 'reconcile.py'].flatMap(name => [
...(process.platform === 'win32' ? ['file'] : ['file', 'heredoc', 'inline']).map(entry => [name, entry, 'small']),
[name, process.platform === 'win32' ? 'file' : 'heredoc', 'original'],
]))('completes recorded local computation %s through %s on %s inputs without approval', async (name, entry, dataset) => {
const inputs: Record<string, string> = name === 'orders.py' ? {
'orders.ndjson': [
{ status: 'completed', region: 'north', discount_cents: 5, items: [{ sku: 'alpha', qty: 2, unit_cents: 100 }] },
{ status: 'completed', region: 'south', discount_cents: 0, items: [{ sku: 'beta', qty: 1, unit_cents: 50 }] },
{ status: 'cancelled', region: 'south', discount_cents: 0, items: [{ sku: 'beta', qty: 100, unit_cents: 50 }] },
].map(row => JSON.stringify(row)).join('\n') + '\n',
} : {
'orders.csv': 'order_id,customer_id,status\n1,A,completed\n2,B,completed\n3,A,cancelled\n',
'items.csv': 'order_id,qty,unit_cents\n1,2,100\n2,1,50\n3,100,50\n',
'refunds.csv': 'refund_id,order_id,amount_cents\nR1,1,20\nR1,1,20\nR2,3,100\n',
'customers.csv': 'customer_id,region\nA,north\n',
};
if (dataset === 'original') {
const fixture = name === 'orders.py' ? 'data-analysis-local' : 'data-analysis-joins';
for (const file of Object.keys(inputs)) inputs[file] = fs.readFileSync(path.resolve('test/fixtures/sqlite-local-analysis/data', fixture, file), 'utf8');
}
for (const [file, text] of Object.entries(inputs)) fs.writeFileSync(path.join(workspace, file), text);
const source = fs.readFileSync(path.resolve('test/fixtures/sqlite-local-analysis', name));
fs.writeFileSync(path.join(workspace, 'analysis.py'), source);
pendingDecision = 'deny';
const command = entry === 'file' ? 'python analysis.py'
: entry === 'heredoc' ? `python - <<'PY'\n${source.toString('utf8')}PY`
: `python -c '${source.toString('utf8').replace(/'/g, `'"'"'`)}'`;
const result = await bash.execute({ command }, context());
expect(result.isError, result.content).toBeFalsy();
expect(result.observations?.execution).toMatchObject({ status: 'succeeded', exitCode: 0 });
expect(result.observations?.execution?.stderr.bytes).toBe(0);
expect(requests).toHaveLength(0);
const output = name === 'orders.py' ? 'analysis.json' : 'reconciliation.json';
const expected = dataset === 'original' ? (name === 'orders.py' ? {
completed_orders: 180, net_revenue_cents: 540000,
revenue_by_region: { north: 174000, south: 216000, west: 150000 }, top_sku_by_units: { sku: 'beta', units: 210 },
} : {
completed_orders: 4, gross_cents: 11200, refund_cents: 1800, net_cents: 9400,
revenue_by_region: { north: 3300, south: 4000, unknown: 2100 }, unmatched_customers: 1, duplicate_refunds: 1,
}) : name === 'orders.py' ? {
completed_orders: 2, net_revenue_cents: 245, revenue_by_region: { north: 195, south: 50 },
top_sku_by_units: { sku: 'alpha', units: 2 },
} : {
completed_orders: 2, gross_cents: 250, refund_cents: 20, net_cents: 230,
revenue_by_region: { north: 180, unknown: 50 }, unmatched_customers: 1, duplicate_refunds: 1,
};
expect(JSON.parse(fs.readFileSync(path.join(workspace, output), 'utf8'))).toEqual(expected);
for (const [file, text] of Object.entries(inputs)) expect(fs.readFileSync(path.join(workspace, file), 'utf8')).toBe(text);
expect(fs.readFileSync(path.join(workspace, 'analysis.py'))).toEqual(source);
});
it.each(['json.py', 'json/__init__.py', 'hashlib.py', 'json.pyc', 'json.native.so', 'json/__init__.native.pyd', 'JSON.PYD'])
('requires approval for the data-analysis import shadow candidate %s', async module => {
fs.copyFileSync(path.resolve('test/fixtures/sqlite-local-analysis/orders.py'), path.join(workspace, 'analysis.py'));
const shadow = path.join(workspace, module);
fs.mkdirSync(path.dirname(shadow), { recursive: true });
fs.writeFileSync(shadow, 'raise RuntimeError("must not execute")');
pendingDecision = 'deny';
const result = await bash.execute({ command: 'python analysis.py' }, context());
expect(result.isError).toBe(true);
expect(result.content).toContain('E_BASH_RISK_DENIED');
expect(requests).toHaveLength(1);
expect(requests[0].reasons).toContain('external_mutation');
expect(requests[0].external_mutations?.every(f => !('resource' in f))).toBe(true);
expect(fs.existsSync(path.join(workspace, 'analysis.json'))).toBe(false);
expect(fs.existsSync(path.join(workspace, '__pycache__'))).toBe(false);
});
it('retains approval when the data-import scope exceeds the bounded inspection', async () => {
fs.copyFileSync(path.resolve('test/fixtures/sqlite-local-analysis/orders.py'), path.join(workspace, 'analysis.py'));
for (let i = 0; i < 1025; i++) fs.writeFileSync(path.join(workspace, `entry-${i}.txt`), 'unchanged');
pendingDecision = 'deny';
const result = await bash.execute({ command: 'python analysis.py' }, context());
expect(result.isError).toBe(true);
expect(result.content).toContain('E_BASH_RISK_DENIED');
expect(requests).toHaveLength(1);
expect(requests[0].reasons).toContain('external_mutation');
expect(fs.existsSync(path.join(workspace, 'analysis.json'))).toBe(false);
expect(fs.readFileSync(path.join(workspace, 'entry-0.txt'), 'utf8')).toBe('unchanged');
});
it('retains approval for a file-based PYTHONPATH importer without executing it', async () => {
fs.copyFileSync(path.resolve('test/fixtures/sqlite-local-analysis/orders.py'), path.join(workspace, 'analysis.py'));
const archive = path.join(workspace, 'modules.zip');
fs.writeFileSync(archive, Buffer.alloc(22));
pendingDecision = 'deny';
const ctx = context();
ctx.state.sandboxEnv = { ...sandboxEnv, PYTHONPATH: archive };
const result = await bash.execute({ command: 'python analysis.py' }, ctx);
expect(result.isError).toBe(true);
expect(result.content).toContain('E_BASH_RISK_DENIED');
expect(requests).toHaveLength(1);
expect(requests[0].reasons).toContain('external_mutation');
expect(fs.readFileSync(archive)).toEqual(Buffer.alloc(22));
expect(fs.existsSync(path.join(workspace, 'analysis.json'))).toBe(false);
});
it.runIf(process.platform !== 'win32').each(['json.py', 'json.native.so', 'json.pyc'])
('retains approval before a combined command creates the import shadow %s', async module => {
fs.copyFileSync(path.resolve('test/fixtures/sqlite-local-analysis/orders.py'), path.join(workspace, 'analysis.py'));
pendingDecision = 'deny';
const command = `cat > ${module} <<'MODULE'\nraise RuntimeError('must not execute')\nMODULE\npython analysis.py`;
const result = await bash.execute({ command }, context());
expect(result.isError).toBe(true);
expect(result.content).toContain('E_BASH_RISK_DENIED');
expect(requests).toHaveLength(1);
expect(requests[0].reasons).toContain('external_mutation');
expect(fs.existsSync(path.join(workspace, module))).toBe(false);
expect(fs.existsSync(path.join(workspace, 'analysis.json'))).toBe(false);
});
it.each(['workspace_approval', 'all_files_auto'] as const)(
'cleans a task-created build tree without a dialog and preserves a later user file in %s', async mode => {
const permissions = await import('../../../../src/main/features/permissions');
@@ -160,15 +274,18 @@ describe('bash permission journey — deterministic native execution', () => {
},
);
// Current command-start paths: one-shot bash, managed bash continued through
// process_session, and the live-input interactive_cli session.
it.runIf(process.platform !== 'win32').each([
['bash', 'workspace_approval'], ['bash', 'all_files_approval'],
['process_session', 'workspace_approval'], ['process_session', 'all_files_approval'],
['managed bash', 'workspace_approval'], ['managed bash', 'all_files_approval'],
['interactive_cli', 'workspace_approval'], ['interactive_cli', 'all_files_approval'],
] as const)('cleans ordinary entries with redirection through %s in %s', async (name, mode) => {
const permissions = await import('../../../../src/main/features/permissions');
permissions.setLocalExecMode(mode);
const { createLocalTools } = await import('../../../../src/main/model/core-agent/local-tools');
const tool = createLocalTools({ userId: UID, cid: 'permission-journey', agentId: 'model-eval' })
.find(candidate => candidate.name === name)!;
const tools = createLocalTools({ userId: UID, cid: 'permission-journey', agentId: 'model-eval' });
const toolNamed = (toolName: string) => tools.find(candidate => candidate.name === toolName)!;
pendingDecision = 'deny';
for (const command of [
'rmdir empty 2>/dev/null || true; printf verified',
@@ -178,24 +295,51 @@ describe('bash permission journey — deterministic native execution', () => {
]) {
fs.mkdirSync(path.join(workspace, 'empty'));
fs.writeFileSync(path.join(workspace, 'disposable.txt'), 'temporary');
let result = await tool.execute({ action: 'start', command }, context());
expect(result.isError, result.content).toBeFalsy();
if (name === 'process_session') {
if (name === 'interactive_cli') {
// Interactive sessions report the terminal state in their payload and
// carry no execution observation.
const interactive = toolNamed('interactive_cli');
const started = await interactive.execute({ action: 'start', command }, context());
expect(started.isError, started.content).toBeFalsy();
let payload = JSON.parse(started.content);
try {
await vi.waitFor(async () => {
if (payload.status === 'running') {
payload = JSON.parse((await interactive.execute({ action: 'read', session_id: payload.session_id }, context())).content);
}
expect(payload.status).toBe('exited');
}, { timeout: 5_000, interval: 50 });
expect(payload.exit_code).toBe(0);
} finally {
await interactive.execute({ action: 'close', session_id: payload.session_id }, context());
}
} else if (name === 'managed bash') {
// A zero initial wait returns the running session; the terminal
// execution observation arrives through process_session continuation.
const started = await toolNamed('bash').execute({ command, yield_time_ms: 0 }, managedContext());
expect(started.isError, started.content).toBeFalsy();
let result = started;
let payload = JSON.parse(result.content);
expect(payload.status).toBe('running');
const continuation = toolNamed('process_session');
const sessionId = payload.session_id;
try {
await vi.waitFor(async () => {
if (payload.status === 'running') {
result = await tool.execute({ action: 'read', session_id: sessionId, cursor: payload.next_cursor }, context());
result = await continuation.execute({ action: 'read', session_id: sessionId, cursor: payload.next_cursor, yield_time_ms: 0 }, managedContext());
payload = JSON.parse(result.content);
}
expect(payload.status).toBe('exited');
}, { timeout: 5_000, interval: 50 });
} finally {
if (payload.status === 'running') await tool.execute({ action: 'stop', session_id: sessionId }, context());
if (payload.status === 'running') await continuation.execute({ action: 'stop', session_id: sessionId }, managedContext());
}
expect(result.observations?.execution).toMatchObject({ status: 'succeeded', exitCode: 0 });
} else {
const result = await toolNamed('bash').execute({ command }, context());
expect(result.isError, result.content).toBeFalsy();
expect(result.observations?.execution).toMatchObject({ status: 'succeeded', exitCode: 0 });
}
expect(result.observations?.execution).toMatchObject({ status: 'succeeded', exitCode: 0 });
expect(requests).toHaveLength(0);
expect(fs.existsSync(path.join(workspace, 'empty'))).toBe(false);
expect(fs.readFileSync(path.join(workspace, 'input-a.txt'), 'utf8')).toBe('alpha\n');
@@ -208,12 +352,10 @@ describe('bash permission journey — deterministic native execution', () => {
}
});
it.runIf(process.platform !== 'win32').each(['bash', 'process_session'])(
it.runIf(process.platform !== 'win32').each(['bash', 'managed bash'] as const)(
'checks redirection independently before any cleanup through %s', async name => {
expectedMainWarnings.push('bash filesystem mutation scope reject', 'bash filesystem read scope reject', 'interactive_cli_start risk denied');
const { createLocalTools } = await import('../../../../src/main/model/core-agent/local-tools');
const tool = createLocalTools({ userId: UID, cid: 'permission-journey', agentId: 'model-eval' })
.find(candidate => candidate.name === name)!;
expectedMainWarnings.push('bash filesystem mutation scope reject', 'bash filesystem read scope reject');
const ctx = name === 'managed bash' ? managedContext() : context();
const outside = path.join(root, 'outside.txt');
fs.writeFileSync(outside, 'outside stays intact');
fs.mkdirSync(path.join(workspace, 'empty'));
@@ -227,7 +369,7 @@ describe('bash permission journey — deterministic native execution', () => {
['rmdir empty > "$UNKNOWN_REDIRECT"', 'E_BASH_RISK_DENIED'],
['rm -r empty 2>/dev/null', 'E_BASH_RISK_DENIED'],
]) {
const result = await tool.execute({ action: 'start', command }, context());
const result = await bash.execute({ command }, ctx);
expect(result.isError, command).toBe(true);
expect(result.content).toContain(error);
expect(fs.statSync(path.join(workspace, 'empty')).isDirectory()).toBe(true);
@@ -239,6 +381,34 @@ describe('bash permission journey — deterministic native execution', () => {
},
);
// interactive_cli deliberately skips the read/write path-scope guard for login
// flows (policy: only a removal target gates interactive_cli.start), so the
// redirection rows above do not apply. Removal targets stay behind the gate.
it.runIf(process.platform !== 'win32')('gates removal targets before any cleanup through interactive_cli', async () => {
expectedMainWarnings.push('bash filesystem mutation scope reject', 'interactive_cli_start risk denied');
const { createLocalTools } = await import('../../../../src/main/model/core-agent/local-tools');
const interactive = createLocalTools({ userId: UID, cid: 'permission-journey', agentId: 'model-eval' })
.find(candidate => candidate.name === 'interactive_cli')!;
const outsideDir = path.join(root, 'outside-empty');
fs.mkdirSync(outsideDir);
fs.mkdirSync(path.join(workspace, 'empty'));
pendingDecision = 'deny';
for (const [command, error] of [
[`rmdir '${outsideDir}' 2>/dev/null`, 'E_BASH_PATH_OUT_OF_SCOPE'],
['rmdir "$UNKNOWN_CLEANUP_DIR" 2>/dev/null; rmdir empty', 'E_BASH_RISK_DENIED'],
['rm -r empty 2>/dev/null', 'E_BASH_RISK_DENIED'],
]) {
const result = await interactive.execute({ action: 'start', command }, context());
expect(result.isError, command).toBe(true);
expect(result.content).toContain(error);
expect(fs.statSync(outsideDir).isDirectory()).toBe(true);
expect(fs.statSync(path.join(workspace, 'empty')).isDirectory()).toBe(true);
}
expect(requests).toHaveLength(2);
expect(requests[0]).toMatchObject({ unresolved_paths: true, can_allow_run: false });
expect(requests[1].reasons).toContain('destructive');
});
it('never exempts raw-device writes just because ordinary cleanup is also present', async () => {
const { bashDestructiveRiskIsOnlyProducedFileDeletion } = await import('../../../../src/main/model/core-agent/local-tools');
// No device is opened. This guards the exemption even in all-files mode,
@@ -750,15 +920,16 @@ describe('bash permission journey — deterministic native execution', () => {
expect(fs.existsSync(path.join(workspace, '.env'))).toBe(false);
});
it('denies an outside SQLite write, then executes it once after explicit approval', async () => {
it.each(['execute', 'executescript'])('denies an outside SQLite write through %s, then executes it once after explicit approval', async api => {
const permissions = await import('../../../../src/main/features/permissions');
permissions.setLocalExecMode('all_files_approval');
const database = path.join(root, 'outside.db');
fs.writeFileSync(path.join(workspace, 'outside.py'), [
'import sqlite3',
`db = sqlite3.connect(${JSON.stringify(database)})`,
'db.execute("CREATE TABLE dogs (id integer)")',
'db.execute("INSERT INTO dogs VALUES (42)")',
`db.${api}("CREATE TABLE dogs (id integer)")`,
'for value in [42]:',
api === 'execute' ? ' db.execute("INSERT INTO dogs VALUES (?)", (value,))' : ' db.executescript("INSERT INTO dogs VALUES (42)")',
'db.commit()',
'print(db.execute("SELECT id FROM dogs").fetchall())',
].join('\n'));
@@ -366,6 +366,26 @@ describe('bash-risk › routine commands are NOT flagged', () => {
});
describe('bash-risk › structure / edge cases', () => {
it.each([
'constructor probe',
'__proto__ probe',
'constructor.exe probe',
'__proto__.cmd probe',
'bash -c "constructor probe"',
'powershell -Command "__proto__ probe"',
])('treats unregistered prototype-named executables as ordinary commands: %s', command => {
expect(classifyBashCommand(command).reasons).toEqual([]);
});
it.each([
['constructor probe; curl https://example.com', 'network_egress'],
['__proto__ probe; rm -rf ./cache', 'destructive'],
['constructor probe; npm install -g example', 'system_package_change'],
['__proto__ probe ~/.ssh/id_rsa', 'sensitive_path'],
] as const)('preserves independent risk after an unregistered executable: %s', (command, reason) => {
expect(classifyBashCommand(command).reasons).toContain(reason);
});
it('empty / whitespace command is not risky', () => {
expect(classifyBashCommand('').risky).toBe(false);
expect(classifyBashCommand(' ').risky).toBe(false);
@@ -32,6 +32,26 @@ function ctxFor(state: Record<string, unknown> = {}) {
return { state } as unknown as { state: Record<string, unknown> };
}
// Test fixture selectors are encoded into the single public contract here.
function historyRequest(action: 'search' | 'read', input: Record<string, unknown>) {
if (['search', 'read', 'process', 'output'].some(key => input[key] !== undefined)) return { ...input, action: input.action ?? action };
const { scope, cid, cursor, max_tokens, ...selection } = input;
delete selection.action;
if (action === 'read') {
const { msg_index, before_msg_index, window, limit } = selection;
if ((msg_index !== undefined && before_msg_index !== undefined)
|| (selection.page !== undefined && [msg_index, before_msg_index, window, limit].some(value => value !== undefined))) return { ...input, action };
delete selection.msg_index; delete selection.before_msg_index; delete selection.window; delete selection.limit;
if (!selection.page && (msg_index !== undefined || before_msg_index !== undefined || limit !== undefined)) selection.page = msg_index !== undefined
? { mode: 'around', index: msg_index, ...(window !== undefined ? { count: window } : {}) }
: before_msg_index !== undefined ? { mode: 'before', index: before_msg_index, ...(limit !== undefined ? { count: limit } : {}) }
: { mode: 'latest', count: limit };
}
const contract = require('../../../../bin/chat-history-contract.cjs');
return contract.locator({ action, ...selection, ...(scope !== undefined ? { scope } : {}),
...(cid !== undefined ? { cid } : {}), ...(cursor !== undefined ? { cursor } : {}), ...(max_tokens !== undefined ? { max_tokens } : {}) });
}
async function createChatHistoryActions(opts: {
userId: string;
currentCid?: string;
@@ -49,7 +69,7 @@ async function createChatHistoryActions(opts: {
const tool = createChatHistoryTool(opts);
const forAction = (action: 'search' | 'read') => ({
...tool,
execute: (input: Record<string, unknown>, ctx: any) => tool.execute({ ...input, action }, ctx),
execute: (input: Record<string, unknown>, ctx: any) => tool.execute(historyRequest(action, { ...(opts.allowedScopes?.length === 1 && opts.allowedScopes[0] === 'current' ? { scope: 'current' } : {}), ...input }), ctx),
});
return [forAction('search'), forAction('read'), tool] as const;
}
@@ -172,6 +192,117 @@ describe('history availability', () => {
});
});
describe('grouped history operations', () => {
it('reads recent messages with populated inactive operation groups instead of activating their selectors', async () => {
writeConversation('grouped', 'History', [
{ id: 'prior', from: 'commander', text: 'PUBLIC_PRIOR_FACT' },
{ id: 'now', from: 'user', text: 'CURRENT_TRIGGER' },
]);
const [,,tool] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'grouped', currentMessageId: 'now', allowedScopes: ['current'] });
const result = await tool.execute({ action: 'read', scope: 'current',
read: { page: { mode: 'latest', index: 0, count: 10 } },
search: { query: '', k: 0 }, process: { record_id: '', process_index: 0 },
output: { record_id: '', tool_call_id: '' }, cursor: 0,
}, ctxFor());
expect(result.isError).toBeFalsy();
expect(result.content).toContain('PUBLIC_PRIOR_FACT');
expect(result.content).not.toContain('CURRENT_TRIGGER');
expect(result.content).toContain('ignored_fields: ["output","process","search"]');
});
describe('omitted read group', () => {
const record = (i: number) => `RECORD_${String(i).padStart(2, '0')}`;
const recentHistory = async (allowedScopes: readonly ('current' | 'all')[]) => {
writeConversation('recent', 'History', [
...Array.from({ length: 12 }, (_, i) => ({ id: `m${i}`, from: 'commander', text: record(i) })),
{ id: 'now', from: 'user', text: 'CURRENT_TRIGGER' },
]);
const [,,tool] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'recent', currentMessageId: 'now', allowedScopes });
return tool;
};
it('returns the latest ten messages the schema advertises', async () => {
const tool = await recentHistory(['current']);
expect((tool.inputSchema as any).required).toEqual(['action', 'scope']);
expect((tool.inputSchema as any).properties.read.description).toContain('default latest 10');
const omitted = await tool.execute({ action: 'read', scope: 'current' }, ctxFor());
expect(omitted.isError).toBeFalsy();
for (let i = 2; i < 12; i++) expect(omitted.content).toContain(record(i));
for (const excluded of [record(0), record(1), 'CURRENT_TRIGGER']) expect(omitted.content).not.toContain(excluded);
expect(omitted.content).not.toContain('ignored_fields');
const empty = await tool.execute({ action: 'read', scope: 'current', read: {} }, ctxFor());
expect(omitted.content).toBe(empty.content);
});
it('applies the same default to a cross-conversation read', async () => {
await recentHistory(['current', 'all']);
const [,,other] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'elsewhere', currentMessageId: 'x', allowedScopes: ['current', 'all'] });
const result = await other.execute({ action: 'read', scope: 'all', cid: 'recent' }, ctxFor());
expect(result.isError).toBeFalsy();
expect(result.content).toContain(record(11));
expect(result.content).not.toContain(record(1));
});
it.each([
['a null read group', { action: 'read', read: null }, 'read'],
['a non-object read group', { action: 'read', read: 'latest' }, 'read'],
['search without its group', { action: 'search' }, 'search'],
['process read without its group', { action: 'read_process' }, 'process'],
['output read without its group', { action: 'read_output' }, 'output'],
])('still refuses %s before reading anything', async (_label, input, field) => {
const tool = await recentHistory(['current']);
const result = await tool.execute({ scope: 'current', ...input }, ctxFor());
expect(result.isError).toBe(true);
expect(JSON.parse(result.content)).toMatchObject({ code: 'invalid_arguments', invalid_fields: [field] });
expect(result.content).not.toContain('RECORD_');
});
it.each([
[{ record_id: 'm0' }], [{ turn_id: 'm0' }], [{ page: { mode: 'latest', count: 1 } }],
[{ msg_index: 0 }], [{ before_msg_index: 1 }], [{ limit: 1 }],
])('does not turn the retired flat selector %j into a default read', async selector => {
const tool = await recentHistory(['current']);
const result = await tool.execute({ action: 'read', scope: 'current', ...selector }, ctxFor());
expect(result.isError).toBe(true);
expect(JSON.parse(result.content)).toMatchObject({ code: 'unsupported_fields', fields: Object.keys(selector) });
expect(result.content).not.toContain('RECORD_');
});
});
it('validates the selected group, retains zero process indexes and rejects mixed old/new formats', async () => {
writeConversation('grouped', 'History', [
{ id: 'prior', from: 'commander', text: 'WHOLE_RECORD', process: [
{ type: 'event', event: { stream: 'tool', data: { phase: 'end', id: 'public-call', output: 'PUBLIC_SEGMENT_ZERO' } } },
] }, { id: 'now', from: 'user', text: 'CURRENT_TRIGGER' },
]);
const [,,tool] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'grouped', currentMessageId: 'now', allowedScopes: ['current'] });
const valid = await tool.execute({ action: 'read_process', scope: 'current', process: { record_id: 'prior', process_index: 0 } }, ctxFor());
expect(valid.isError).toBeFalsy();
expect(valid.content).toContain('PUBLIC_SEGMENT_ZERO');
expect(valid.content).not.toContain('WHOLE_RECORD');
const unavailable = await tool.execute({ action: 'read_output', scope: 'current', output: { record_id: 'prior', tool_call_id: 'public-call' } }, ctxFor());
expect(unavailable.isError).toBe(true);
expect(JSON.parse(unavailable.content)).toMatchObject({ code: 'E_HISTORY_OUTPUT_UNAVAILABLE', available_projection: 'public_process' });
for (const input of [
{ action: 'read_process', process: { record_id: '', process_index: 0 } },
{ action: 'read_process', process: { record_id: 'prior', process_index: -1 } },
{ action: 'read_output', output: { record_id: 'prior', tool_call_id: '' } },
{ action: 'read', read: { page: { mode: 'latest' } }, record_id: 'prior' },
]) {
const result = await tool.execute({ scope: 'current', ...input }, ctxFor());
expect(result.isError, JSON.stringify(input)).toBe(true);
expect(result.content).not.toMatch(/PUBLIC_SEGMENT_ZERO|WHOLE_RECORD/);
const failure = JSON.parse(result.content);
if (input.action === 'read_process' && input.process?.record_id === '') {
expect(failure.issues).toContainEqual(expect.objectContaining({ field: 'process.record_id', minimum: 1 }));
}
}
const old = await tool.execute({action: 'read_process', scope: 'current', process: {record_id: 'prior', process_index: 0}}, ctxFor());
expect(old.isError).toBeFalsy();
expect(old.content).toContain('PUBLIC_SEGMENT_ZERO');
});
});
describe('chat-history-tools › chat_history(search)', () => {
it('finds current group-chat message text and returns cid/msg metadata', async () => {
writeConversation('cgroup', 'Planning chat', [
@@ -214,7 +345,7 @@ describe('chat-history-tools › chat_history(search)', () => {
const [chatSearch] = await createChatHistoryActions({ userId: TEST_UID });
const result = await chatSearch.execute({ query: ' ' }, ctxFor());
expect(result.isError).toBe(true);
expect(result.content).toMatch(/required/);
expect(JSON.parse(result.content)).toMatchObject({ code: 'invalid_arguments', invalid_fields: ['search.query'] });
});
it('prefers the current conversation when relevance ties', async () => {
@@ -319,7 +450,7 @@ describe('chat-history-tools › chat_history(search)', () => {
expect((chatSearch.inputSchema.properties as any).scope.enum).toEqual(['current', 'all']);
const result = await chatSearch.execute({ query: 'anything', scope: 'project' }, ctxFor());
expect(result.isError).toBe(true);
expect(result.content).toMatch(/scope "project" is not allowed/);
expect(JSON.parse(result.content)).toMatchObject({ code: 'invalid_arguments', invalid_fields: ['scope'] });
});
it('searches only visible earlier rows in the host-bound current conversation', async () => {
@@ -461,8 +592,8 @@ describe('chat-history-tools › chat_history(search)', () => {
const all = await chatSearch.execute({ query: 'x', scope: 'all' }, ctxFor());
expect(project.isError).toBe(true);
expect(all.isError).toBe(true);
expect(project.content).toContain('not allowed for this agent');
expect(all.content).toContain('not allowed for this agent');
expect(JSON.parse(project.content)).toMatchObject({ code: 'invalid_arguments', invalid_fields: ['scope'] });
expect(JSON.parse(all.content)).toMatchObject({ code: 'invalid_arguments', invalid_fields: ['scope'] });
});
});
@@ -490,7 +621,7 @@ describe('chat-history-tools › chat_history(read)', () => {
expect(schema.properties).not.toHaveProperty('before_msg_index');
expect(schema.properties).not.toHaveProperty('window');
expect(schema.properties).not.toHaveProperty('limit');
expect(schema.properties.page).toMatchObject({
expect(schema.properties.read.properties.page).toMatchObject({
type: 'object',
additionalProperties: false,
properties: {
@@ -530,7 +661,7 @@ describe('chat-history-tools › chat_history(read)', () => {
expect(around.content).toContain('last tagged followup');
});
it('rejects mixed tagged and legacy paging while keeping legacy calls executable', async () => {
it('rejects retired paging fields and executes canonical tagged paging', async () => {
writeConversation('cmixed', 'Mixed chat', [
{ id: 'm0', ts: '2026-01-01T00:00:00Z', from: 'user', text: 'mixed note' },
]);
@@ -542,11 +673,11 @@ describe('chat-history-tools › chat_history(read)', () => {
msg_index: 0,
}, ctxFor());
expect(mixed.isError).toBe(true);
expect(mixed.content).toContain('cannot be combined');
expect(JSON.parse(mixed.content)).toMatchObject({ code: 'unsupported_fields' });
const legacy = await chatRead.execute({ cid: 'cmixed', msg_index: 0, window: 0 }, ctxFor());
expect(legacy.isError).toBeFalsy();
expect(legacy.content).toContain('mixed note');
const current = await chatRead.execute({ cid: 'cmixed', page: { mode: 'around', index: 0, count: 0 } }, ctxFor());
expect(current.isError).toBeFalsy();
expect(current.content).toContain('mixed note');
});
it('returns a window around the requested message index', async () => {
@@ -622,7 +753,7 @@ describe('chat-history-tools › chat_history(read)', () => {
const result = await chatRead.execute({ cid: 'outside', scope: 'project' }, ctxFor());
expect(result.isError).toBe(true);
expect(result.content).toMatch(/scope "project" is not allowed/);
expect(JSON.parse(result.content)).toMatchObject({ code: 'invalid_arguments', invalid_fields: ['scope'] });
});
it('rejects unsafe conversation ids', async () => {
@@ -685,8 +816,8 @@ describe('chat-history-tools › chat_history(read)', () => {
scope: 'current',
cid: 'other-read',
}, ctxFor());
expect(hostBound.isError).toBeFalsy();
expect(hostBound.content).toContain('PUBLIC_TEXT_ONLY');
expect(hostBound.isError).toBe(true);
expect(hostBound.content).toContain('unsupported_fields');
expect(hostBound.content).not.toContain('OTHER_CONVERSATION_TEXT');
});
@@ -754,7 +885,7 @@ describe('chat-history-tools › chat_history(read)', () => {
before_msg_index: 10,
}, ctxFor());
expect(conflicting.isError).toBe(true);
expect(conflicting.content).toContain('cannot be combined');
expect(JSON.parse(conflicting.content)).toMatchObject({ code: 'unsupported_fields' });
});
});
@@ -763,7 +894,7 @@ describe('chat-history-tools › shape', () => {
const [, , chatHistory] = await createChatHistoryActions({ userId: TEST_UID });
const schema = chatHistory.inputSchema as any;
expect(chatHistory.name).toBe('chat_history');
expect(schema.properties.action.enum).toEqual(['search', 'read']);
expect(schema.properties.action.enum).toEqual(['search', 'read', 'read_process', 'read_output']);
expect(schema.properties.scope.enum).toEqual(['current', 'all']);
expect(chatHistory.inputSchema.required).toEqual(['action']);
expect(JSON.stringify(chatHistory.inputSchema)).not.toMatch(/project/i);
@@ -790,68 +921,49 @@ describe('chat-history-tools › shape', () => {
const providerContext = buildPiContextForTest([], undefined, [toToolDefinition(tool)]);
const schema = Compile(JSON.parse(JSON.stringify(providerContext.tools![0].parameters)));
const scope = currentOnly ? 'current' : 'all';
const search = { action: 'search', scope, query: 'contractword', k: 2 };
const read = {
action: 'read', scope,
...(currentOnly ? {} : { cid: 'sibling' }),
page: { mode: 'latest', count: 1 },
};
const search = { action: 'search', scope, search: { query: 'contractword', k: 2 } };
const read = { action: 'read', scope, ...(currentOnly ? {} : { cid: 'sibling' }), read: { page: { mode: 'latest', count: 1 } } };
for (const valid of [search, read]) {
expect(schema.Check(valid), JSON.stringify(valid)).toBe(true);
const result = await tool.execute(valid, ctxFor());
expect(result.isError).toBeFalsy();
expect(result.content).toContain('contractword');
}
const harmlessCases: Array<[Record<string, unknown>, string[]]> = [
[{ ...read, query: 'contractword' }, ['query']],
[{ ...read, k: 2 }, ['k']],
...(!currentOnly ? [[{ ...read, include_current: true }, ['include_current']] as [Record<string, unknown>, string[]]] : []),
[{ ...search, page: read.page }, ['page']],
...(!currentOnly ? [[{ ...search, cid: '' }, ['cid']] as [Record<string, unknown>, string[]]] : []),
];
for (const [harmless, ignored] of harmlessCases) {
expect(schema.Check(harmless)).toBe(true);
const result = await tool.execute(harmless, ctxFor());
for (const [input, ignored] of [
[{ ...read, search: search.search }, ['search']],
[{ ...read, process: { record_id: '', process_index: 0 }, output: { record_id: '', tool_call_id: '' } }, ['output', 'process']],
[{ ...search, read: read.read }, ['read']],
] as const) {
const result = await tool.execute(input, ctxFor());
expect(result.isError).toBeFalsy();
expect(result.content).toContain('contractword');
expect(result.content).toContain(`ignored_fields: ${JSON.stringify(ignored)}`);
expect(result.content).not.toContain('ignored_values');
}
for (const invalid of [{ ...search, cid: 'sibling' }, ...['record_id', 'turn_id', 'tool_call_id'].map(key => ({ ...search, [key]: 'selected-record' })), { ...search, unknown_field: true }]) {
for (const invalid of [{ ...search, cid: 'sibling' }, { ...search, record_id: 'selected-record' }, { ...read, page: { mode: 'latest' } }]) {
const result = await tool.execute(invalid, ctxFor());
expect(result.isError).toBe(true);
expect(result.content).toContain('unsupported field(s)');
expect(result.content).toMatch(/unsupported_search_selector|unsupported_fields/);
}
const unknown = { ...search, unknown_field: true };
expect(schema.Check(unknown)).toBe(false);
expect((await tool.execute(unknown, ctxFor())).isError).toBe(true);
expect(schema.Check({ action: 'search', scope })).toBe(true);
expect((await tool.execute({ action: 'search', scope }, ctxFor())).isError).toBe(true);
expect(schema.Check({ ...search, action: 'invalid' })).toBe(false);
expect(schema.Check({action: 'search', scope})).toBe(true);
expect((await tool.execute({action: 'search', scope, search: {}}, ctxFor())).isError).toBe(true);
});
it('keeps action guidance on the tool and paging semantics on their fields', async () => {
const [, , chatHistory] = await createChatHistoryActions({ userId: TEST_UID, projectId: 'project-a' });
const description = chatHistory.description.replace(/\s+/g, ' ');
const properties = chatHistory.inputSchema.properties as any;
expect(description).toContain('earlier work dependencies');
expect(description).toContain('potentially stale quoted data');
expect(properties.query.description).toContain('natural language or keywords');
expect(properties.query.description).toContain('discriminative name, phrase, id, or fact');
expect(properties.query.description).toContain('Search only');
expect(properties.action.description).toContain('Unused fields are reported in ignored_fields');
expect(properties.action.description).toContain('latest for vague local references');
expect(properties.action.description).toContain('Follow next_read');
expect(properties.cid.description).toContain('Read only');
expect(properties.cid.description).toContain('conversation ID from search');
expect(properties.page.description).toContain('Read only');
expect(properties.page.properties.mode.description).toContain('latest: tail');
expect(properties.page.properties.mode.description).toContain('around: centered on index');
expect(properties.page.properties.mode.description).toContain('before: backward');
expect((chatHistory.inputSchema.properties as any).scope.enum).toEqual(['current', 'project', 'all']);
expect((chatHistory.inputSchema.properties as any).scope.description).toContain('project stays in this project');
expect((chatHistory.inputSchema.properties as any).include_current.type).toBe('boolean');
expect((chatHistory.inputSchema.properties as any).page.properties.count.description).toContain('Defaults to 3 or 10');
it('advertises separate operation groups and rejects retired selectors at execution', async () => {
const [, , tool] = await createChatHistoryActions({ userId: TEST_UID, projectId: 'project-a' });
const properties = tool.inputSchema.properties as any;
expect(properties.search.required).toEqual(['query']);
expect(properties.read.properties.page.required).toEqual(['mode']);
expect(properties.process.required).toEqual(['record_id', 'process_index']);
expect(properties.output.required).toEqual(['record_id', 'tool_call_id']);
expect(properties.process.properties.process_index.minimum).toBe(0);
expect(properties.search.properties.include_current.type).toBe('boolean');
expect(properties.scope.enum).toEqual(['current', 'project', 'all']);
expect(properties).not.toHaveProperty('query');
expect(properties).not.toHaveProperty('page');
expect(properties).not.toHaveProperty('process_index');
expect(properties).not.toHaveProperty('output_cursor');
expect(tool.compatibilityInputFields).toEqual(expect.arrayContaining(['query', 'page', 'process_index', 'output_cursor']));
});
it('advertises only current scope to ordinary Agents', async () => {
@@ -861,10 +973,10 @@ describe('chat-history-tools › shape', () => {
allowedScopes: ['current'],
});
expect((chatHistory.inputSchema.properties as any).scope.enum).toEqual(['current']);
expect((chatHistory.inputSchema.properties as any).page.properties.mode.enum)
expect((chatHistory.inputSchema.properties as any).read.properties.page.properties.mode.enum)
.toEqual(['latest', 'around', 'before', 'from']);
expect((chatHistory.inputSchema.properties as any).scope.description)
.toBe('History scope. current is host-bound to this conversation.');
.toBe('Host-bound to this conversation.');
expect((chatHistory.inputSchema.properties as any)).not.toHaveProperty('cid');
expect((chatHistory.inputSchema as any).required).toEqual(['action', 'scope']);
});
@@ -872,31 +984,33 @@ describe('chat-history-tools › shape', () => {
it('rejects a missing action and cross-action search locators', async () => {
const [, , chatHistory] = await createChatHistoryActions({ userId: TEST_UID });
const missingAction = await chatHistory.execute({ query: 'x' }, ctxFor());
const crossActionField = await chatHistory.execute({
action: 'search', cid: 'c1', query: 'x',
}, ctxFor());
const crossActionField = await chatHistory.execute({action: 'read', cid: 'c1', query: 'x', read: {}}, ctxFor());
expect(missingAction.isError).toBe(true);
expect(missingAction.content).toContain('`action`');
expect(missingAction.content).toContain('invalid_action');
for (const action of ['__proto__', 'constructor', 'toString', 0, null, {}]) {
const rejected = await chatHistory.execute({ action }, ctxFor());
expect(rejected.isError).toBe(true);
expect(rejected.content).toContain('invalid_action');
}
expect(crossActionField.isError).toBe(true);
expect(crossActionField.content).toContain('unsupported field(s)');
const crossSearch = await chatHistory.execute({ action: 'search', scope: 'all', cid: 'c1', search: { query: 'x' } }, ctxFor());
expect(crossSearch.isError).toBe(true);
expect(JSON.parse(crossSearch.content)).toMatchObject({ code: 'unsupported_search_selector', conflicting_fields: ['cid'] });
expect(JSON.parse(crossActionField.content)).toMatchObject({ code: 'unsupported_fields', fields: ['query'] });
});
it('retains runtime compatibility for legacy search calls that carried read paging metadata', async () => {
it('rejects retired search inputs before selecting history', async () => {
writeConversation('search-page-compat', 'Search paging compatibility', [
{ id: 'm0', ts: '2026-01-01T00:00:00Z', from: 'user', text: 'find schemaunionword here' },
]);
const [, , chatHistory] = await createChatHistoryActions({ userId: TEST_UID });
expect((chatHistory.inputSchema as any).properties).toHaveProperty('page');
expect((chatHistory.inputSchema as any)).not.toHaveProperty('oneOf');
const result = await chatHistory.execute({
action: 'search',
query: 'schemaunionword',
page: { mode: 'latest', count: 10 },
action: 'search', search: {}, query: 'schemaunionword', page: { mode: 'latest', count: 10 },
}, ctxFor());
expect(result.isError).toBeFalsy();
expect(result.content).toContain('cid=search-page-compat');
expect(result.isError).toBe(true);
expect(result.content).toContain('unsupported_fields');
});
it('fails current scope closed when the host omitted the turn boundary', async () => {
@@ -949,7 +1063,7 @@ describe('historical execution retrieval and exact paging', () => {
const opts = { userId: TEST_UID, currentCid: 'child-history', currentMessageId: 'now', allowedScopes: ['current'] as const };
const ledger = { remainingTokens: 0, perResultTokens: 10_000 };
const ctx = ctxFor({ toolResultInlineLedger: ledger, isProgrammaticToolCall: true });
const input = { action: 'read', scope: 'current', record_id: 'old' };
const input = {action: 'read', scope: 'current', read: {record_id: 'old'}};
const child = createChatHistoryTool({ ...opts, isProgrammaticToolCallContext: () => true });
expect((await child.execute(input, ctx)).content).toContain('ORIGINAL_CHILD_INPUT');
expect(ledger.remainingTokens).toBe(0);
@@ -1054,7 +1168,7 @@ describe('chat history discovery and evidence recovery', () => {
], 'p1');
const [,,tool] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'current', currentMessageId: 'now', projectId: 'p1' });
const decode = (s: string) => s.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
let args: any = { action: 'read', scope, ...(scope !== 'current' ? { cid } : {}), turn_id: 'report-turn', max_tokens: 1000 };
let args: any = {action: 'read', scope, ...(scope !== 'current' ? { cid } : {}), max_tokens: 1000, read: {turn_id: 'report-turn'}};
const restored = new Map<string, string>();
let pages = 0, done = false;
for (; pages < 30; pages++) {
@@ -1067,8 +1181,9 @@ describe('chat history discovery and evidence recovery', () => {
}
const next = decode(/<next_read>(.*?)<\/next_read>/.exec(result.content)![1]);
if (next === 'done') { done = true; break; }
expect(result.content).not.toContain('Older records:');
args = JSON.parse(next);
expect(args).toMatchObject({ action: 'read', scope, turn_id: 'report-turn', max_tokens: 1000 });
expect(args).toMatchObject({ action: 'read', scope, read: { turn_id: 'report-turn' }, max_tokens: 1000 });
expect(args.cid).toBe(scope === 'current' ? undefined : cid);
}
expect(done).toBe(true);
@@ -1084,9 +1199,9 @@ describe('chat history discovery and evidence recovery', () => {
{ type: 'progress', text: report },
] }, { id: 'now', from: 'user', text: 'Recall the report' }]);
const [,,tool] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'focused', currentMessageId: 'now', allowedScopes: ['current'] });
const hit = await tool.execute({ action: 'search', scope: 'current', query: 'Cedar verified report' }, ctxFor());
const hit = await tool.execute({action: 'search', scope: 'current', search: {query: 'Cedar verified report'}}, ctxFor());
const locator = JSON.parse(/^ process_read: (.+)$/m.exec(hit.content)![1]);
expect(locator).toMatchObject({ action: 'read', scope: 'current', record_id: 'report', process_index: 2 });
expect(locator).toMatchObject({ action: 'read_process', scope: 'current', process: { record_id: 'report', process_index: 2 } });
const decode = (s: string) => s.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
let args = { ...locator, max_tokens: 1000 }, restored = '', pages = 0, done = false;
const { estimateToolResultTokens } = await import('../../../../src/main/util/tool-result-cap');
@@ -1102,7 +1217,7 @@ describe('chat history discovery and evidence recovery', () => {
const next = decode(/<next_read>(.*?)<\/next_read>/.exec(result.content)![1]);
if (next === 'done') { done = true; break; }
args = JSON.parse(next);
expect(args).toMatchObject({ action: 'read', scope: 'current', process_index: 2, max_tokens: 1000 });
expect(args).toMatchObject({ action: 'read_process', scope: 'current', process: { process_index: 2 }, max_tokens: 1000 });
}
expect(done).toBe(true);
expect(pages).toBeGreaterThan(1);
@@ -1124,11 +1239,11 @@ describe('chat history discovery and evidence recovery', () => {
{ process_index: 1, output_cursor: 0 }, { process_index: 1, tool_call_id: 'wrong-call' },
{ process_index: 1, record_id: 'now' },
]) {
const result = await tool.execute({ action: 'read', scope: 'current', record_id: 'report', ...delta }, ctxFor());
const result = await tool.execute({action: 'read', scope: 'current', ...delta, read: {record_id: 'report'}}, ctxFor());
expect(result.isError, JSON.stringify(delta)).toBe(true);
expect(result.content).not.toMatch(/PRIVATE_SENTINEL|PUBLIC_SENTINEL|WHOLE_MESSAGE_SENTINEL/);
}
const valid = await tool.execute({ action: 'read', scope: 'current', record_id: 'report', process_index: 1, tool_call_id: 'public-call' }, ctxFor());
const valid = await tool.execute({action: 'read_process', scope: 'current', process: {record_id: 'report', tool_call_id: 'public-call', process_index: 1}}, ctxFor());
expect(valid.isError).toBeFalsy();
expect(valid.content).toContain('PUBLIC_SENTINEL');
expect(valid.content).not.toContain('WHOLE_MESSAGE_SENTINEL');
@@ -1140,18 +1255,35 @@ describe('chat history discovery and evidence recovery', () => {
const directory = cloudSessionToolResultsDirFor(TEST_UID, 'gconv-retained');
const file = persistToolResult(directory, 'lookup', 'FULL_RETAINED_OUTPUT ' + 'data '.repeat(3000));
writeConversation('retained', 'Receipt', [{ id: 'report', from: 'commander', text: 'Done', process: [
{ type: 'event', event: { stream: 'tool', data: { phase: 'start', id: 'call', name: 'lookup' } } },
{ type: 'event', event: { stream: 'tool', data: { phase: 'end', id: 'call', name: 'lookup', result_preview: 'Short preview', result_path: file } } },
] }, { id: 'now', from: 'user', text: 'Recall' }]);
const [,,tool] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'retained', currentMessageId: 'now', allowedScopes: ['current'] });
const receipt = await tool.execute({ action: 'read', scope: 'current', record_id: 'report', process_index: 0, max_tokens: 1000 }, ctxFor());
const receipt = await tool.execute({action: 'read_process', scope: 'current', max_tokens: 1000, process: {record_id: 'report', process_index: 0}}, ctxFor());
expect(receipt.isError).toBeFalsy();
const decode = (s: string) => s.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
const locator = JSON.parse(decode(/<output_read>(.*?)<\/output_read>/.exec(receipt.content)![1]));
expect(locator).toMatchObject({ action: 'read', scope: 'current', record_id: 'report', tool_call_id: 'call', output_cursor: 0 });
expect(locator).toMatchObject({ action: 'read_output', scope: 'current', output: { record_id: 'report', tool_call_id: 'call' }, cursor: 0 });
expect(locator).not.toHaveProperty('process_index');
const output = await tool.execute(locator, ctxFor());
expect(output.isError).toBeFalsy();
expect(output.content).toContain('FULL_RETAINED_OUTPUT');
const whole = await tool.execute({ action: 'read', scope: 'current', read: { record_id: 'report', include_process: true } }, ctxFor());
expect(whole.content).toContain('<output_read>');
const { estimateToolResultTokens } = await import('../../../../src/main/util/tool-result-cap');
let args = { ...locator, max_tokens: 800 }, restored = '', done = false;
for (let page = 0; page < 20; page++) {
const output = await tool.execute(args, ctxFor());
expect(output.isError).toBeFalsy();
expect(estimateToolResultTokens(output.content)).toBeLessThanOrEqual(800);
expect(output.content).not.toContain('ref=');
const chunk = /covered="(\d+)-(\d+)" next_cursor="[^"]+">\n([\s\S]*?)\n<\/history-tool-output>/.exec(output.content)!;
expect(Number(chunk[1])).toBe(restored.length);
restored += chunk[3];
const next = decode(/<next_read>(.*?)<\/next_read>/.exec(output.content)![1]);
if (next === 'done') { done = true; break; }
args = JSON.parse(next);
expect(args).toMatchObject({ action: 'read_output', output: { record_id: 'report', tool_call_id: 'call' }, max_tokens: 800 });
}
expect(done).toBe(true);
expect(restored).toBe('FULL_RETAINED_OUTPUT ' + 'data '.repeat(3000));
});
it('searches and pages media-bearing history as text without returning image/video bytes', async () => {
@@ -1165,7 +1297,7 @@ describe('chat history discovery and evidence recovery', () => {
};
writeConversation('media-history', 'Media task', [original, { id: 'now', from: 'user', text: 'Continue' }]);
const [, , tool] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'media-history', currentMessageId: 'now', allowedScopes: ['current'] });
const search = await tool.execute({ action: 'search', scope: 'current', query: 'Cedar export' }, ctxFor());
const search = await tool.execute({action: 'search', scope: 'current', search: {query: 'Cedar export'}}, ctxFor());
const locator = JSON.parse(/^ read: (.+)$/m.exec(search.content)![1]);
const read = await tool.execute(locator, ctxFor());
expect(read.isError).toBeFalsy();
@@ -1191,7 +1323,7 @@ describe('chat history discovery and evidence recovery', () => {
it('recovers a process result using only locators returned for a natural query', async () => {
writeConversation('discovery', 'Export check', [answer(), { id: 'now', from: 'user', text: 'Write the status' }]);
const [, , tool] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'discovery', currentMessageId: 'now', allowedScopes: ['current'] });
const search = await tool.execute({ action: 'search', scope: 'current', query }, ctxFor());
const search = await tool.execute({action: 'search', scope: 'current', search: { query }}, ctxFor());
expect(search.content).toContain(facts);
expect(search.content).toContain('process_match:');
const locator = JSON.parse(/^ read: (.+)$/m.exec(search.content)![1]);
@@ -1205,14 +1337,14 @@ describe('chat history discovery and evidence recovery', () => {
writeConversation('other', 'Other export', [answer()], scope === 'project' ? 'p1' : 'p2');
writeConversation('current', 'Current task', [{ id: 'now', from: 'user', text: 'Status' }], 'p1');
const [, , tool] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'current', currentMessageId: 'now', projectId: 'p1' });
const result = await tool.execute({ action: 'search', scope, query }, ctxFor());
const result = await tool.execute({action: 'search', scope, search: { query }}, ctxFor());
const locator = JSON.parse(/^ read: (.+)$/m.exec(result.content)![1]);
expect(locator).toMatchObject({ cid: 'other', scope });
const read = await tool.execute(locator, ctxFor());
expect(read.isError).toBeFalsy();
expect(read.content).toContain(facts);
const processLocator = JSON.parse(/^ process_read: (.+)$/m.exec(result.content)![1]);
expect(processLocator).toMatchObject({ cid: 'other', scope, record_id: 'answer', process_index: 1 });
expect(processLocator).toMatchObject({ action: 'read_process', cid: 'other', scope, process: { record_id: 'answer', process_index: 1 } });
const segment = await tool.execute(processLocator, ctxFor());
expect(segment.isError).toBeFalsy();
expect(segment.content).toContain(facts);
@@ -1224,12 +1356,12 @@ describe('chat history discovery and evidence recovery', () => {
{ id: 'private', from: 'commander', text: 'No public execution', process: [{ type: 'event', event: { stream: 'reasoning', data: { text: 'PRIVATE_ONLY' } } }] },
{ id: 'now', from: 'user', text: 'Status' }]);
const [, , tool] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'discovery', currentMessageId: 'now', allowedScopes: ['current'] });
const text = await tool.execute({ action: 'read', scope: 'current', record_id: 'answer' }, ctxFor());
const text = await tool.execute({action: 'read', scope: 'current', read: {record_id: 'answer'}}, ctxFor());
expect(text.content).not.toContain(facts);
expect(text.content).toContain('include_process="false"');
const locator = JSON.parse(decode(/<process_read>(.*?)<\/process_read>/.exec(text.content)![1]));
expect((await tool.execute(locator, ctxFor())).content).toContain(facts);
const privateOnly = await tool.execute({ action: 'read', scope: 'current', record_id: 'private' }, ctxFor());
const privateOnly = await tool.execute({action: 'read', scope: 'current', read: {record_id: 'private'}}, ctxFor());
expect(privateOnly.content).not.toContain('<process_read>');
expect(privateOnly.content).not.toContain('PRIVATE_ONLY');
});
@@ -1242,14 +1374,14 @@ describe('chat history discovery and evidence recovery', () => {
await indexer.indexChatMessage(TEST_UID, 'partial', 1, trigger);
const [, , tool] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'partial', currentMessageId: 'now', allowedScopes: ['current'] }, false);
try {
const result = await tool.execute({ action: 'search', scope: 'current', query }, ctxFor());
const result = await tool.execute({action: 'search', scope: 'current', search: { query }}, ctxFor());
expect(result.content).toContain('index_complete=false');
expect(result.content).not.toContain('No conversation-history results');
const locator = JSON.parse(/^Read recent records: (.+)$/m.exec(result.content)![1]);
expect((await tool.execute(locator, ctxFor())).content).toContain(facts);
} finally { search.__searchTestHooks.cancelChatRepair(TEST_UID); }
await indexer.reconcileChatsIndex(TEST_UID);
const result = await tool.execute({ action: 'search', scope: 'current', query: 'absentuniqueterm' }, ctxFor());
const result = await tool.execute({action: 'search', scope: 'current', search: {query: 'absentuniqueterm'}}, ctxFor());
expect(result.content).toContain('index_complete=true');
expect(result.content).toContain('No conversation-history results');
});
@@ -1260,7 +1392,7 @@ describe('chat history discovery and evidence recovery', () => {
writeConversation('bounded', 'Long record', [record, { id: 'now', from: 'user', text: 'Status' }]);
const [, , tool] = await createChatHistoryActions({ userId: TEST_UID, currentCid: 'bounded', currentMessageId: 'now', allowedScopes: ['current'] });
const { estimateToolResultTokens } = await import('../../../../src/main/util/tool-result-cap');
let input: any = { action: 'read', scope: 'current', record_id: 'answer', max_tokens: 1000 };
let input: any = {action: 'read', scope: 'current', max_tokens: 1000, read: {record_id: 'answer'}};
let body = '';
for (let i = 0; i < 20; i++) {
const result = await tool.execute(input, ctxFor());
@@ -1270,7 +1402,7 @@ describe('chat history discovery and evidence recovery', () => {
body += decode(/<msg [^>]*>\n([\s\S]*?)\n<process_read>/.exec(result.content)![1]);
const next = decode(/<next_read>([\s\S]*?)<\/next_read>/.exec(result.content)![1]);
if (next === 'done') break;
input = { action: 'read', scope: 'current', ...JSON.parse(next), max_tokens: 1000 };
input = {action: 'read', scope: 'current', ...JSON.parse(next), max_tokens: 1000, read: {}};
}
expect(body).toBe(record.text);
});
@@ -0,0 +1,66 @@
import { spawnSync } from 'node:child_process';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createChatHistoryTool } from '../../../../src/main/model/core-agent/chat-history-tools';
import { createExecutionPlanTool } from '../../../../src/core-agent/src/tools/execution-plan';
import { createProjectTasksTool } from '../../../../src/core-agent/src/tools/project-tasks-tool';
import { processSessionTool, processStartTool } from '../../../../src/core-agent/src/tools/process-session';
vi.mock('../../../../src/main/logger', () => ({
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}));
afterEach(() => vi.restoreAllMocks());
describe('current-only model tool inputs', () => {
it('rejects a flat history read instead of silently selecting recent records', async () => {
const tool = createChatHistoryTool({ userId: 'retired-input-fixture' });
const result = await tool.execute({ action: 'read', record_id: 'old-record' }, { state: {} });
expect(result.isError).toBe(true);
expect(result.content).toContain('unsupported_fields');
});
it.each([{ query: 'Cedar export', k: 5 }, { search: { query: 'Cedar export' }, k: 5 }])('rejects the observed top-level history k shape: %j', async selectors => {
const tool = createChatHistoryTool({ userId: 'retired-input-fixture', currentCid: 'fixture', allowedScopes: ['current'] });
const result = await tool.execute({ action: 'search', scope: 'current', ...selectors }, { state: {} });
expect(result.isError).toBe(true);
expect(JSON.parse(result.content)).toMatchObject({ code: 'unsupported_fields', fields: expect.arrayContaining(['k']) });
});
it('does not clear a plan through an operation absent from the current snapshot contract', async () => {
const controller = { get: vi.fn(), update: vi.fn(), clear: vi.fn() };
const result = await createExecutionPlanTool(controller).execute({ action: 'clear' }, { state: {} });
expect(result.isError).toBe(true);
expect(controller.clear).not.toHaveBeenCalled();
expect(controller.update).not.toHaveBeenCalled();
});
it.each(['set_statuses', 'set_status'])('rejects the retired %s plan action and no built-in instruction still teaches it', async action => {
const controller = { get: vi.fn(), update: vi.fn(), clear: vi.fn() };
const result = await createExecutionPlanTool(controller).execute({ action, status: 'completed' }, { state: {} });
expect(result.isError).toBe(true);
expect(controller.update).not.toHaveBeenCalled();
// A Skill or prompt naming it would spend a model round on a rejected call.
const search = spawnSync('git', ['grep', '-lw', action, '--', 'resources', 'src/main/prompts'], { encoding: 'utf8', timeout: 30_000 });
expect(search.status, search.stderr).toBeLessThanOrEqual(1);
expect(search.stdout.split('\n').filter(Boolean)).toEqual([]);
});
it.each([false, true])('does not create or edit a todo by translating retired text fields (mixed=%s)', async mixed => {
const handler = { create: vi.fn(async () => ({ ok: true })), update: vi.fn(async () => ({ ok: true })) };
for (const action of ['create', 'update']) {
const result = await createProjectTasksTool(handler as any).execute({
action, task_id: 'existing', title: 'Old title', detail: 'Old detail', ...(mixed ? { content: 'Current text' } : {}),
}, { state: {} });
expect(result.isError).toBe(true);
}
expect(handler.create).not.toHaveBeenCalled();
expect(handler.update).not.toHaveBeenCalled();
});
it('does not launch a command through the retired process_session start action', async () => {
const start = vi.spyOn(processStartTool, 'execute').mockResolvedValue({ content: 'fixture command started' });
const result = await processSessionTool.execute({ action: 'start', command: 'fixture command' }, { state: {} });
expect(result.isError).toBe(true);
expect(start).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import {
classifyExternalMutationCommand,
classifyExternalMutationScript,
@@ -6,8 +8,28 @@ import {
} from '../../../../src/main/model/core-agent/external-mutation-risk';
describe('external-mutation-risk › wrapped script inspection', () => {
it.each(['orders.py', 'reconcile.py'])('retains in-memory provenance in the recorded local-analysis program %s', name => {
const source = fs.readFileSync(path.resolve('test/fixtures/sqlite-local-analysis', name), 'utf8');
const findings = classifyExternalMutationScript(source, { language: 'python' });
expect(findings.length).toBeGreaterThan(0);
expect(findings.every(f => f.resource?.database === ':memory:')).toBe(true);
});
it.each([
'sql += "; ATTACH DATABASE \'/outside.db\' AS foreign"',
'sql, unused = ("ATTACH DATABASE \'/outside.db\' AS foreign", 0)',
'for sql in ["ATTACH DATABASE \'/outside.db\' AS foreign"]:\n c.executescript(sql)',
'sorted(["ATTACH DATABASE \'/outside.db\' AS foreign"], key=lambda sql: c.execute(sql))',
])('does not trust an SQL constant after rebinding or shadowing: %s', change => {
const source = `import sqlite3\nc = sqlite3.connect(":memory:")\nsql = "SELECT 1"\n${change}\nc.executescript(sql)\nc.execute("DELETE FROM foreign.dogs")`;
const writes = classifyExternalMutationScript(source, { language: 'python' });
expect(writes.some(f => f.action === 'delete')).toBe(true);
expect(writes.filter(f => f.action === 'delete').every(f => !f.resource)).toBe(true);
});
it.each([
['memory', 'import sqlite3\nc = sqlite3.connect(":memory:")\nc.execute("CREATE TABLE dogs (id)")', ':memory:'],
['static SQL script', 'import sqlite3\nc = sqlite3.connect(":memory:")\nc.executescript("CREATE TABLE dogs (id); INSERT INTO dogs VALUES (1);")', ':memory:'],
['file and cursor alias', 'import sqlite3 as sql\nfrom pathlib import Path\np = Path("checks.db")\nc = sql.connect(p)\ncursor = c.cursor()\ncursor.execute("INSERT INTO dogs VALUES (1)")', 'checks.db'],
['semicolon-separated source', 'from sqlite3 import connect\nc = connect("checks.db"); c.execute("DELETE FROM dogs")', 'checks.db'],
['literal SQL parameters', 'import sqlite3\nc = sqlite3.connect("checks.db"); c.execute("INSERT INTO dogs VALUES (?, ?)", ("Cleo", 4))', 'checks.db'],
@@ -34,6 +56,84 @@ describe('external-mutation-risk › wrapped script inspection', () => {
expect(findings.some(f => f.action === 'delete')).toBe(true);
});
// Python joins these forms before SQLite sees them, so each one really attaches
// the outside file (checked against CPython). The first token alone proves nothing.
it.each([
['adjacent literals', `conn.execute("ATT" "ACH DATABASE '/outside.db' AS y")`],
['adjacent literals on separate lines', `conn.execute(\n "ATT"\n "ACH DATABASE '/outside.db' AS y"\n)`],
['adjacent literals after a line continuation', `conn.execute("ATT" \\\n "ACH DATABASE '/outside.db' AS y")`],
['adjacent literals before parameters', `conn.execute("ATT" "ACH DATABASE ? AS y", ("/outside.db",))`],
['literal concatenation', `conn.execute("ATT" + "ACH DATABASE '/outside.db' AS y")`],
['literal formatting', `conn.execute("ATT%s" % "ACH DATABASE '/outside.db' AS y")`],
['concatenation onto a SQL variable', `head = "ATT"\nconn.execute(head + "ACH DATABASE '/outside.db' AS y")`],
])('keeps approval for a write after SQL hidden past its first token: %s', (_label, attach) => {
const source = `import sqlite3\nconn = sqlite3.connect(":memory:")\n${attach}\nconn.execute("INSERT INTO y.t VALUES (1)")`;
expect(classifyExternalMutationScript(source, { language: 'python' })).toEqual([
{ kind: 'database_write', action: 'insert', target: 'y.t' },
]);
});
it.each([
['parameters on the next line', `conn.execute(\n "INSERT INTO t VALUES (?)",\n (1,)\n)`],
['a trailing comma', `conn.execute("INSERT INTO t VALUES (1)",)`],
['a closing parenthesis on the next line', `conn.execute("INSERT INTO t VALUES (1)"\n)`],
['a literal parameter list', `conn.executemany("INSERT INTO t VALUES (?)", [(1,), (2,)])`],
['a SQL variable with literal parameters', `sql = "INSERT INTO t VALUES (?)"\nconn.execute(sql, (1,))`],
])('keeps the local proof for a whole SQL argument followed by %s', (_label, insert) => {
const source = `import sqlite3\nconn = sqlite3.connect(":memory:")\nconn.execute("CREATE TABLE t (a)")\n${insert}\nconn.execute("DELETE FROM t")`;
const findings = classifyExternalMutationScript(source, { language: 'python' });
expect(findings.map(f => f.action).sort()).toEqual(['create', 'delete', 'insert']);
expect(findings.every(f => f.resource?.database === ':memory:')).toBe(true);
});
it.each([
'c = sqlite3.connect("/outside.db")',
'for c in [remote]:\n c.execute("DELETE FROM dogs")',
'sink = c.execute',
'c.execute("INSERT INTO dogs VALUES (?)", (mutate_connections(),))',
'text = f"{c.execute(\'ATTACH DATABASE ? AS foreign\', (\'/outside.db\',))}"',
'text = f"{getattr(c, \'execute\')(\'ATTACH DATABASE ? AS foreign\', (\'/outside.db\',))}"',
'c.enable_load_extension(True)',
'c.execute(f"{sql_verb} INTO dogs VALUES (1)")',
'c.executescript(f"CREATE TABLE {table} (id)")',
'import user_helpers\nuser_helpers.prepare()',
'import constructor',
'from toString import connect',
'text = f"{"literal"}"',
])('does not widen data-loop proof around an unsafe or unresolved operation: %s', change => {
const source = `import sqlite3\nc = sqlite3.connect(":memory:")\nfor value in [1]:\n pass\n${change}\nc.execute("DELETE FROM dogs")`;
const writes = classifyExternalMutationScript(source, { language: 'python' });
expect(writes.some(f => f.action === 'delete')).toBe(true);
expect(writes.filter(f => f.action === 'delete').every(f => !f.resource)).toBe(true);
});
it('retains data-module provenance when identical SQL findings are deduplicated', () => {
const source = 'import sqlite3, json\nc=sqlite3.connect(":memory:")\nc.execute("DELETE FROM dogs")\nrows=json.loads("[1]")\nfor row in rows:\n c.execute("DELETE FROM dogs")';
const findings = classifyExternalMutationScript(source, { language: 'python' });
expect(findings).toHaveLength(1);
expect(findings[0].resource?.imports).toContain('json');
});
it('retains in-memory identity with aliased imports, cursor bindings and loop parameters', () => {
const source = [
'from sqlite3 import connect as local', 'from json import loads as decode',
'rows = decode("[1, 2]")', 'db = local(":memory:")', 'cursor = db.cursor()',
'cursor.execute("CREATE TABLE dogs (id)")', 'sql = "INSERT INTO dogs VALUES (?)"',
'for value in rows:', ' cursor.execute(sql, (value,))',
].join('\n');
const findings = classifyExternalMutationScript(source, { language: 'python' });
expect(findings).toHaveLength(2);
expect(findings.every(f => f.resource?.database === ':memory:')).toBe(true);
});
it('retains approval when repeated SQL exhausts the bounded data-program proof', () => {
const source = 'import sqlite3\nc=sqlite3.connect(":memory:")\nsql="DELETE FROM dogs ' + ' '.repeat(1000)
+ '"\nfor value in [1]:\n pass\n' + 'c.execute(sql)\n'.repeat(300);
expect(classifyExternalMutationScript(source, { language: 'python' })).toEqual([
{ kind: 'database_write', action: 'delete', target: 'dogs' },
]);
});
it('does not apply Python provenance to another language or a custom connection factory', () => {
const source = 'import sqlite3\nc = sqlite3.connect(":memory:")\nc.execute("DELETE FROM dogs")';
expect(classifyExternalMutationScript(source)[0].resource).toBeUndefined();
@@ -47,6 +147,16 @@ describe('external-mutation-risk › wrapped script inspection', () => {
expect(findings.filter(f => !f.resource)).toEqual([{ kind: 'database_write', action: 'delete', target: 'dogs' }]);
});
it.each([
'remote.executescript("CREATE TABLE dogs (id); INSERT INTO dogs VALUES (1);")',
'sql="DELETE FROM dogs"\nremote.executescript(sql)',
])('retains external-write detection for an unresolved script receiver: %s', source => {
const findings = classifyExternalMutationScript(source, { language: 'python' });
expect(findings).toHaveLength(1);
expect(findings[0].kind).toBe('database_write');
expect(findings[0].resource).toBeUndefined();
});
it('retains approval when source exceeds the bounded provenance analysis', () => {
const source = '# padding\n'.repeat(7000) + 'import sqlite3\nc = sqlite3.connect(":memory:")\nc.execute("DELETE FROM dogs")';
expect(classifyExternalMutationScript(source, { language: 'python' })).toEqual([
@@ -86,6 +86,59 @@ it.skipIf(process.platform === 'win32')('preserves a dedicated read across unrel
expect(fs.readFileSync(target, 'utf8')).toBe('value = 9\n');
});
it('does not let a decoded read authorize an edit that would rewrite the file as UTF-8', async () => {
const { workingDir, ctx, call } = await setup();
const file = path.join(workingDir, 'orders.csv');
const gbk = Buffer.from([0xD6, 0xD0, 0xCE, 0xC4, 0x2C, 0x41, 0x42, 0x0A]);
fs.writeFileSync(file, gbk);
const decoded = await call('read_files', { paths: [{ path: 'orders.csv', encoding: 'gb18030' }] });
expect(decoded.content).toContain('中文,AB');
expect(ctx.state.readFileState.has(file)).toBe(false);
const rejected = await call('edit_file', { path: 'orders.csv', old_string: 'AB', new_string: 'CD' });
expect(rejected.content).toContain('E_NOT_READ');
expect(fs.readFileSync(file)).toEqual(gbk);
});
it('keeps edit evidence when a valid UTF-8 file is read with an explicit utf-8 label', async () => {
const { target, call } = await setup();
fs.writeFileSync(target, 'value = 1\n');
const read = await call('read_files', { paths: [{ path: 'target.py', encoding: 'utf8' }] });
expect(read.content).toContain('file_hash="sha256:');
expect(read.content).not.toContain('encoding=');
expect((await call('edit_file', { path: 'target.py', old_string: 'value = 1', new_string: 'value = 2' })).isError).toBeFalsy();
expect(fs.readFileSync(target, 'utf8')).toBe('value = 2\n');
});
it('hands the refreshed source byte size from a raced read to one replay-safe append', async () => {
const { target, call } = await setup();
fs.writeFileSync(target, 'value = 1\n');
const current = 'value = 100\nother = 200\n';
const originalRead = fs.promises.readFile;
let changed = false;
const spy = vi.spyOn(fs.promises, 'readFile').mockImplementation(async (file, options) => {
const body = await originalRead(file, options);
if (String(file) === target && !changed) {
changed = true;
fs.writeFileSync(target, current);
const at = new Date(Date.now() + 10_000);
fs.utimesSync(target, at, at);
}
return body;
});
let read;
try { read = await call('read_files', { paths: [{ path: 'target.py' }] }); }
finally { spy.mockRestore(); }
expect(changed).toBe(true);
expect(read.isError).toBeFalsy();
const revision = /revision="(file_rev_[A-Za-z0-9_-]{16})"/.exec(read.content)?.[1];
expect(revision).toBeTruthy();
const input = { path: 'target.py', content: 'last = 300\n', base_revision: revision };
expect((await call('append_file', input)).isError).toBeFalsy();
expect(fs.readFileSync(target, 'utf8')).toBe(current + input.content);
expect((await call('append_file', input)).isError).toBeFalsy();
expect(fs.readFileSync(target, 'utf8')).toBe(current + input.content);
});
it.each(['write_file', 'apply_patch'])('continues editing after a dedicated %s creates the file without another read', async creator => {
const { target, call, patch } = await setup();
const input = creator === 'write_file'
@@ -0,0 +1,254 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { profileSyncCosts } from '../../../helpers/sync-cost.cjs';
import { syncBuiltinESMExports } from 'node:module';
import { spawnSync } from 'node:child_process';
import { drainMainRuntimeForTest } from '../../../helpers/drain-main-runtime';
import { extractOffice, closeOfficeExtraction } from '../../../../src/main/util/office-extract';
import { makeMinimalXlsx } from '../../../fixtures/make-minimal-office';
import { grepRepository } from '../../../../src/main/model/core-agent/repository-search';
const ATTRIBUTION = process.env.ORKAS_PERFORMANCE_ATTRIBUTION === '1';
const RG_AVAILABLE = spawnSync('rg', ['--version']).status === 0;
// Actual host tools/files/worker/rg; logger is captured, not a production sink benchmark.
const warnings = vi.hoisted(() => [] as string[]);
const logs = vi.hoisted(() => ({ debug: 0, info: 0, warn: 0, error: 0 }));
vi.mock('../../../../src/main/logger', () => ({ createLogger: () => Object.fromEntries(
Object.keys(logs).map(level => [level, (message: string) => { logs[level as keyof typeof logs]++; if (level === 'warn') warnings.push(message); }]),
) }));
let root: string, previous: string | undefined, dir: string;
const uid = 'synthetic-performance';
beforeEach(async () => {
previous = process.env.ORKAS_WORKSPACE_ROOT;
root = fs.mkdtempSync(path.join(os.tmpdir(), 'orkas-file-performance-'));
process.env.ORKAS_WORKSPACE_ROOT = root;
vi.resetModules(); warnings.length = 0;
for (const level of Object.keys(logs)) logs[level as keyof typeof logs] = 0;
(await import('../../../../src/main/features/users')).activateUser(uid);
dir = path.join(root, 'workspace'); fs.mkdirSync(dir);
expect((await import('../../../../src/main/features/user_workspace')).setWorkspacePath(uid, dir).ok).toBe(true);
(await import('../../../../src/main/features/permissions')).setLocalExecMode('all_files_auto');
});
afterEach(async () => {
vi.restoreAllMocks(); syncBuiltinESMExports();
await closeOfficeExtraction(); await drainMainRuntimeForTest();
if (previous === undefined) delete process.env.ORKAS_WORKSPACE_ROOT;
else process.env.ORKAS_WORKSPACE_ROOT = previous;
fs.rmSync(root, { recursive: true, force: true });
});
async function tools() {
const local = (await import('../../../../src/main/model/core-agent/local-tools')).createLocalTools({ userId: uid, cid: 'fixture' });
const ctx = { workingDir: dir, state: { readFileState: new Map() } } as any;
return (name: string, input: Record<string, unknown>) => local.find(t => t.name === name)!.execute(input, ctx);
}
function revision(content: string): string {
const value = /revision="([^"]+)"/.exec(content)?.[1];
expect(value, 'Saved results must expose a usable revision').toBeTruthy(); return value!;
}
function complete(actual: Buffer, expected: Buffer) {
if (!actual.equals(expected)) throw new Error('Incomplete or duplicated file bytes');
}
async function measure<T>(work: () => Promise<T>) {
const cpu = process.cpuUsage(), memoryBefore = process.memoryUsage();
let last = performance.now(), maxGapMs = 0, ticks = 0;
const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; ticks++; }, 2);
try {
await new Promise(resolve => setTimeout(resolve, 5));
last = performance.now(); ticks = 0; maxGapMs = 0;
const start = performance.now(); const value = await work(); const elapsedMs = performance.now() - start;
await new Promise(resolve => setTimeout(resolve, 5));
const used = process.cpuUsage(cpu), memoryAfter = process.memoryUsage();
return { value, metrics: { elapsedMs, maxGapMs, ticks, cpuUserMs: used.user / 1000, cpuSystemMs: used.system / 1000,
rssBefore: memoryBefore.rss, rssAfter: memoryAfter.rss, externalBefore: memoryBefore.external, externalAfter: memoryAfter.external } };
} finally { clearInterval(timer); }
}
it('PF-03 measures fixed final bytes across chunk counts without accepting truncation or replay', async () => {
const call = await tools(), measurements = [];
for (const finalBytes of [256 * 1024, 1024 * 1024, 8 * 1024 * 1024]) {
// Same independently constructed Unicode payload at every chunk count.
const expected = Buffer.from('界x'.repeat(finalBytes / 4));
for (const chunks of [4, 16, 32]) {
const target = path.join(dir, `source-${finalBytes}-${chunks}.txt`);
const chunk = expected.subarray(0, finalBytes / chunks).toString('utf8');
const first = await call('write_file', { path: target, content: chunk });
expect(first.isError).not.toBe(true); let base = revision(first.content);
const costs = ATTRIBUTION ? profileSyncCosts(file => String(file) === target) : null;
const original = fs.readFileSync;
let sourceReads = 0, sourceReadBytes = 0, maxAppendMs = 0;
const spy = vi.spyOn(fs, 'readFileSync').mockImplementation(((file: fs.PathOrFileDescriptor, ...args: unknown[]) => {
const result = (original as Function)(file, ...args);
if (String(file) === target) { sourceReads++; sourceReadBytes += Buffer.byteLength(result); }
return result;
}) as typeof fs.readFileSync);
syncBuiltinESMExports();
let measured;
try {
measured = await measure(async () => {
for (let n = 1; n < chunks; n++) {
const operationStart = performance.now();
const result = await call('append_file', { path: target, content: chunk, base_revision: base });
maxAppendMs = Math.max(maxAppendMs, performance.now() - operationStart);
expect(result.isError).not.toBe(true); base = revision(result.content);
}
});
} finally { spy.mockRestore(); syncBuiltinESMExports(); costs?.stop(); }
complete(fs.readFileSync(target), expected);
// Tap validity only, NOT a requirement to preserve the inefficient number of reads.
expect(sourceReads).toBeGreaterThan(0); expect(sourceReadBytes).toBeGreaterThan(0);
measurements.push({ finalBytes, chunks, sourceReads, sourceReadBytes, maxAppendMs, ...(costs ? { callerCosts: costs.report } : {}), ...measured.metrics });
}
}
expect(logs.warn).toBe(0); expect(logs.error).toBe(0);
console.log('[PF-03 fixed-payload append]', JSON.stringify({ measurements, capturedLogs: logs,
scope: 'one sample per workload; API reads, warm OS cache; endpoint memory, not peak; no latency gate' }));
}, 30000);
it('PF-03 preserves replay and stale-write protection after a long append/edit sequence', async () => {
const call = await tools(), target = path.join(dir, 'editable.txt');
const source = 'HEAD_0000\n' + '界x'.repeat(256 * 1024) + '\nTAIL';
const first = await call('write_file', { path: target, content: source });
const append = { path: target, content: '\nNEW', base_revision: revision(first.content) };
expect((await call('append_file', append)).isError).not.toBe(true);
expect((await call('append_file', append)).isError).not.toBe(true);
complete(fs.readFileSync(target), Buffer.from(source + '\nNEW'));
const costs = ATTRIBUTION ? profileSyncCosts(file => String(file) === target) : null;
let maxEditMs = 0;
let measured;
try { measured = await measure(async () => {
for (let n = 0; n < 100; n++) {
const operationStart = performance.now();
const result = await call('edit_file', { path: target, old_string: `HEAD_${String(n).padStart(4, '0')}`,
new_string: `HEAD_${String(n + 1).padStart(4, '0')}` });
maxEditMs = Math.max(maxEditMs, performance.now() - operationStart);
expect(result.isError, `edit ${n}`).not.toBe(true);
}
}); } finally { costs?.stop(); }
const expected = Buffer.from(source.replace('HEAD_0000', 'HEAD_0100') + '\nNEW');
complete(fs.readFileSync(target), expected);
const external = Buffer.concat([expected, Buffer.from('\nEXTERNAL')]); fs.writeFileSync(target, external);
const stale = await call('edit_file', { path: target, old_string: 'HEAD_0100', new_string: 'WRONG' });
expect(stale.isError).toBe(true); expect(stale.content).toContain('E_STALE');
complete(fs.readFileSync(target), external);
expect(warnings).toEqual(['edit_file freshness reject']); expect(logs.error).toBe(0);
console.log('[PF-03 repeated edit]', JSON.stringify({ edits: 100, maxEditMs, ...(costs ? { callerCosts: costs.report } : {}), finalBytes: expected.length, ...measured.metrics, capturedLogs: logs, expectedWarnings: warnings }));
}, 30000);
it.skipIf(!RG_AVAILABLE)('PF-19 completes repository search and Office extraction during eight code-writing tasks', async () => {
const call = await tools();
const rows = Array.from({ length: 40000 }, (_, n) => [`row-${n}`, `value-${n}`]);
const workbook = makeMinimalXlsx({ rows }), originalWorkbook = Buffer.from(workbook);
const repository = path.join(dir, 'repository'); fs.mkdirSync(repository);
const expectedHits = Array.from({ length: 100 }, (_, n) => `file-${n}.ts`).sort();
for (const name of expectedHits) fs.writeFileSync(path.join(repository, name), 'first\nPF_NEEDLE\nlast\n');
const chunk = '界x'.repeat(16384), expected = Buffer.from(chunk.repeat(16));
const inputs = await Promise.all(Array.from({ length: 8 }, async (_, task) => {
const target = path.join(dir, `task-${task}.txt`);
const first = await call('write_file', { path: target, content: chunk });
expect(first.isError).not.toBe(true); return { target, base: revision(first.content) };
}));
const measured = await measure(async () => Promise.all([
extractOffice('xlsx-markdown', workbook),
grepRepository(repository, { pattern: 'PF_NEEDLE', regex: false, caseSensitive: true, contextLines: 0,
maxResults: 200, includeGlobs: [], excludeGlobs: [] }),
Promise.all(inputs.map(async input => {
for (let n = 1; n < 16; n++) {
const result = await call('append_file', { path: input.target, content: chunk, base_revision: input.base });
expect(result.isError).not.toBe(true); input.base = revision(result.content);
}
})),
]));
const [text, search] = measured.value;
const extracted = text.split('\n').filter(line => /^Row \d+:/.test(line));
expect(extracted).toEqual(rows.map((row, n) => `Row ${n + 1}: ${row.join('\t')}`));
expect(workbook).toEqual(originalWorkbook);
expect(search.available).toBe(true); expect(search.error).toBeUndefined(); expect(search.capped).toBe(false);
expect(search.hits.map(hit => path.basename(hit.path)).sort()).toEqual(expectedHits);
expect(search.hits.every(hit => hit.line === 2 && hit.text === 'PF_NEEDLE')).toBe(true);
for (const { target } of inputs) complete(fs.readFileSync(target), expected);
expect(logs.warn).toBe(0); expect(logs.error).toBe(0);
console.log('[PF-19 code-search-office]', JSON.stringify({ tasks: 8, appends: 120, fileBytes: expected.length,
officeRows: rows.length, searchHits: expectedHits.length, ...measured.metrics, capturedLogs: logs,
scope: 'actual local dependencies, host event loop; not renderer input/physical disk/production model' }));
}, 30000);
// PF-06: defaults exercise 10K files; the explicitly selected large tier also runs 100K.
for (const files of [10000, 100000]) {
it.skipIf(!RG_AVAILABLE || (files === 100000 && process.env.ORKAS_PERFORMANCE_LARGE !== '1'))(`PF-06 searches ${files} files and empty directories with native and fallback traversal`, async () => {
const repository = path.join(dir, 'repository'); fs.mkdirSync(repository);
fs.mkdirSync(path.join(repository, '.git')); // scale fixture is a Git repository
const expected: string[] = [];
for (let bucket = 0; bucket < 100; bucket++) {
const folder = path.join(repository, `bucket-${bucket}`); fs.mkdirSync(folder);
fs.mkdirSync(path.join(repository, `empty-${bucket}`));
for (let n = 0; n < files / 100; n++) {
const name = n === 0 && bucket < 17 ? `match-${bucket}.ts` : `other-${n}.ts`;
fs.writeFileSync(path.join(folder, name), 'value\n');
if (name.startsWith('match-')) expected.push(path.join(folder, name));
}
}
fs.mkdirSync(path.join(repository, 'ignored'));
fs.writeFileSync(path.join(repository, 'ignored', 'match-hidden.ts'), 'excluded\n');
fs.writeFileSync(path.join(repository, '.gitignore'), 'ignored/\n');
const { createFileTools } = await import('../../../../src/main/model/core-agent/file-tools');
const search = createFileTools({ userId: uid, cid: 'fixture' }).find(tool => tool.name === 'search_files')!;
const priorPath = process.env.PATH, measurements = [];
try {
for (const backend of ['native', 'fallback']) {
if (backend === 'fallback') process.env.PATH = root; // no rg, actual traversal remains real
const costs = ATTRIBUTION ? profileSyncCosts(file => typeof file === 'string' && file.startsWith(repository)) : null;
let measured;
try { measured = await measure(() => search.execute({ root: repository, query: 'match-' }, { workingDir: dir, state: {} })); }
finally { costs?.stop(); }
const result = measured.value;
expect(result.isError).not.toBe(true); expect(result.content).toContain('complete=true');
expect(result.content).toContain(`backend=${backend === 'native' ? 'rg' : 'walk'}`);
const listing = JSON.parse(result.content.slice(result.content.indexOf('{')));
expect(listing.files.map((file: { root: number; path: string }) => path.join(listing.roots[file.root], file.path)).sort()).toEqual([...expected].sort());
measurements.push({ backend, ...(costs ? { callerCosts: costs.report } : {}), ...measured.metrics });
}
} finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; }
expect(logs.warn).toBe(0); expect(logs.error).toBe(0);
console.log('[PF-06 repository scale]', JSON.stringify({ files, emptyDirectories: 100, matches: expected.length, measurements,
capturedLogs: logs, scope: 'real directory traversal and rg; file creation excluded; no cold physical disk/p95 claim' }));
}, 60000);
}
it('PF-19 rejects a truncated/replayed result and detects a deliberately blocking caller', async () => {
const expected = Buffer.from('complete-界-end');
expect(() => complete(expected.subarray(0, -1), expected)).toThrow('Incomplete');
expect(() => complete(Buffer.concat([expected, expected]), expected)).toThrow('duplicated');
const measured = await measure(async () => {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 40);
});
// Known stall detection, not a new production latency threshold.
expect(measured.metrics.maxGapMs).toBeGreaterThanOrEqual(40);
console.log('[PF-19 pulse negative control]', JSON.stringify(measured.metrics));
});
// Validate the diagnostic tap itself, including nested append/write and failure cleanup.
it('PF-19 attributes scoped synchronous API bytes without double counting and restores after failure', () => {
const target = path.join(dir, 'attribution.txt'), originalRead = fs.readFileSync;
const costs = profileSyncCosts(file => file === target);
try {
fs.writeFileSync(target, '界');
fs.appendFileSync(target, '🙂');
expect(fs.readFileSync(target)).toEqual(Buffer.from('界🙂'));
const { createHash } = require('node:crypto');
expect(createHash('sha256').update(Buffer.from('界🙂')).digest('hex')).toBe(
'a05ac2fb5a021ff671db73a10f15e163a0c88541e03c1750ee83974097e31085');
expect(() => fs.readFileSync(path.join(target, 'missing'))).toThrow(); // outside scope
fs.unlinkSync(target);
expect(() => fs.readFileSync(target)).toThrow();
} finally { costs.stop(); }
expect(fs.readFileSync).toBe(originalRead);
expect(costs.report.fs.writeFileSync).toMatchObject({ calls: 1, bytes: 3, failures: 0 });
expect(costs.report.fs.appendFileSync).toMatchObject({ calls: 1, bytes: 4, failures: 0 });
expect(costs.report.fs.readFileSync).toMatchObject({ calls: 2, bytes: 7, failures: 1 });
expect(costs.report.hashBytes).toBe(7);
expect(costs.report.hashCalls).toBe(1);
costs.stop(); // idempotent; never restore an obsolete wrapper twice
});
+409 -7
View File
@@ -453,12 +453,49 @@ describe('file-tools › read_file (text)', () => {
expect(changedRevision).not.toBe(firstRevision);
});
it.each([false, true])('delivers a complete current CSV after a save during first read (metadata: %s)', async (metadataOnly) => {
const { tools, wsDir } = await buildTools();
const p = path.join(wsDir, 'current.csv');
fs.writeFileSync(p, 'id,value\nA,1\n');
const current = 'id,value\nA,100\nB,200\nC,300\n';
const originalRead = fs.promises.readFile;
let changed = false;
const spy = vi.spyOn(fs.promises, 'readFile').mockImplementation(async (file, options) => {
const body = await originalRead(file, options);
if (String(file) === p && !changed) {
changed = true;
fs.writeFileSync(p, current);
const at = new Date(Date.now() + 10_000);
fs.utimesSync(p, at, at);
}
return body;
});
try {
const result = await run(getTool(tools, 'read_files'), { paths: [{ path: p }], metadata_only: metadataOnly });
expect(changed).toBe(true);
expect(result.isError).toBeFalsy();
expect(result.content).toContain(`total_chars="${current.length}"`);
if (metadataOnly) {
expect(result.content).toContain(`bytes="${Buffer.byteLength(current)}"`);
expect(fs.readFileSync(p, 'utf8')).toBe(current);
return;
}
expect(result.content).toContain(`covered="0-${current.length}"`);
expect(result.content).toContain('request_complete="true"');
expect(result.content).toContain('4\tC,300');
const hash = `sha256:${(await import('node:crypto')).createHash('sha256').update(current).digest('hex')}`;
expect(result.content).toContain(`file_hash="${hash}"`);
expect(result.observations?.fileReads?.[0]?.hash).toBe(hash);
expect(fs.readFileSync(p, 'utf8')).toBe(current);
} finally { spy.mockRestore(); }
});
it('numbers lines from the absolute line of a mid-file char slice', async () => {
const { tools, wsDir } = await buildTools();
const p = path.join(wsDir, 'code.txt');
fs.writeFileSync(p, 'L1\nL2\nL3\nL4\nL5');
// char 6 is the start of "L3" ("L1\n"=0-2, "L2\n"=3-5).
const r = await run(getTool(tools, 'read_file'), { path: p, charStart: 6 });
const r = await run(getTool(tools, 'read_file'), {path: p, range: { unit: 'char', start: 6, end: (6) + 24000 } });
expect(r.isError).toBeFalsy();
expect(r.content).toContain('lines="3-5"');
expect(r.content).toContain('3\tL3\n4\tL4\n5\tL5');
@@ -466,11 +503,11 @@ describe('file-tools › read_file (text)', () => {
expect(r.content).not.toContain('1\tL3');
});
it('slices by charStart/charEnd', async () => {
it('slices by a tagged character range', async () => {
const { tools, wsDir } = await buildTools();
const p = path.join(wsDir, 'note.md');
fs.writeFileSync(p, 'abcdefghij');
const r = await run(getTool(tools, 'read_file'), { path: p, charStart: 2, charEnd: 7 });
const r = await run(getTool(tools, 'read_file'), {path: p, range: { unit: 'char', start: 2, end: 7 } });
expect(r.isError).toBeFalsy();
expect(r.content).toContain('covered="2-7"');
expect(r.content).toContain('cdefg');
@@ -480,7 +517,7 @@ describe('file-tools › read_file (text)', () => {
const { tools, wsDir } = await buildTools();
const p = path.join(wsDir, 'range.ts');
fs.writeFileSync(p, 'one\ntwo\nthree\nfour\nfive\n');
const r = await run(getTool(tools, 'read_file'), { path: p, lineStart: 2, lineEnd: 4 });
const r = await run(getTool(tools, 'read_file'), {path: p, range: { unit: 'line', start: 2, end: 4 } });
expect(r.isError).toBeFalsy();
expect(r.content).toContain('lines="2-4"');
expect(r.content).toContain('2\ttwo\n3\tthree\n4\tfour');
@@ -557,6 +594,16 @@ describe('file-tools › read_file (text)', () => {
expect(mixed.content).toContain('E_BAD_INPUT');
});
it.each([{ charStart: 0 }, { charEnd: 3 }, { lineStart: 1 }, { lineEnd: 2 }])('rejects retired-only range selectors without reading the default file: %j', async selector => {
const { tools, wsDir } = await buildTools();
const p = path.join(wsDir, 'retired-range.txt');
fs.writeFileSync(p, 'UNREQUESTED_DEFAULT_BODY');
const result = await run(getTool(tools, 'read_files'), { paths: [{ path: p, ...selector }] });
expect(result.isError).toBe(true);
expect(result.content).toContain('E_BAD_INPUT');
expect(result.content).not.toContain('UNREQUESTED_DEFAULT_BODY');
});
it('rejects mixed line and character addressing', async () => {
const { tools, wsDir } = await buildTools();
const p = path.join(wsDir, 'range.txt');
@@ -570,7 +617,7 @@ describe('file-tools › read_file (text)', () => {
const { tools, wsDir } = await buildTools();
const p = path.join(wsDir, 'tiny.txt');
fs.writeFileSync(p, 'xy');
const r = await run(getTool(tools, 'read_file'), { path: p, charEnd: 999 });
const r = await run(getTool(tools, 'read_file'), {path: p, range: { unit: 'char', start: 0, end: 999 } });
expect(r.isError).toBeFalsy();
expect(r.content).toContain('covered="0-2"');
});
@@ -711,7 +758,7 @@ describe('file-tools › read_files (rich documents prepare on first read)', ()
const p = path.join(wsDir, 'notes.docx');
fs.writeFileSync(p, makeMinimalDocx({ heading: 'HEAD', paragraphs: ['Body.'] }));
await run(getTool(tools, 'stat_file'), { path: p });
const r = await run(getTool(tools, 'read_file'), { path: p, charStart: 0, charEnd: 4 });
const r = await run(getTool(tools, 'read_file'), {path: p, range: { unit: 'char', start: 0, end: 4 } });
expect(r.isError).toBeFalsy();
expect(r.content).toContain('covered="0-4"');
});
@@ -1634,7 +1681,7 @@ describe('file-tools › read_files', () => {
expect(schema.properties.metadata_only.type).toBe('boolean');
expect(schema.properties.raw_text).toMatchObject({
type: 'boolean',
description: expect.stringContaining('complete UTF-8 text'),
description: 'Return complete text as JSON {files:[{requested_path,ok,path,content,total_chars,...}|{requested_path,ok:false,error}]}. UTF-8 results include file_hash; decoded results include encoding instead. Cannot combine with ranges/metadata_only. Large direct results may be persisted.',
});
expect(readFiles.description).toMatch(/ranges.*structure or samples/);
expect(readFiles.description).toMatch(/raw_text when complete original text is needed/);
@@ -2447,7 +2494,325 @@ describe('file-tools › read_files', () => {
});
});
describe('file-tools › read_files encoding', () => {
// These strings were confirmed word for word with the requester. A change is a
// model-facing change, so these are exact on purpose.
const READ_NOTE = '<text-encoding-recovery>\nThis file is not valid UTF-8, so the text below may be garbled. Do not infer meaning from garbled text. Read it again with `encoding` set to a likely encoding such as gb18030, big5, shift_jis or utf-16le, and check that the result reads correctly. If none works and most of the text below reads correctly, work from it and tell the user which parts are unreadable. Otherwise tell the user the file could not be decoded.\n</text-encoding-recovery>';
const RAW_NOTE = '<text-encoding-recovery>\nThis file is not valid UTF-8. Read it again with `encoding` set to a likely encoding such as gb18030, big5, shift_jis or utf-16le, and check that the result reads correctly. If none works, read it without raw_text to see whether most of it is readable; if not, tell the user the file could not be decoded.\n</text-encoding-recovery>';
// Written out by hand so the oracle does not depend on the decoder: 中文,AB\n
const GBK = Buffer.from([0xD6, 0xD0, 0xCE, 0xC4, 0x2C, 0x41, 0x42, 0x0A]);
const GBK_TEXT = '中文,AB\n';
const INVALID_GB18030 = Buffer.from([0xFF, 0xFF]);
const plain = (content: string) => /<file [^\n]*>\n([\s\S]*?)\n<\/file>/.exec(content)![1].replace(/^\d+\t/gm, '');
const blocks = (content: string) => [...content.matchAll(/<read-result index="\d+" ok="(?:true|false)">[\s\S]*?<\/read-result>/g)].map(match => match[0]);
const mismatch = (file: string, encoding: string) =>
`E_TEXT_ENCODING: ${file}: the bytes are not valid ${encoding}. Try another likely encoding, or read it without encoding to see whether most of it is readable.`;
it('advertises encoding on each path item with the confirmed wording', async () => {
const { tools } = await buildTools();
const { toToolDefinition } = await import('../../../../src/core-agent/src/tools');
const schema = toToolDefinition(getTool(tools, 'read_files')).inputSchema as any;
expect(schema.properties.paths.items.properties.encoding).toEqual({
type: 'string',
minLength: 1,
description: 'Decode a non-UTF-8 text file with this encoding label, such as gb18030, big5, shift_jis or utf-16le. Fails if the bytes are invalid for it.',
});
expect(schema.properties.paths.items.required).toEqual(['path']);
expect(schema.properties).not.toHaveProperty('encoding');
});
it('warns once before the page, and only for the file that is not UTF-8', async () => {
const { tools, wsDir } = await buildTools();
const gbk = path.join(wsDir, 'orders.csv');
const utf8 = path.join(wsDir, 'notes.txt');
fs.writeFileSync(gbk, GBK);
fs.writeFileSync(utf8, '中文 notes\n');
const result = await run(getTool(tools, 'read_files'), { paths: [{ path: gbk }, { path: utf8 }] });
expect(result.isError).toBeFalsy();
expect(result.content).toContain('errors="0"');
const [first, second] = blocks(result.content);
expect(first.split(READ_NOTE)).toHaveLength(2);
expect(first.indexOf(READ_NOTE)).toBeLessThan(first.indexOf('<file '));
expect(second).not.toContain('text-encoding-recovery');
expect(plain(second)).toBe('中文 notes\n');
});
it.each([
['a character range', { unit: 'char', start: 0, end: 3 }],
['a line range', { unit: 'line', start: 1, end: 1 }],
])('warns on %s too', async (_label, range) => {
const { tools, wsDir } = await buildTools();
const gbk = path.join(wsDir, 'orders.csv');
fs.writeFileSync(gbk, GBK);
const result = await run(getTool(tools, 'read_files'), { paths: [{ path: gbk, range }] });
expect(result.content).toContain(READ_NOTE);
});
it.each([
['a UTF-8 byte-order mark', Buffer.from('中文')],
['an authored replacement character', Buffer.from('a�b')],
['an empty file', Buffer.alloc(0)],
])('does not warn for %s', async (_label, bytes) => {
const { tools, wsDir } = await buildTools();
const file = path.join(wsDir, 'valid.txt');
fs.writeFileSync(file, bytes);
const result = await run(getTool(tools, 'read_files'), { paths: [{ path: file }] });
expect(result.isError).toBeFalsy();
expect(result.content).not.toContain('text-encoding-recovery');
});
it('returns the authored text for the requested encoding, with no edit evidence', async () => {
const { tools, wsDir } = await buildTools();
const gbk = path.join(wsDir, 'orders.csv');
fs.writeFileSync(gbk, GBK);
const ctx = { workingDir: wsDir, state: { readFileState: new Map() } } as any;
const result = await getTool(tools, 'read_files').execute({ paths: [{ path: gbk, encoding: 'gb18030' }] }, ctx);
expect(result.isError).toBeFalsy();
expect(plain(result.content)).toBe(GBK_TEXT);
expect(result.content).toContain('encoding="gb18030"');
expect(result.content).toContain(`total_chars="${GBK_TEXT.length}"`);
expect(result.content).toContain('has_more="false"');
expect(result.content).not.toContain('text-encoding-recovery');
expect(result.content).not.toContain('file_hash=');
expect(result.content).not.toContain('revision=');
expect(ctx.state.readFileState.size).toBe(0);
expect(result.observations?.fileReads).toEqual([{
path: gbk, charRange: [0, GBK_TEXT.length], lineRange: [1, expect.any(Number)],
}]);
expect(fs.readFileSync(gbk)).toEqual(GBK);
});
it('reports the encoding it applied for a label alias', async () => {
const { tools, wsDir } = await buildTools();
const gbk = path.join(wsDir, 'orders.csv');
fs.writeFileSync(gbk, GBK);
const result = await run(getTool(tools, 'read_files'), { paths: [{ path: gbk, encoding: 'GB2312' }] });
expect(plain(result.content)).toBe(GBK_TEXT);
expect(result.content).toContain('encoding="gbk"');
});
it('reconstructs a long file exactly by following next_range with the same encoding', async () => {
const { tools, wsDir } = await buildTools();
const file = path.join(wsDir, 'long.csv');
const rows = 12_000;
fs.writeFileSync(file, Buffer.concat(Array.from({ length: rows }, () => GBK)));
const expected = GBK_TEXT.repeat(rows);
const read = getTool(tools, 'read_files');
let range: any;
let text = '';
for (let page = 0; page < 20; page++) {
const result = await run(read, { paths: [{ path: file, encoding: 'gb18030', ...(range ? { range } : {}) }] });
expect(result.isError).toBeFalsy();
expect(result.content).toContain(`total_chars="${expected.length}"`);
const body = /<file [^\n]*>\n([\s\S]*?)\n<\/file>/.exec(result.content)![1].replace(/^\d+\t/gm, '');
text += body;
const next = /next_range="char:(\d+)-(\d+)"/.exec(result.content);
if (!next) break;
range = { unit: 'char', start: Number(next[1]), end: Number(next[2]) };
}
expect(text).toBe(expected);
});
it('clips a decoded page to the inline budget and resumes from the reported range', async () => {
const { tools, wsDir } = await buildTools();
const file = path.join(wsDir, 'clipped.csv');
const wanted = '中文'.repeat(1500);
fs.writeFileSync(file, Buffer.concat(Array.from({ length: 1500 }, () => Buffer.from([0xD6, 0xD0, 0xCE, 0xC4]))));
const cap = await import('../../../../src/main/util/tool-result-cap');
const ctx = { workingDir: wsDir, state: {
[cap.TOOL_RESULT_INLINE_LEDGER_STATE_KEY]: { initialTokens: 900, remainingTokens: 900, perResultTokens: 900 },
} } as any;
const read = getTool(tools, 'read_files');
const first = await read.execute({ paths: [{ path: file, encoding: 'gb18030', range: { unit: 'char', start: 0, end: wanted.length } }] }, ctx);
const remainder = /remaining_request_range="char:(\d+)-(\d+)"/.exec(first.content);
expect(remainder).not.toBeNull();
const next = await run(read, { paths: [{ path: file, encoding: 'gb18030', range: { unit: 'char', start: Number(remainder![1]), end: Number(remainder![2]) } }] });
expect(next.content).toContain('request_complete="true"');
expect(plain(first.content) + plain(next.content)).toBe(wanted);
});
it('reports bytes that do not fit the encoding per item and keeps the sibling', async () => {
const { tools, wsDir } = await buildTools();
const bad = path.join(wsDir, 'bad.csv');
const good = path.join(wsDir, 'good.txt');
fs.writeFileSync(bad, INVALID_GB18030);
fs.writeFileSync(good, 'still readable\n');
const ctx = { workingDir: wsDir, state: { readFileState: new Map() } } as any;
const read = getTool(tools, 'read_files');
const mixed = await read.execute({ paths: [{ path: bad, encoding: 'gb18030' }, { path: good }] }, ctx);
expect(mixed.isError).toBeFalsy();
const [first, second] = blocks(mixed.content);
expect(first).toContain('ok="false"');
expect(first).toContain(mismatch(bad, 'gb18030'));
expect(plain(second)).toBe('still readable\n');
expect(mixed.observations?.fileReadBatch).toEqual({
attempted: 2, succeeded: 1, failed: 1, failures: [{ index: 0, code: 'E_TEXT_ENCODING' }],
});
expect([...ctx.state.readFileState.keys()]).toEqual([good]);
const alone = await read.execute({ paths: [{ path: bad, encoding: 'gb18030' }] }, ctx);
expect(alone.isError).toBe(true);
expect(alone.content).toContain(mismatch(bad, 'gb18030'));
expect(alone.content).not.toContain('<file ');
});
it.each([['cp936'], [42], [''], [{ label: 'gbk' }]])('refuses encoding %j before touching the file and keeps the sibling', async (encoding) => {
const { tools, wsDir } = await buildTools();
const good = path.join(wsDir, 'good.txt');
fs.writeFileSync(good, 'still readable\n');
const missing = path.join(wsDir, 'never-created.csv');
const result = await run(getTool(tools, 'read_files'), { paths: [{ path: missing, encoding }, { path: good }] });
expect(result.isError).toBeFalsy();
const [first, second] = blocks(result.content);
expect(first).toContain('E_BAD_INPUT');
expect(first).toContain('unknown encoding');
expect(first).toContain('gb18030, big5, shift_jis or utf-16le');
expect(first).not.toContain('E_NOT_FOUND');
expect(plain(second)).toBe('still readable\n');
});
it('raw_text carries the recovery note without encoding and decodes with it', async () => {
const { tools, wsDir } = await buildTools();
const gbk = path.join(wsDir, 'orders.csv');
const bad = path.join(wsDir, 'bad.csv');
fs.writeFileSync(gbk, GBK);
fs.writeFileSync(bad, INVALID_GB18030);
const ctx = { workingDir: wsDir, state: { readFileState: new Map() } } as any;
const read = getTool(tools, 'read_files');
const refused = await read.execute({ paths: [{ path: gbk }], raw_text: true }, ctx);
expect(refused.isError).toBe(true);
expect(JSON.parse(refused.content).files[0].error).toBe(
`E_RAW_TEXT_ENCODING: ${gbk}: raw_text requires valid UTF-8.\n\n${RAW_NOTE}`,
);
const decoded = await read.execute({ paths: [{ path: gbk, encoding: 'gb18030' }], raw_text: true }, ctx);
expect(decoded.isError).toBeFalsy();
const file = JSON.parse(decoded.content).files[0];
expect(file).toMatchObject({ ok: true, content: GBK_TEXT, encoding: 'gb18030', total_chars: GBK_TEXT.length });
expect(file).not.toHaveProperty('file_hash');
expect(ctx.state.readFileState.size).toBe(0);
const wrong = await read.execute({ paths: [{ path: bad, encoding: 'gb18030' }], raw_text: true }, ctx);
expect(wrong.isError).toBe(true);
expect(JSON.parse(wrong.content).files[0].error).toBe(mismatch(bad, 'gb18030'));
});
it('raw_text keeps its size limit with encoding', async () => {
const { tools, wsDir } = await buildTools({ rawTextMaxBytes: 4 });
const gbk = path.join(wsDir, 'orders.csv');
fs.writeFileSync(gbk, GBK);
const result = await run(getTool(tools, 'read_files'), { paths: [{ path: gbk, encoding: 'gb18030' }], raw_text: true });
expect(result.isError).toBe(true);
expect(result.content).toContain('E_RAW_TEXT_LIMIT');
expect(result.content).not.toContain('中文');
});
it('treats an explicit utf-8 label as the ordinary read, on both read paths', async () => {
const { tools, wsDir } = await buildTools();
const valid = path.join(wsDir, 'valid.txt');
const gbk = path.join(wsDir, 'orders.csv');
fs.writeFileSync(valid, '中文\n');
fs.writeFileSync(gbk, GBK);
const read = getTool(tools, 'read_files');
const page = await run(read, { paths: [{ path: valid, encoding: 'utf-8' }] });
expect(page.content).toContain('file_hash="sha256:');
expect(page.content).not.toContain('encoding=');
const raw = JSON.parse((await run(read, { paths: [{ path: valid, encoding: 'utf8' }], raw_text: true })).content).files[0];
expect(raw).toMatchObject({ ok: true, content: '中文\n', file_hash: expect.stringMatching(/^sha256:/) });
expect(raw).not.toHaveProperty('encoding');
const refused = await run(read, { paths: [{ path: gbk, encoding: 'utf-8' }] });
expect(refused.isError).toBe(true);
expect(refused.content).toContain(mismatch(gbk, 'utf-8'));
const rawRefused = JSON.parse((await run(read, { paths: [{ path: gbk, encoding: 'utf-8' }], raw_text: true })).content).files[0];
expect(rawRefused.error).toContain('E_RAW_TEXT_ENCODING');
});
it('ignores encoding for kinds that are not plain text and for metadata reads', async () => {
const { tools, wsDir } = await buildTools();
const pdf = path.join(wsDir, 'report.pdf');
fs.writeFileSync(pdf, await makeMinimalPdf(['Quarterly sentinel 8841']));
const read = getTool(tools, 'read_files');
const plainPdf = await run(read, { paths: [{ path: pdf }] });
const labelled = await run(read, { paths: [{ path: pdf, encoding: 'gb18030' }] });
expect(labelled.content).toBe(plainPdf.content);
expect(labelled.content).toContain('Quarterly sentinel 8841');
expect(labelled.content).not.toContain('text-encoding-recovery');
const gbk = path.join(wsDir, 'orders.csv');
fs.writeFileSync(gbk, GBK);
const metadata = await run(read, { paths: [{ path: gbk, encoding: 'gb18030' }], metadata_only: true });
expect(metadata.isError).toBeFalsy();
expect(metadata.content).toContain(`bytes="${GBK.length}"`);
expect(metadata.content).not.toContain('中文');
});
it('applies the ordinary scope gate before decoding', async () => {
const perm = await import('../../../../src/main/features/permissions');
perm.setLocalExecMode('workspace_approval');
const { tools } = await buildTools();
const outside = path.join(tmpDir, '..', 'outside-encoding', 'secret.csv');
fs.mkdirSync(path.dirname(outside), { recursive: true });
fs.writeFileSync(outside, GBK);
try {
const result = await run(getTool(tools, 'read_files'), { paths: [{ path: outside, encoding: 'gb18030' }] });
expect(result.isError).toBe(true);
expect(result.content).toContain('E_PATH_OUT_OF_SCOPE');
expect(result.content).not.toContain('中文');
} finally { fs.rmSync(path.dirname(outside), { recursive: true, force: true }); }
});
it('reads a GBK attachment admitted through the real import, leaving the stored bytes alone', async () => {
const { tools, attDir } = await buildTools();
const chatAttachments = await import('../../../../src/main/features/chat_attachments');
const source = path.join(tmpDir, '订单历史(5378).csv');
fs.writeFileSync(source, GBK);
const imported = await chatAttachments.importAttachmentFromPath(UID, CID, source);
expect(imported).toMatchObject({ ok: true });
if (!imported.ok) return;
const stored = path.join(attDir, imported.info.name);
const read = getTool(tools, 'read_files');
const first = await run(read, { paths: [{ path: stored }] });
expect(first.content).toContain(READ_NOTE);
expect(first.content).toContain('ok="true"');
const second = await run(read, { paths: [{ path: stored, encoding: 'gb18030' }] });
expect(plain(second.content)).toBe(GBK_TEXT);
expect(fs.readFileSync(stored)).toEqual(GBK);
expect(fs.readFileSync(source)).toEqual(GBK);
});
});
describe('file-tools › read_files metadata_only', () => {
it('reports a failed analysis per item instead of claiming a populated CSV is empty', async () => {
const { tools, wsDir } = await buildTools();
const file = path.join(wsDir, 'populated.csv');
const empty = path.join(wsDir, 'empty.csv');
const text = 'id,value\nA,100\nB,200\n';
fs.writeFileSync(file, text);
fs.writeFileSync(empty, '');
const originalRead = fs.promises.readFile;
const spy = vi.spyOn(fs.promises, 'readFile').mockImplementation(async (target, options) => {
if (String(target) === file) throw Object.assign(new Error('Controlled analysis failure'), { code: 'EIO' });
return originalRead(target, options);
});
const read = getTool(tools, 'read_files');
try {
const result = await run(read, { paths: [{ path: file }, { path: empty }], metadata_only: true });
expect(result.isError).toBeFalsy();
expect(result.content).toContain('errors="1"');
expect(result.content).toContain('<read-result index="0" ok="false">');
expect(result.content).toContain('<read-result index="1" ok="true">');
expect(result.content).toContain('E_READ_FAILED');
} finally { spy.mockRestore(); }
const retry = await run(read, { paths: [{ path: file }], metadata_only: true });
expect(retry.isError).toBeFalsy();
expect(retry.content).toContain(`total_chars="${text.length}"`);
expect(fs.readFileSync(file, 'utf8')).toBe(text);
});
it('returns total_chars for text without extra extraction work', async () => {
const { tools, wsDir } = await buildTools();
const p = path.join(wsDir, 'hello.txt');
@@ -2583,6 +2948,43 @@ describe('file-tools › read_files metadata_only', () => {
});
describe('file-tools › search_files', () => {
it.each(['search_files', 'grep_files'])('honours trailing double-star ignores through the walk fallback (%s)', async (name) => {
const { tools, wsDir } = await buildTools();
const files = ['logs/today.txt', 'logs/old/jan.txt', 'logs/keep.txt', 'src/logs/a.txt',
'cache/index.txt', 'cache/v1/data.txt', 'pkg/generated/data.txt', 'pkg/visible.txt'];
for (const relative of files) {
const file = path.join(wsDir, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 'needle\n');
}
fs.writeFileSync(path.join(wsDir, '.gitignore'), 'logs/**\n!logs/keep.txt\ncache/**/\n');
fs.writeFileSync(path.join(wsDir, 'pkg', '.gitignore'), 'generated/**\n');
const visible = ['logs/keep.txt', 'src/logs/a.txt', 'cache/index.txt', 'pkg/visible.txt'];
const originalPath = process.env.PATH;
// Exercise the real tool, traversal, and filesystem with rg unavailable.
process.env.PATH = tmpDir;
try {
for (const includeIgnored of [false, true]) {
const result = await run(getTool(tools, name), {
root: wsDir, query: '*.txt', pattern: 'needle', include_ignored: includeIgnored,
});
expect(result.isError, result.content).toBeFalsy();
expect(result.content).toContain('backend=walk');
expect(result.content).toContain('complete=true');
const expected = (includeIgnored ? files : visible).map(relative => path.join(wsDir, relative)).sort();
if (name === 'search_files') {
const listing = searchResults(result.content);
expect(listing.files.map(file => path.join(listing.roots[file.root], file.path)).sort()).toEqual(expected);
} else {
for (const relative of files) {
const marker = `file: ${JSON.stringify(path.join(wsDir, relative))}\n`;
expect(result.content.includes(marker), relative).toBe(expected.includes(path.join(wsDir, relative)));
}
}
}
} finally { process.env.PATH = originalPath; }
});
it.each(['account', 'project'])('reads discovered files directly from a nested %s conversation cwd', async (scope) => {
const { tools, wsDir, attDir } = scope === 'project' ? await buildProjectTools() : await buildTools();
const cwd = path.join(wsDir, 'conversation');
@@ -74,6 +74,7 @@ export function enumerateAllInjectedTools(): AgentTool[] {
clear: () => {},
}));
tools.push(createCrossSessionMemoryTool({
batch: () => ({ ok: true, entries: [], usage: { current: 0, limit: 1 } }),
add: () => ({ ok: true, entries: [], usage: { current: 0, limit: 1 } }),
replace: () => ({ ok: true, entries: [], usage: { current: 0, limit: 1 } }),
remove: () => ({ ok: true, entries: [], usage: { current: 0, limit: 1 } }),
+160 -29
View File
@@ -61,6 +61,84 @@ it.each(['814b61b027f0', '79df9cc89f5f'])('does not add an agent-specific browse
} finally { execute.mockRestore(); }
});
it('passes a prototype-named executable to the shell without inventing package approval', async () => {
await allFilesApproval();
const core = await import('../../../../src/core-agent/src/tools/builtin');
const { createLocalTools } = await import('../../../../src/main/model/core-agent/local-tools');
const execute = vi.spyOn(core.bashTool, 'execute').mockResolvedValue({ content: 'shell receipt' });
try {
const bash = createLocalTools({ userId: UID, cid: CID }).find(tool => tool.name === 'bash')!;
const result = await bash.execute({ command: 'constructor probe' }, { workingDir: tmpDir, state: {} } as any);
expect(result).toMatchObject({ content: 'shell receipt' });
expect(execute).toHaveBeenCalledOnce();
expect(execute.mock.calls[0][0].command).toBe('constructor probe');
} finally { execute.mockRestore(); }
});
it('keeps network approval after a prototype-named executable and prevents dispatch on denial', async () => {
await allFilesApproval();
const core = await import('../../../../src/core-agent/src/tools/builtin');
const { createLocalTools } = await import('../../../../src/main/model/core-agent/local-tools');
const bp = await import('../../../../src/main/model/core-agent/bash-permissions');
const execute = vi.spyOn(core.bashTool, 'execute').mockResolvedValue({ content: 'must not run' });
const requests: any[] = [];
bp._setBroadcastForTest((_channel, request: any) => {
requests.push(request);
bp.respond(request.request_id, 'deny');
});
try {
const bash = createLocalTools({ userId: UID, cid: CID }).find(tool => tool.name === 'bash')!;
const result = await bash.execute({ command: '__proto__ probe; curl https://example.com' }, {
workingDir: tmpDir, state: {},
} as any);
expect(result.isError).toBe(true);
expect(result.content).toContain('E_BASH_RISK_DENIED');
expect(requests).toHaveLength(1);
expect(requests[0].reasons).toContain('network_egress');
expect(execute).not.toHaveBeenCalled();
} finally { execute.mockRestore(); bp._setBroadcastForTest(null); bp._resetForTest(); }
});
it('evaluates owned-file cleanup with prototype-named segments without exempting recursive deletion', async () => {
const { bashDestructiveRiskIsOnlyProducedFileDeletion } = await import('../../../../src/main/model/core-agent/local-tools');
const target = path.join(tmpDir, 'generated.txt');
const owns = (candidate: string) => candidate === target;
for (const hostPlatform of ['darwin', 'win32'] as const) {
const remove = hostPlatform === 'win32'
? `Remove-Item -LiteralPath ${JSON.stringify(target)} -Force`
: `rm -f ${JSON.stringify(target)}`;
const recursive = hostPlatform === 'win32'
? `Remove-Item -LiteralPath ${JSON.stringify(target)} -Recurse -Force`
: `rm -rf ${JSON.stringify(target)}`;
expect(bashDestructiveRiskIsOnlyProducedFileDeletion(
`constructor probe; ${remove}`, tmpDir, {}, owns, hostPlatform,
)).toBe(true);
expect(bashDestructiveRiskIsOnlyProducedFileDeletion(
`__proto__ probe; ${recursive}`, tmpDir, {}, owns, hostPlatform,
)).toBe(false);
}
});
it('rejects retired process startup before asking permission or touching the workspace', async () => {
await allFilesApproval();
const { createLocalTools } = await import('../../../../src/main/model/core-agent/local-tools');
const bp = await import('../../../../src/main/model/core-agent/bash-permissions');
const { processStartTool } = await import('../../../../src/core-agent/src/tools/process-session');
const start = vi.spyOn(processStartTool, 'execute');
const asks: unknown[] = [];
bp._setBroadcastForTest((_channel, request: any) => { asks.push(request); bp.respond(request.request_id, 'deny'); });
const before = fs.readdirSync(tmpDir);
try {
const tool = createLocalTools({ userId: UID, cid: CID }).find(tool => tool.name === 'process_session')!;
const result = await tool.execute({ action: 'start', command: 'rm -rf must-not-start' }, { workingDir: tmpDir, state: {} } as any);
expect(result.isError).toBe(true);
expect(result.content).toContain('action');
expect(asks).toEqual([]);
expect(start).not.toHaveBeenCalled();
expect(fs.readdirSync(tmpDir)).toEqual(before);
} finally { start.mockRestore(); bp._setBroadcastForTest(null); bp._resetForTest(); }
});
it('offers detached lifetime explicitly without directing ordinary long tasks away from managed sessions', async () => {
const { bashTool } = await import('../../../../src/core-agent/src/tools/builtin');
const { createLocalTools } = await import('../../../../src/main/model/core-agent/local-tools');
@@ -142,7 +220,7 @@ describe('local-tools › Windows PowerShell compatibility preflight', () => {
} finally { execute.mockRestore(); }
});
it.each(['bash', 'process_session', 'interactive_cli'])('keeps conditional removal behind approval: %s', async name => {
it.each(['bash', 'interactive_cli'])('keeps conditional removal behind approval: %s', async name => {
await allFilesApproval();
const { createLocalTools } = await import('../../../../src/main/model/core-agent/local-tools');
const bp = await import('../../../../src/main/model/core-agent/bash-permissions');
@@ -242,22 +320,21 @@ describe('local-tools › Windows PowerShell compatibility preflight', () => {
finally { vi.unstubAllEnvs(); }
});
it.each(['process_session', 'interactive_cli'])('passes accepted here-string bytes to the persistent starter once: %s', async name => {
it.each(['bash', 'interactive_cli'])('passes accepted here-string bytes to the command starter once: %s', async name => {
await allFilesAuto();
const { createLocalTools } = await import('../../../../src/main/model/core-agent/local-tools');
const processTools = await import('../../../../src/core-agent/src/tools/process-session');
const { bashTool } = await import('../../../../src/core-agent/src/tools/builtin');
const interactive = await import('../../../../src/main/model/core-agent/interactive-cli-sessions');
const command = "$s = @'\necho 'left && right'\n'@\nWrite-Output $s";
const coreProcess = processTools.getProcessSessionTools()[0];
const execute = vi.spyOn(coreProcess, 'execute').mockResolvedValue({ content: JSON.stringify({ status: 'exited', exit_code: 0 }) });
const execute = vi.spyOn(bashTool, 'execute').mockResolvedValue({ content: JSON.stringify({ status: 'exited', exit_code: 0 }) });
const view = { session_id: 'fixture-session', status: 'exited', exit_code: 0, output: '', urls: [] } as any;
const start = vi.spyOn(interactive, 'startInteractiveCliSession').mockReturnValue(view);
const read = vi.spyOn(interactive, 'readInteractiveCliSession').mockReturnValue(view);
try {
const tool = createLocalTools({ userId: UID, cid: CID, hostPlatform: 'win32' }).find(tool => tool.name === name)!;
const result = await tool.execute({ action: 'start', command }, { workingDir: tmpDir, state: {} } as any);
const result = await tool.execute(name === 'bash' ? { command } : { action: 'start', command }, { workingDir: tmpDir, state: {} } as any);
expect(result.isError).toBeFalsy();
if (name === 'process_session') {
if (name === 'bash') {
expect(execute).toHaveBeenCalledOnce();
expect(execute.mock.calls[0][0].command).toBe(command);
expect(start).not.toHaveBeenCalled();
@@ -269,7 +346,7 @@ describe('local-tools › Windows PowerShell compatibility preflight', () => {
} finally { execute.mockRestore(); start.mockRestore(); read.mockRestore(); }
});
it.each(['bash', 'process_session', 'interactive_cli'])('retains one approval for mutations after literal here-string data: %s', async name => {
it.each(['bash', 'interactive_cli'])('retains one approval for mutations after literal here-string data: %s', async name => {
await allFilesApproval();
const { createLocalTools } = await import('../../../../src/main/model/core-agent/local-tools');
const bp = await import('../../../../src/main/model/core-agent/bash-permissions');
@@ -311,26 +388,22 @@ describe('local-tools › Windows PowerShell compatibility preflight', () => {
} finally { execute.mockRestore(); }
});
it('blocks incompatible commands through bash and both persistent session starters before spawning them', async () => {
it('blocks incompatible commands through the two current command starters before spawning them', async () => {
await allFilesAuto();
const localTools = await import('../../../../src/main/model/core-agent/local-tools');
const tools = localTools.createLocalTools({ userId: UID, cid: CID, hostPlatform: 'win32' });
const bash = tools.find((tool) => tool.name === 'bash')!;
const processStart = tools.find((tool) => tool.name === 'process_session')!;
const interactive = tools.find((tool) => tool.name === 'interactive_cli')!;
const marker = path.join(tmpDir, 'must-not-run.txt');
const command = `node -e "require('fs').writeFileSync('${marker}', 'ran')" && (echo done)`;
const ctx = { workingDir: tmpDir, signal: undefined, state: {} } as any;
const bashResult = await bash.execute({ command }, ctx);
const processResult = await processStart.execute({ action: 'start', command }, ctx);
const interactiveResult = await interactive.execute({ action: 'start', command }, ctx);
expect(bashResult).toMatchObject({ isError: true });
expect(processResult).toMatchObject({ isError: true });
expect(interactiveResult).toMatchObject({ isError: true });
expect(bashResult.content).toContain('E_SHELL_SYNTAX_MISMATCH');
expect(processResult.content).toContain('E_SHELL_SYNTAX_MISMATCH');
expect(interactiveResult.content).toContain('E_SHELL_SYNTAX_MISMATCH');
expect(fs.existsSync(marker)).toBe(false);
});
@@ -755,7 +828,7 @@ describe('local-tools › persistent process sessions', () => {
return bash[id].execute({ command: `${process.platform === 'win32' ? '& ' : ''}${quote(TEST_NODE)} -e ${quote(script)}`, yield_time_ms: 5000, timeoutMs: 6000 }, ctx);
}));
results.forEach((result, id) => {
expect(result.isError).toBeUndefined();
expect(result.isError, result.content).toBeUndefined();
expect(result.observations?.execution?.status).toBe('succeeded');
expect(written[id].mock.calls.map(([file]) => file)).toEqual([path.join(workspace, '.cache', `${id}.txt`)]);
const match = result.content.match(/\.orkas-output-manifest[^\s<"]*/);
@@ -811,13 +884,12 @@ describe('local-tools › persistent process sessions', () => {
const processStart = localTools.createLocalTools({
userId: UID,
cid: CID,
}).find((tool) => tool.name === 'process_session')!;
}).find((tool) => tool.name === 'bash')!;
const command = process.platform === 'win32'
? `Set-Content -LiteralPath ${JSON.stringify(outside)} -Value blocked`
: `printf blocked > ${JSON.stringify(outside)}`;
const result = await processStart.execute({
action: 'start',
command,
}, {
workingDir: workspace,
@@ -839,7 +911,8 @@ describe('local-tools › persistent process sessions', () => {
const command = process.platform === 'win32' ? '& $env:ComSpec /c exit 0' : 'true';
const state = { commandSessionEnabled: mode === 'managed' || mode === 'programmatic' };
try {
const result = await bash.execute({ command, yield_time_ms: 1000 }, {
// Use the ordinary bounded wait: a cold Windows shell may take over a second to exit.
const result = await bash.execute({ command }, {
workingDir: mode === 'no-workspace' ? undefined : tmpDir,
state: mode === 'programmatic' ? markProgrammaticToolCallState(state) : state,
});
@@ -938,14 +1011,12 @@ describe('local-tools › persistent process sessions', () => {
onFileWritten,
});
const processSession = tools.find((tool) => tool.name === 'process_session')!;
const ctx = { workingDir: workspace, state: {} } as any;
const ctx = { workingDir: workspace, state: { commandSessionEnabled: true } } as any;
const command = process.platform === 'win32'
? 'Start-Sleep -Milliseconds 700; Set-Content -NoNewline -Path generated.ts -Value "export const generated = true;`n"'
: "sleep 0.7; printf 'export const generated = true;\\n' > generated.ts";
const started = await processSession.execute({
action: 'start',
command,
}, ctx);
const bash = tools.find(tool => tool.name === 'bash')!;
const started = await bash.execute({ command, yield_time_ms: 0 }, ctx);
const initial = JSON.parse(started.content);
let terminal = started;
let payload = initial;
@@ -2146,7 +2217,7 @@ describe('local-tools \u203a bash \u203a removing files outside the writable wor
});
});
it.each(['bash', 'process_session', 'interactive_cli'])(
it.each(['bash', 'interactive_cli'])(
'preserves an outside source when a PowerShell move is denied through %s', async (name) => {
await allFilesApproval();
await withBashPermission('deny', async (seen) => {
@@ -2530,14 +2601,74 @@ describe('local-tools › edit_file › input validation', () => {
expect(r.content).toContain('E_BAD_INPUT');
});
it('rejects no-op when old === new', async () => {
it.each([
['unique', 'foo', 'foo'],
['repeated', 'foo foo', 'foo'],
['absent', 'bar', 'foo'],
['empty', '', ''],
['whitespace', 'foo \nbar', 'foo\nbar'],
])('ignores identical replacement strings without writes or change notifications (%s)', async (_kind, body, text) => {
await allFilesAuto();
const { edit, wsDir } = await buildEditTool();
const onFileWritten = vi.fn();
const { edit, wsDir } = await buildEditTool({ onFileWritten });
const { createFileTools } = await import('../../../../src/main/model/core-agent/file-tools');
const read = createFileTools({ userId: UID, cid: CID }).find(tool => tool.name === 'read_files')!;
const p = path.join(wsDir, 'a.txt');
fs.writeFileSync(p, 'foo');
const r = await run(edit, { path: p, old_string: 'foo', new_string: 'foo' });
expect(r.isError).toBe(true);
expect(r.content).toContain('E_BAD_INPUT');
fs.writeFileSync(p, body);
fs.utimesSync(p, new Date('2000-01-01'), new Date('2000-01-01'));
const before = fs.statSync(p);
const ctx = { workingDir: wsDir, state: { readFileState: new Map(), runScopedLedger: new Map() } } as any;
const observed = await read.execute({ paths: [{ path: p }] }, ctx);
expect(observed.isError).toBeFalsy();
const revision = /revision="([^"]+)"/.exec(observed.content)![1];
const hash = `sha256:${createHash('sha256').update(body).digest('hex')}`;
for (let attempt = 0; attempt < 2; attempt++) {
const result = await edit.execute({ path: p, old_string: text, new_string: text }, ctx);
expect(result.isError, result.content).toBeFalsy();
expect(result.content).toContain('edited="0"');
expect(result.content).toContain(`file_hash="${hash}"`);
expect(result.content).toContain(`revision="${revision}"`);
expect(result.observations?.fileChanges?.length ?? 0).toBe(0);
}
const after = fs.statSync(p);
expect([after.mtimeMs, after.ctimeMs, after.ino]).toEqual([before.mtimeMs, before.ctimeMs, before.ino]);
expect(fs.readFileSync(p, 'utf8')).toBe(body);
expect(fs.readdirSync(wsDir)).toEqual(['a.txt']);
expect(onFileWritten).not.toHaveBeenCalled();
});
it.each([
['outside', 'E_PATH_OUT_OF_SCOPE'],
['protected', 'E_PROTECTED_PATH_READ_ONLY'],
['missing', 'E_NOT_FOUND'],
['unread', 'E_NOT_READ'],
['stale', 'E_STALE'],
['hash', 'E_STALE'],
['pdf', 'E_NOT_EDITABLE'],
])('retains target and version guards for identical replacement strings (%s)', async (kind, code) => {
await workspaceOnly();
const onFileWritten = vi.fn();
const protectedDir = path.join(tmpDir, 'protected');
const { edit, wsDir } = await buildEditTool({ onFileWritten, readOnlyExtraRoots: [protectedDir] });
const p = path.join(kind === 'outside' ? tmpDir : kind === 'protected' ? protectedDir : wsDir,
kind === 'pdf' ? 'a.pdf' : 'a.txt');
fs.mkdirSync(path.dirname(p), { recursive: true });
const body = kind === 'pdf' ? makeMinimalPdf(['foo']) : 'foo';
if (kind !== 'missing') fs.writeFileSync(p, body);
const readFileState = new Map();
const ctx = { workingDir: wsDir, state: { readFileState } } as any;
if (kind === 'stale') {
const st = fs.statSync(p);
readFileState.set(p, { mtimeMs: st.mtimeMs, size: st.size, hash: `sha256:${'0'.repeat(64)}` });
}
const result = await edit.execute({ path: p, old_string: 'foo', new_string: 'foo',
...(kind === 'hash' ? { expected_hash: `sha256:${'0'.repeat(64)}` } : {}) }, ctx);
expect(result.isError).toBe(true);
expect(result.content).toContain(code);
expect(result.observations?.fileChanges?.length ?? 0).toBe(0);
expect(onFileWritten).not.toHaveBeenCalled();
if (kind === 'missing') expect(fs.existsSync(p)).toBe(false);
else expect(fs.readFileSync(p)).toEqual(Buffer.from(body));
});
});
+11 -51
View File
@@ -281,38 +281,14 @@ describe('Office built-in tools', () => {
});
});
it('keeps historical flat create_xlsx calls executable but rejects mixed input forms', async () => {
it('rejects retired flat XLSX input before OfficeCLI or publication', async () => {
const tool = getTool('create_xlsx');
const legacy = await tool.execute({
path: 'out/legacy.xlsx',
sheet: 'Legacy',
rows: [['Label', 'Value'], ['Total', { formula: '=SUM(B3:B4)', bold: true }]],
columns: [{ name: 'B', width: '14' }],
charts: [{ type: 'column', dataRange: 'Legacy!A1:B2', anchor: 'D2:J14' }],
preview: false,
}, ctx());
expect(legacy.isError).toBeUndefined();
expect(legacy.content).toContain('(4 cells)');
const batchCall = h.runOfficeCli.mock.calls.find(([args]) => args[0] === 'batch');
const operations = JSON.parse(batchCall?.[1]?.stdin as string);
expect(operations).toContainEqual({
command: 'set', path: '/Legacy/B2', props: { formula: 'SUM(B3:B4)', bold: 'true' },
});
expect(operations).toContainEqual(expect.objectContaining({
command: 'add', parent: '/Legacy', type: 'chart',
}));
h.runOfficeCli.mockClear();
const mixed = await tool.execute({
path: 'out/mixed.xlsx',
sheets: [{ name: 'Canonical', rows: [['ok']] }],
rows: [['legacy']],
preview: false,
}, ctx());
expect(mixed).toMatchObject({ isError: true });
expect(mixed.content).toContain('do not mix');
for (const input of [
{ path: 'retired.xlsx', rows: [['old']] },
{ path: 'mixed.xlsx', sheets: [{ name: 'Current', rows: [['ok']] }], rows: [['old']] },
]) expect((await tool.execute(input, ctx())).isError).toBe(true);
expect(h.runOfficeCli).not.toHaveBeenCalled();
expect(onFileWritten).not.toHaveBeenCalled();
});
it('exposes PPTX visual controls and emits native charts in the initial create batch', async () => {
@@ -414,11 +390,7 @@ describe('Office built-in tools', () => {
h.runOfficeCli
.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' })
.mockResolvedValueOnce({ code: 3, stdout: '', stderr: 'batch failed' });
const batchFailed = await getTool('create_xlsx').execute({
path: 'failed.xlsx',
rows: [['value']],
preview: false,
}, ctx());
const batchFailed = await getTool('create_xlsx').execute({path: 'failed.xlsx', preview: false, sheets: [{name: 'Sheet1', rows: [['value']] }]}, ctx());
expect(batchFailed.content).toContain('E_OFFICE_BATCH_FAILED: batch failed');
expect(fs.existsSync(path.join(h.workspace, 'failed.xlsx'))).toBe(false);
expect(h.closeOfficeFile).toHaveBeenCalledTimes(2);
@@ -473,9 +445,7 @@ describe('Office built-in tools', () => {
}
return { code: 0, stdout: 'ok', stderr: '' };
});
const result = await getTool('create_xlsx').execute({
path: target, rows: [['value']], preview: false,
}, ctx());
const result = await getTool('create_xlsx').execute({path: target, preview: false, sheets: [{name: 'Sheet1', rows: [['value']] }]}, ctx());
expect(batches).toBe(2);
expect(inputStates).toEqual(['pristine', 'pristine']);
expect(fs.readFileSync(target, 'utf8')).toBe(retryFails ? 'prior-owned-file' : 'pristine:one-sheet');
@@ -491,9 +461,7 @@ describe('Office built-in tools', () => {
return { code: 0, stdout: '', stderr: '' };
}).mockResolvedValueOnce({ code: 3, stdout: '', stderr: 'batch could not be delivered' });
h.closeOfficeFile.mockImplementationOnce(async () => { abort.abort(); });
const result = await getTool('create_xlsx').execute({
path: 'cancelled.xlsx', rows: [['value']], preview: false,
}, ctx({ signal: abort.signal }));
const result = await getTool('create_xlsx').execute({path: 'cancelled.xlsx', preview: false, sheets: [{name: 'Sheet1', rows: [['value']] }]}, ctx({ signal: abort.signal }));
expect(result.isError).toBe(true);
expect(h.runOfficeCli).toHaveBeenCalledTimes(2);
expect(onFileWritten).not.toHaveBeenCalled();
@@ -502,11 +470,7 @@ describe('Office built-in tools', () => {
it('keeps a prior conversation-produced workbook intact when a recreate batch fails', async () => {
const tool = getTool('create_xlsx');
const first = await tool.execute({
path: 'stable.xlsx',
rows: [['original']],
preview: false,
}, ctx());
const first = await tool.execute({path: 'stable.xlsx', preview: false, sheets: [{name: 'Sheet1', rows: [['original']] }]}, ctx());
expect(first.isError).toBeUndefined();
const stable = path.join(h.workspace, 'stable.xlsx');
const before = fs.readFileSync(stable);
@@ -522,11 +486,7 @@ describe('Office built-in tools', () => {
}
return { code: 0, stdout: 'ok', stderr: '' };
});
const failed = await tool.execute({
path: 'stable.xlsx',
rows: [['replacement']],
preview: false,
}, ctx());
const failed = await tool.execute({path: 'stable.xlsx', preview: false, sheets: [{name: 'Sheet1', rows: [['replacement']] }]}, ctx());
expect(failed).toMatchObject({ isError: true });
expect(failed.content).toContain('E_OFFICE_BATCH_FAILED');
@@ -228,6 +228,22 @@ describe('persisted tool-result retrieval', () => {
});
});
it('does not resolve another session result even when its genuine ref is known', async () => {
const otherSession = path.join(dir, 'other-session');
const source = 'Historical audit: seven passed, two failed.';
const historicalRef = toolResultRefForPath(persistToolResult(otherSession, 'audit', source));
expect(resolveToolResultRef(otherSession, historicalRef)).toMatchObject({ ok: true });
const result = await getTool(tools, 'tool_result').execute({
action: 'read', requests: [{ ref: historicalRef, cursor: 0 }],
}, ctx);
expect(result.isError).toBe(true);
expect(result.content).toContain('E_RESULT_REF_MISSING');
expect(result.content).not.toContain(source);
expect(ctx.state.toolResultReadLedger.readKeys.size).toBe(0);
expect(fs.readFileSync(path.join(otherSession, `${historicalRef}.txt`), 'utf8')).toBe(source);
});
it('retrieves persisted connector output whose tool name starts with call_', async () => {
const connectorContent = 'Figma frame title: Checkout\nReview status: needs spacing fix';
const connectorRef = toolResultRefForPath(
@@ -12,6 +12,55 @@ import {
import { TOOL_CATALOG_REVISION } from '../../../../src/main/model/core-agent/tool-catalog-revision';
describe('tool-surface', () => {
it.each([
['management.projects', 'project_setup'],
['management.todos', 'todo_tasks'],
['management.automation', 'auto_tasks'],
])('loads only the requested management capability: %s', (group, expected) => {
const mutations = ['todo_tasks', 'project_setup', 'auto_tasks'];
const surface = createToolSurfaceController({
availableToolNames: [...mutations, 'tool_load'], scopedEligible: true,
});
expect(surface.load([group]).isError).toBeFalsy();
expect(surface.activeToolNames().filter(name => mutations.includes(name))).toEqual([expected]);
});
it.each(['management.projects', 'management.todos'])('preserves saved Agent backlog access from %s without project mutation authority', group => {
const surface = createToolSurfaceController({
availableToolNames: ['todo_tasks', 'project_setup', 'tool_load'],
configuredGroups: [group], scopedEligible: true,
dynamicLoadPolicy: 'agent-dependency', allowLegacyAll: false,
});
expect(surface.isActive('todo_tasks')).toBe(true);
expect(surface.isActive('project_setup')).toBe(false);
expect(surface.loadableGroups()).toEqual(['management.todos']);
expect(surface.loadedGroups()).toEqual(['management.todos']);
expect(surface.loadableGroupsForTool('todo_tasks')).toEqual(['management.todos']);
});
it('rejects retired Agent load aliases atomically while accepting the current dependency id', () => {
const make = () => createToolSurfaceController({
availableToolNames: ['todo_tasks', 'tool_load'], scopedEligible: true,
dynamicLoadPolicy: 'agent-dependency', allowLegacyAll: false,
});
const surface = make();
expect(surface.load(['management.projects']).isError).toBe(true);
expect(surface.isActive('todo_tasks')).toBe(false);
expect(JSON.parse(surface.load(['management.todos']).content)).toMatchObject({
ok: true, newly_loaded: ['management.todos'], newly_activated_tools: ['todo_tasks'],
});
const mixed = make();
expect(mixed.load(['management.projects', 'management.todos']).isError).toBe(true);
expect(mixed.isActive('todo_tasks')).toBe(false);
});
it.each(['fs', 'shell', 'pdf', 'kb', 'image', 'video', 'connector'])('rejects retired tool-load shorthand without activating any capability: %s', alias => {
const surface = createToolSurfaceController({ availableToolNames: TOOL_CATALOG.map(entry => entry.name), scopedEligible: true });
const before = surface.activeToolNames();
expect(surface.load([alias]).isError).toBe(true);
expect(surface.activeToolNames()).toEqual(before);
});
it.each(LOADABLE_TOOL_GROUP_IDS)('activates exactly the available descendants of %s, then resets next turn', (groupId) => {
const available = TOOL_CATALOG.filter((entry) => !entry.ownerAgent).map((entry) => entry.name);
const surface = createToolSurfaceController({ availableToolNames: available, scopedEligible: true });
@@ -277,7 +326,8 @@ describe('tool-surface', () => {
availableToolNames: ['add_custom_connector', 'connector_setup', 'tool_load'],
allowedGroupIds: surface.loadableGroups(),
});
expect(directory).toContain('`add_custom_connector` — Commander-only custom MCP installation request.');
expect(directory).toContain('`add_custom_connector` — '
+ TOOL_CATALOG.find(entry => entry.name === 'add_custom_connector')!.summary);
expect(directory).toMatch(/`connector_setup`[^\n]*Setup\/reconnect any built-in/);
expect(directory).not.toContain('`list_connector_tools`');
expect(directory).not.toContain('`call_connector_tool`');
@@ -746,7 +796,7 @@ describe('tool-surface', () => {
});
expect(surface.loadableGroupsForTool('write_file')).toEqual(['workspace.write.output']);
expect(surface.loadableGroupsForTool('call_connector_tool')).toEqual([]);
expect(surface.loadableGroupsForTool('todo_tasks')).toEqual(['management.projects']);
expect(surface.loadableGroupsForTool('todo_tasks')).toEqual(['management.todos']);
expect(surface.loadableGroupsForTool('missing')).toEqual([]);
});
+78 -107
View File
@@ -16,7 +16,7 @@ const POWERSHELL_PATH_ASSIGNMENTS = [
{ label: 'right-spaced single-quoted relative path', spacing: '= ', separator: '\n', quote: "'", relative: true, variable: '$script', reference: '$SCRIPT', tool: 'bash' },
{ label: 'left-spaced double-quoted relative path', spacing: ' =', separator: '; ', quote: '"', relative: true, variable: '$script', reference: '$SCRIPT', tool: 'bash' },
{ label: 'tabbed environment path', spacing: '\t=\t', separator: '\r\n', quote: '"', relative: false, variable: '$ENV:ORKAS_TEST_PATH_TARGET', reference: '$env:orkas_test_path_target', tool: 'bash' },
{ label: 'compact environment path in a persistent process', spacing: '=', separator: '\n', quote: "'", relative: true, variable: '$env:ORKAS_TEST_PATH_TARGET', reference: '$ENV:orkas_test_path_target', tool: 'process_session' },
{ label: 'compact environment path in a shell command', spacing: '=', separator: '\n', quote: "'", relative: true, variable: '$env:ORKAS_TEST_PATH_TARGET', reference: '$ENV:orkas_test_path_target', tool: 'bash' },
] as const;
function powershellPathLiteral(value: string, quote: string): string {
@@ -520,13 +520,12 @@ describe('local-tools › bash filesystem mutation scope', () => {
} finally { execute.mockRestore(); }
});
it.each(['bash', 'process_session'] as const)('checks New-Item targets and resolves pure Join-Path before %s execution', async toolName => {
it.each(['bash'] as const)('checks New-Item targets and resolves pure Join-Path before %s execution', async toolName => {
const { lt, perm } = await loadModules();
perm.setLocalExecMode('workspace_approval'); await setTmpWorkspace();
const bp = await import('../../../src/main/model/core-agent/bash-permissions');
const { bashTool } = await import('../../../src/core-agent/src/tools/builtin');
const { processSessionTool } = await import('../../../src/core-agent/src/tools/process-session');
const execute = vi.spyOn(toolName === 'bash' ? bashTool : processSessionTool, 'execute').mockResolvedValue({ content: 'accepted' });
const execute = vi.spyOn(bashTool, 'execute').mockResolvedValue({ content: 'accepted' });
const tool = lt.createLocalTools({ userId: 'u1', cid: 'c1', hostPlatform: 'win32' }).find(t => t.name === toolName)!;
const outside = path.join(path.dirname(tmpDir), 'orkas-outside-target.txt');
const asks: any[] = [];
@@ -541,7 +540,7 @@ describe('local-tools › bash filesystem mutation scope', () => {
"New-Item -Path '.' -Name 'note.txt' -ItemType File -Value ok",
]) {
execute.mockClear();
const result = await tool.execute({ action: 'start', command }, makeCtx());
const result = await tool.execute({ command }, makeCtx());
expect(result.isError, result.content).toBeFalsy(); expect(execute).toHaveBeenCalledOnce();
}
expect(asks).toEqual([]);
@@ -550,7 +549,7 @@ describe('local-tools › bash filesystem mutation scope', () => {
`$base='${path.dirname(tmpDir)}'; $out=Join-Path $base 'orkas-outside-target.txt'; New-Item -Path $out -ItemType File`,
]) {
execute.mockClear();
const result = await tool.execute({ action: 'start', command }, makeCtx());
const result = await tool.execute({ command }, makeCtx());
expect(result.content).toContain('E_BASH_PATH_OUT_OF_SCOPE'); expect(execute).not.toHaveBeenCalled();
}
for (const command of [
@@ -562,7 +561,7 @@ describe('local-tools › bash filesystem mutation scope', () => {
"New-Item -Path 'HKCU:\\Software\\ReviewFixture'",
]) {
execute.mockClear();
const result = await tool.execute({ action: 'start', command }, makeCtx());
const result = await tool.execute({ command }, makeCtx());
expect(result.content).toContain('E_BASH_RISK_DENIED'); expect(execute).not.toHaveBeenCalled();
expect(asks.at(-1)).toMatchObject({ unresolved_paths: true, can_allow_run: false });
expect(asks.at(-1).key_facts).toContainEqual(expect.objectContaining({ kind: 'write', unresolved: true }));
@@ -827,7 +826,7 @@ describe('local-tools › bash filesystem mutation scope', () => {
expect(res.content).toContain('E_BASH_RISK_DENIED');
});
it.each(['bash', 'process_session'])(
it.each(['bash'])(
'rejects a known out-of-scope write mixed with an unresolved path before %s asks or executes',
async (toolName) => {
const { lt, perm } = await loadModules();
@@ -847,7 +846,7 @@ describe('local-tools › bash filesystem mutation scope', () => {
? `$target = Join-Path (Get-Location) "dynamic.txt"; Set-Content -LiteralPath "$target" -Value changed; Set-Content -LiteralPath ${powershellPathLiteral(outside, "'")} -Value changed`
: `target="$(pwd)/dynamic.txt"; printf changed > "$target"; printf changed > ${JSON.stringify(outside)}`;
const tool = lt.createLocalTools({ userId: 'u1', cid: 'c1' }).find(t => t.name === toolName)!;
const result = await tool.execute({ action: 'start', command }, makeCtx());
const result = await tool.execute({ command }, makeCtx());
expect(result.content).toContain('E_BASH_PATH_OUT_OF_SCOPE');
expect(result.isError).toBe(true);
expect(requests).toEqual([]);
@@ -1023,10 +1022,10 @@ describe('local-tools › bash filesystem mutation scope', () => {
'Copy-Item -Destination "target.txt" -Path "$source"',
'Copy-Item "source.txt" "$target" -ErrorAction Stop',
];
for (const name of ['bash', 'process_session']) {
for (const name of ['bash']) {
const tool = tools.find((candidate) => candidate.name === name)!;
for (const command of commands) {
const result = await tool.execute({ action: 'start', command }, makeCtx());
const result = await tool.execute({ command }, makeCtx());
expect(result.isError, `${name}: ${command}`).toBe(true);
expect(result.content).toContain('E_BASH_RISK_DENIED');
}
@@ -1245,7 +1244,6 @@ describe('local-tools › bash filesystem mutation scope', () => {
perm.setLocalExecMode('workspace_approval');
await setTmpWorkspace();
const { bashTool } = await import('../../../src/core-agent/src/tools/builtin');
const { processSessionTool } = await import('../../../src/core-agent/src/tools/process-session');
const coreTool = fixture.tool === 'bash' ? bashTool : processSessionTool;
const execute = vi.spyOn(coreTool, 'execute').mockResolvedValue({ content: 'shell accepted' });
try {
@@ -1295,21 +1293,19 @@ describe('local-tools › bash filesystem mutation scope', () => {
}
});
it('checks the latest compact PowerShell assignment against workspace scope through both shell tools', async () => {
it('checks the latest compact PowerShell assignment against workspace scope before bash executes', async () => {
const { lt, perm } = await loadModules();
perm.setLocalExecMode('workspace_approval');
await setTmpWorkspace();
const { bashTool } = await import('../../../src/core-agent/src/tools/builtin');
const { processSessionTool } = await import('../../../src/core-agent/src/tools/process-session');
const execute = vi.spyOn(bashTool, 'execute').mockResolvedValue({ content: 'must not execute' });
const start = vi.spyOn(processSessionTool, 'execute').mockResolvedValue({ content: 'must not execute' });
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'orkas-ps-assigned-outside-'));
const outside = path.join(outsideDir, 'blocked.txt');
try {
const tools = lt.createLocalTools({ userId: 'u1', cid: 'c1', hostPlatform: 'win32' });
for (const name of ['bash', 'process_session']) {
for (const name of ['bash']) {
const result = await tools.find((tool) => tool.name === name)!.execute({
action: 'start',
command: `$script = '${path.join(tmpDir, 'safe.txt')}'; $SCRIPT='${outside}'; Set-Content -LiteralPath $script -Value nope`,
}, makeCtx());
expect(result.isError).toBe(true);
@@ -1318,10 +1314,8 @@ describe('local-tools › bash filesystem mutation scope', () => {
expect(fs.existsSync(path.join(tmpDir, 'safe.txt'))).toBe(false);
}
expect(execute).not.toHaveBeenCalled();
expect(start).not.toHaveBeenCalled();
} finally {
execute.mockRestore();
start.mockRestore();
fs.rmSync(outsideDir, { recursive: true, force: true });
}
});
@@ -1422,8 +1416,8 @@ describe('local-tools › bash filesystem mutation scope', () => {
const command = `Set-Content -LiteralPath ${powershellPathLiteral(marker, "'")} -Value started; $script=${powershellPathLiteral(original, "'")}; ${assignment}; Set-Content -LiteralPath $script -Value changed`;
const tools = lt.createLocalTools({ userId: 'u1', cid: 'c1' });
try {
for (const name of ['bash', 'process_session']) {
const input = name === 'bash' ? { command } : { action: 'start', command };
for (const name of ['bash']) {
const input = { command };
const result = await tools.find((tool) => tool.name === name)!.execute(input, { ...makeCtx(), workingDir: workspace });
expect(result.isError).toBe(true);
expect(result.content).toContain(kind === 'outside' ? 'E_BASH_PATH_OUT_OF_SCOPE' : 'E_BASH_RISK_DENIED');
@@ -2206,7 +2200,7 @@ describe('local-tools › bash sensitive approval modes (e2e)', () => {
},
);
it.each(['process_session', 'interactive_cli'])(
it.each(['bash', 'interactive_cli'])(
'%s performs ordinary empty-directory cleanup through the shared permission gate', async (toolName) => {
const { lt, perm, bashPerms } = await loadWithBashPerms();
perm.setLocalExecMode('workspace_approval');
@@ -2222,19 +2216,19 @@ describe('local-tools › bash sensitive approval modes (e2e)', () => {
let sessionId: string | undefined;
try {
const command = process.platform === 'win32' ? 'rd -LiteralPath empty' : 'rmdir empty';
const started = await tool.execute({ action: 'start', command }, makeCtx());
const started = await tool.execute(toolName === 'interactive_cli' ? { action: 'start', command } : { command }, makeCtx());
expect(started.isError, started.content).toBeFalsy();
sessionId = JSON.parse(started.content).session_id;
if (toolName === 'interactive_cli') sessionId = JSON.parse(started.content).session_id;
await vi.waitFor(() => expect(fs.existsSync(dir)).toBe(false));
expect(prompted).toBe(false);
} finally {
if (sessionId) await tool.execute({ action: toolName === 'process_session' ? 'stop' : 'close', session_id: sessionId }, makeCtx());
if (sessionId) await tool.execute({ action: 'close', session_id: sessionId }, makeCtx());
bashPerms._setBroadcastForTest(null);
}
},
);
it.runIf(process.platform !== 'win32').each(['process_session', 'interactive_cli'])(
it.runIf(process.platform !== 'win32').each(['bash', 'interactive_cli'])(
'%s removes a workspace link without authorizing deletion of its shared target', async (toolName) => {
const { lt, perm, bashPerms } = await loadWithBashPerms();
perm.setLocalExecMode('workspace_approval');
@@ -2251,21 +2245,21 @@ describe('local-tools › bash sensitive approval modes (e2e)', () => {
const tool = lt.createLocalTools(OPTS).find(t => t.name === toolName)!;
let sessionId: string | undefined;
try {
const started = await tool.execute({ action: 'start', command: 'rm node_modules' }, makeCtx());
const started = await tool.execute(toolName === 'interactive_cli' ? { action: 'start', command: 'rm node_modules' } : { command: 'rm node_modules' }, makeCtx());
expect(started.isError, started.content).toBeFalsy();
sessionId = JSON.parse(started.content).session_id;
if (toolName === 'interactive_cli') sessionId = JSON.parse(started.content).session_id;
await vi.waitFor(() => expect(fs.existsSync(link)).toBe(false));
expect(prompted).toBe(false);
expect(fs.readFileSync(path.join(donor, 'keep.txt'), 'utf8')).toBe('unchanged');
} finally {
if (sessionId) await tool.execute({ action: toolName === 'process_session' ? 'stop' : 'close', session_id: sessionId }, makeCtx());
if (sessionId) await tool.execute({ action: 'close', session_id: sessionId }, makeCtx());
bashPerms._setBroadcastForTest(null);
fs.rmSync(donor, { recursive: true, force: true });
}
},
);
it.each(['interactive_cli', 'process_session'])(
it.each(['interactive_cli', 'bash'])(
'prompts before %s changes a system package even in all_files_auto',
async (toolName) => {
const { lt, perm, bashPerms } = await loadWithBashPerms();
@@ -2287,7 +2281,7 @@ describe('local-tools › bash sensitive approval modes (e2e)', () => {
},
);
it.each(['interactive_cli', 'process_session'])(
it.each(['interactive_cli', 'bash'])(
'prompts before %s publishes remote state even in all_files_auto',
async (toolName) => {
const { lt, perm, bashPerms } = await loadWithBashPerms();
@@ -2318,7 +2312,7 @@ describe('local-tools › bash sensitive approval modes (e2e)', () => {
: `rm ${recursive ? '-rf' : '-f'} ${targets.map((target) => JSON.stringify(target)).join(' ')}`
);
it.each(['interactive_cli', 'process_session'])
it.each(['interactive_cli', 'bash'])
('lets %s delete an explicit non-recursive workspace file without prompting', async (toolName) => {
const { lt, perm, bashPerms } = await loadWithBashPerms();
perm.setLocalExecMode('workspace_approval');
@@ -2329,7 +2323,7 @@ describe('local-tools › bash sensitive approval modes (e2e)', () => {
bashPerms._setBroadcastForTest(() => { prompted = true; });
try {
const tool = lt.createLocalTools(OPTS).find((item) => item.name === toolName)!;
const res = await tool.execute({ action: 'start', command: deleteCommand([target]) }, makeCtx());
const res = await tool.execute(toolName === 'interactive_cli' ? { action: 'start', command: deleteCommand([target]) } : { command: deleteCommand([target]) }, makeCtx());
expect(res.isError, `content=${res.content}`).toBeFalsy();
await vi.waitFor(() => expect(fs.existsSync(target)).toBe(false));
expect(prompted).toBe(false);
@@ -2339,7 +2333,7 @@ describe('local-tools › bash sensitive approval modes (e2e)', () => {
}
});
it.each(['bash', 'process_session', 'interactive_cli', 'run_program'])(
it.each(['bash', 'interactive_cli', 'run_program'])(
'holds %s dynamic removal until approval and preserves files on cancellation',
async (toolName) => {
const { lt, perm, bashPerms } = await loadWithBashPerms();
@@ -2356,7 +2350,7 @@ describe('local-tools › bash sensitive approval modes (e2e)', () => {
});
const tool = lt.createLocalTools(OPTS).find(item => item.name === (toolName === 'run_program' ? 'bash' : toolName))!;
const execute = async () => {
if (toolName !== 'run_program') return tool.execute({ action: 'start', command }, makeCtx());
if (toolName !== 'run_program') return tool.execute(toolName === 'interactive_cli' ? { action: 'start', command } : { command }, makeCtx());
const { createRunProgramTool } = await import('../../../src/core-agent/src/tools/run-program');
const { createProgrammaticToolPolicy } = await import('../../../src/main/model/core-agent/programmatic-tool-policy');
const policy = createProgrammaticToolPolicy({ userId: 'u1' });
@@ -2413,7 +2407,7 @@ describe('local-tools › bash sensitive approval modes (e2e)', () => {
['all_files_approval', true],
['all_files_auto', false],
] as const)(
'applies the unresolved-path boundary before process_session starts in %s',
'applies the unresolved-path boundary before bash starts in %s',
async (mode, shouldReject) => {
const { lt, perm } = await loadWithBashPerms();
perm.setLocalExecMode(mode);
@@ -2422,9 +2416,9 @@ describe('local-tools › bash sensitive approval modes (e2e)', () => {
const command = process.platform === 'win32'
? `$target = Join-Path (Get-Location) ${JSON.stringify(path.basename(target))}; Set-Content -LiteralPath "$target" -Value allowed`
: `target="$(pwd)/${path.basename(target)}"; printf allowed > "$target"`;
const processTool = lt.createLocalTools(OPTS).find((item) => item.name === 'process_session')!;
const processTool = lt.createLocalTools(OPTS).find((item) => item.name === 'bash')!;
const res = await processTool.execute({ action: 'start', command }, makeCtx());
const res = await processTool.execute({ command }, makeCtx());
if (shouldReject) {
expect(res.isError).toBe(true);
@@ -3185,7 +3179,7 @@ describe('local-tools › bash sensitive approval modes (e2e)', () => {
} finally { bashPerms._setBroadcastForTest(null); }
});
it.each(['bash', 'process_session', 'bash-inline', ...(process.platform === 'win32' ? [] : ['bash-heredoc'])])('runs SQLite workspace verification through %s without an external-mutation prompt', async (toolName) => {
it.each(['bash', 'bash-inline', ...(process.platform === 'win32' ? [] : ['bash-heredoc'])])('runs SQLite workspace verification through %s without an external-mutation prompt', async (toolName) => {
const { lt, perm, bashPerms } = await loadWithBashPerms();
const { bundledRuntimeEnv } = await import('../../../src/main/util/bundled-runtime');
const python = bundledRuntimeEnv().ORKAS_PYTHON;
@@ -3213,17 +3207,9 @@ describe('local-tools › bash sensitive approval modes (e2e)', () => {
const source = fs.readFileSync(script, 'utf8');
const command = toolName === 'bash-inline' ? `${executable} -c ${quote(source)}`
: toolName === 'bash-heredoc' ? `${executable} - <<'PY'\n${source}\nPY` : `${executable} ${quote(script)}`;
const tool = lt.createLocalTools(OPTS).find(t => t.name === (toolName === 'process_session' ? toolName : 'bash'))!;
let result = await tool.execute(toolName !== 'process_session' ? { command, timeoutMs: SHELL_SUCCESS_TIMEOUT_MS } : { action: 'start', command }, makeCtx());
const tool = lt.createLocalTools(OPTS).find(t => t.name === 'bash')!;
const result = await tool.execute({ command, timeoutMs: SHELL_SUCCESS_TIMEOUT_MS }, makeCtx());
expect(result.isError, String(result.content)).toBeFalsy();
if (toolName === 'process_session') {
const session = JSON.parse(result.content).session_id;
await vi.waitFor(async () => {
result = await tool.execute({ action: 'read', session_id: session }, makeCtx());
expect(result.isError, result.content).toBeFalsy();
expect(JSON.parse(result.content).status).not.toBe('running');
}, { timeout: SHELL_SUCCESS_TIMEOUT_MS });
}
expect(result.content).toContain('42');
expect(fs.existsSync(path.join(tmpDir, 'verification.db'))).toBe(true);
expect(prompts).toBe(0);
@@ -3389,9 +3375,16 @@ fs.writeFileSync(args[at + 1], 'downloaded');
`;
writeFakeCommand(fakeBin, 'curl', downloadScript);
writeFakeCommand(fakeBin, 'wget', downloadScript);
// Preserve AllScope when binding PowerShell's inherited aliases to the offline shims.
const bindDownloaders = process.platform === 'win32'
? ['curl', 'wget'].map((name) => (
`Set-Alias -Name ${name} -Value ${powershellPathLiteral(path.join(fakeBin, `${name}.cmd`), "'")} -Scope Local -Option AllScope\n`
)).join('')
: '';
const res = await bash.execute({
command:
bindDownloaders +
'curl -o downloaded.txt https://example.com/downloaded.txt\n' +
'wget -O fetched.json https://example.com/fetched.json\n' +
'node -e "require(\'fs\').writeFileSync(process.env.ORKAS_OUTPUT_DIR + \'/generated.md\', \'# ok\')"',
@@ -3399,6 +3392,8 @@ fs.writeFileSync(args[at + 1], 'downloaded');
}, makeCtx({ PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}` }));
expect(res.isError, `content=${res.content}`).toBeFalsy();
expect(fs.readFileSync(path.join(tmpDir, 'downloaded.txt'), 'utf8')).toBe('downloaded');
expect(fs.readFileSync(path.join(tmpDir, 'fetched.json'), 'utf8')).toBe('downloaded');
const produced = new Set(onFileWritten.mock.calls.map(([p]) => p));
expect(produced).toContain(path.join(tmpDir, 'generated.md'));
expect(produced).not.toContain(path.join(tmpDir, 'downloaded.txt'));
@@ -3873,7 +3868,7 @@ describe('local-tools › append_file', () => {
}, ctx);
expect(missingBaseline.isError).toBe(true);
expect(missingBaseline.content).toContain('E_BAD_INPUT');
expect(missingBaseline.observations?.fileFailure).toMatchObject({ reason: 'append_precondition', revision_provided: false, size_provided: false, size_valid: false });
expect(missingBaseline.observations?.fileFailure).toMatchObject({ reason: 'append_precondition', revision_provided: false });
expect(fs.readFileSync(absolutePath, 'utf8')).toBe('kept');
expect(onFileWritten).toHaveBeenCalledTimes(1);
});
@@ -3892,19 +3887,19 @@ describe('local-tools › append_file', () => {
const write = tools.find((t) => t.name === 'write_file')!;
const append = tools.find((t) => t.name === 'append_file')!;
const abs = path.join(tmpDir, 'out', 'large.md');
const ctx = makeCtx();
const first = 'alpha\n';
const next = '第二块\n';
const written = await write.execute({ path: 'out/large.md', content: first }, makeCtx());
const written = await write.execute({ path: 'out/large.md', content: first }, ctx);
expect(written.isError).toBeFalsy();
const sizeMatch = written.content.match(/total_bytes="(\d+)"/);
expect(sizeMatch).not.toBeNull();
const expectedSize = Number(sizeMatch?.[1]);
const revision = /revision="(file_rev_[A-Za-z0-9_-]{16})"/.exec(written.content)?.[1];
expect(revision).toBeTruthy();
const appended = await append.execute({
path: 'out/large.md',
content: next,
expected_size: expectedSize,
}, makeCtx());
base_revision: revision,
}, ctx);
expect(appended.isError).toBeFalsy();
expect(appended.content).toContain('replayed="false"');
expect(appended.content).toContain(`total_bytes="${Buffer.byteLength(first + next, 'utf8')}"`);
@@ -3912,8 +3907,8 @@ describe('local-tools › append_file', () => {
const replayed = await append.execute({
path: 'out/large.md',
content: next,
expected_size: expectedSize,
}, makeCtx());
base_revision: revision,
}, ctx);
expect(replayed.isError).toBeFalsy();
expect(replayed.content).toContain('replayed="true"');
expect(fs.readFileSync(abs, 'utf8')).toBe(first + next);
@@ -3921,8 +3916,8 @@ describe('local-tools › append_file', () => {
const stale = await append.execute({
path: 'out/large.md',
content: 'different',
expected_size: expectedSize,
}, makeCtx());
base_revision: revision,
}, ctx);
expect(stale.isError).toBe(true);
expect(stale.content).toContain('E_STALE');
expect(fs.readFileSync(abs, 'utf8')).toBe(first + next);
@@ -3965,9 +3960,6 @@ describe('local-tools › append_file', () => {
path: relativePath,
content: secondChunk,
base_revision: writtenRevision,
// Deliberately repeat the case bug: this is a UTF-16 character count,
// not the UTF-8 byte size. The opaque revision must be authoritative.
expected_size: firstChunk.length,
}, ctx);
expect(appended.isError).toBeFalsy();
expect(appended.content).toContain('replayed="false"');
@@ -4058,7 +4050,7 @@ describe('local-tools › append_file', () => {
type: 'string',
pattern: '^file_rev_[A-Za-z0-9_-]{16}$',
});
expect(append.inputSchema.required).toEqual(['path', 'content']);
expect(append.inputSchema.required).toEqual(['path', 'content', 'base_revision']);
const firstChunk = 'header\n';
const appendCases = [
@@ -4081,6 +4073,7 @@ describe('local-tools › append_file', () => {
expect(revision).toBeTruthy();
const originalRevision = revision;
let oracleBody = firstChunk;
for (const [index, appendCase] of appendCases.entries()) {
const { content: chunk } = appendCase;
expect(chunk.length, appendCase.name).toBeGreaterThanOrEqual(12_000);
@@ -4088,11 +4081,18 @@ describe('local-tools › append_file', () => {
path: relativePath,
content: chunk,
base_revision: revision,
// base_revision is authoritative even when a caller repeats the old
// character-count/byte-count mistake from the historical case.
...(index === 2 ? { expected_size: 1 } : {}),
}, ctx);
expect(result.isError, appendCase.name).toBeFalsy();
const beforeBytes = Buffer.from(oracleBody, 'utf8');
oracleBody += chunk;
const afterBytes = Buffer.from(oracleBody, 'utf8');
expect(result.observations?.fileChanges, appendCase.name).toEqual([{
operation: 'update', sourcePath: absolutePath, beforeExists: true, afterExists: true,
beforeHash: `sha256:${createHash('sha256').update(beforeBytes).digest('hex')}`,
afterHash: `sha256:${createHash('sha256').update(afterBytes).digest('hex')}`,
beforeBytes: beforeBytes.length, afterBytes: afterBytes.length, coverage: 'exact',
}]);
expect(fs.readFileSync(absolutePath), appendCase.name).toEqual(afterBytes);
expectedBytes += Buffer.byteLength(chunk, 'utf8');
expect(result.content).toContain(`total_bytes="${expectedBytes}"`);
expect(result.content).toContain('replayed="false"');
@@ -4132,53 +4132,24 @@ describe('local-tools › append_file', () => {
expect(onFileWritten).toHaveBeenCalledTimes(1 + appendCases.length);
});
it('keeps legacy expected_size compatible for large appends, including replay and stale recovery', async () => {
it('rejects retired byte-size preconditions, including mixed calls, without writing', async () => {
const { lt, perm } = await loadModules();
perm.setLocalExecMode('all_files_auto');
await setTmpWorkspace();
const onFileWritten = vi.fn();
const tools = lt.createLocalTools({ userId: 'u1', onFileWritten });
const write = tools.find((tool) => tool.name === 'write_file')!;
const append = tools.find((tool) => tool.name === 'append_file')!;
const relativePath = 'out/legacy-large.txt';
const absolutePath = path.join(tmpDir, relativePath);
const firstChunk = '初始\n';
const largeChunk = '界🙂'.repeat(5_000);
const write = tools.find(tool => tool.name === 'write_file')!;
const append = tools.find(tool => tool.name === 'append_file')!;
const ctx = makeCtx();
const first = await write.execute({ path: relativePath, content: firstChunk }, ctx);
const expectedSize = Buffer.byteLength(firstChunk, 'utf8');
expect(first.isError).toBeFalsy();
expect(largeChunk.length).toBeGreaterThan(12_000);
const appended = await append.execute({
path: relativePath,
content: largeChunk,
expected_size: expectedSize,
}, ctx);
expect(appended.isError).toBeFalsy();
expect(appended.content).toContain('replayed="false"');
const expectedBody = firstChunk + largeChunk;
expect(fs.readFileSync(absolutePath, 'utf8')).toBe(expectedBody);
const replayed = await append.execute({
path: relativePath,
content: largeChunk,
expected_size: expectedSize,
}, ctx);
expect(replayed.isError).toBeFalsy();
expect(replayed.content).toContain('replayed="true"');
expect(fs.readFileSync(absolutePath, 'utf8')).toBe(expectedBody);
const stale = await append.execute({
path: relativePath,
content: 'stale'.repeat(3_000),
expected_size: expectedSize,
}, ctx);
expect(stale.isError).toBe(true);
expect(stale.content).toContain('E_STALE');
expect(fs.readFileSync(absolutePath, 'utf8')).toBe(expectedBody);
expect(onFileWritten).toHaveBeenCalledTimes(2);
const written = await write.execute({ path: 'retired.txt', content: 'kept' }, ctx);
const revision = /revision="(file_rev_[A-Za-z0-9_-]{16})"/.exec(written.content)?.[1];
for (const extra of [{}, { base_revision: revision }]) {
const result = await append.execute({ path: 'retired.txt', content: '界🙂'.repeat(5000), expected_size: 4, ...extra }, ctx);
expect(result.isError).toBe(true);
expect(result.content).toContain('expected_size is unsupported');
expect(fs.readFileSync(path.join(tmpDir, 'retired.txt'), 'utf8')).toBe('kept');
}
expect(onFileWritten).toHaveBeenCalledTimes(1);
});
});
+207 -28
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { expectTestLog } from '../../helpers/test-log-analysis';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import * as os from 'node:os';
import * as path from 'node:path';
import { captureMainLogWorkers } from '../../helpers/capture-main-log-workers';
@@ -47,6 +48,7 @@ beforeEach(async () => {
});
afterEach(async () => {
vi.doUnmock('../../../src/main/features/auth');
await (await import('../../../src/main/features/search/indexer')).flushAll();
await closeLogWorkers();
vi.doUnmock('@earendil-works/pi-ai/oauth');
@@ -68,6 +70,33 @@ async function loadRunner() {
}
describe('runner › buildRunner auth gate', () => {
it('forwards the caller failure observer through its real rotating provider without adding model metadata', async () => {
const failure = { code: 'rate_limit_error', phase: 'after_response', elapsedMs: 12,
httpStatus: 429, aborted: false, source: 'http', lastEvent: 'none', requestSequence: 1 };
const createPiProvider = vi.fn(() => ({
id: 'custom', name: 'Diagnostic fixture',
async *stream(params: any) {
params.onRequestFailure?.(failure);
yield { type: 'text_delta' as const, text: 'ok' };
yield { type: 'message_end' as const, model: 'fixture-alias', stopReason: 'end_turn' as const };
},
complete: vi.fn(), validateAuth: vi.fn(),
}));
vi.doMock('#core-agent', async () => ({ ...(await vi.importActual<any>('#core-agent')), createPiProvider }));
const uid = 'runner-local-diagnostic';
(await import('../../../src/main/features/users')).activateUser(uid);
const auth = await import('../../../src/main/features/auth');
await auth.addCustomModelEntry({ protocol: 'anthropic', baseUrl: 'https://gateway.example.test', model: 'fixture-alias',
apiKey: 'synthetic-diagnostic-key', contextWindow: 200000, maxTokens: 8192 });
const onRequestFailure = vi.fn();
const { buildRunner } = await loadRunner();
const built = await buildRunner({ sessionId: 'gconv-local-diagnostic', userId: uid, onRequestFailure });
const provider = (built.runner as any).providers.get('custom');
for await (const _ of provider.stream({ messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }] })) { }
expect(onRequestFailure).toHaveBeenCalledExactlyOnceWith(failure);
expect(built.resolvedSystemPrompt).not.toContain('rate_limit_error');
});
it('propagates custom Anthropic metadata to the runner provider without catalog lookup', async () => {
const createPiProvider = vi.fn(({ customModel }: any) => ({
id: 'custom', name: 'Protocol fixture',
@@ -491,12 +520,15 @@ describe('runner › buildRunner auth gate', () => {
const users = await import('../../../src/main/features/users');
users.activateUser('runner-reasoning-parity');
const { clientConfig } = await import('../../../src/main/features/client_config');
// Exercise the real SDK wire with a model it supports. Advertising only a
// retired ID prevents auth admission before the reasoning oracle runs.
const modelId = 'gpt-6.1-sol';
clientConfig.applyServerPayload({ immediate: { model_catalog: { providers: {
'openai-codex': [{ id: 'gpt-5.5', name: 'GPT-5.5' }],
'openai-codex': [{ id: modelId, name: 'Reasoning wire fixture' }],
} } } }, 'reasoning-parity-fixture');
const token = Buffer.from(JSON.stringify({ 'https://api.openai.com/auth': { chatgpt_account_id: 'fixture' } })).toString('base64url');
const auth = await import('../../../src/main/features/auth');
await auth.addApiKeyEntry('openai-codex', 'gpt-5.5', `fixture.${token}.fixture`, 'Fixture');
await auth.addApiKeyEntry('openai-codex', modelId, `fixture.${token}.fixture`, 'Fixture');
const { buildRunner } = await loadRunner();
const built = await buildRunner({ sessionId: 'gconv-reasoning-parity', userId: 'runner-reasoning-parity' });
const provider = (built.runner as any).providers.get('openai-codex');
@@ -511,6 +543,7 @@ describe('runner › buildRunner auth gate', () => {
expect(fetchStub).toHaveBeenCalledTimes(2);
} finally { vi.unstubAllGlobals(); }
expect(payloads).toHaveLength(2);
expect(payloads.map(payload => payload.model)).toEqual([modelId, modelId]);
expect(payloads[0].reasoning?.effort).toBeUndefined();
expect(payloads[1].reasoning?.effort).toBe('low');
});
@@ -595,6 +628,45 @@ describe('runner › buildRunner auth gate', () => {
});
});
// Exhausting the user-configured candidates reports the public recovery result.
describe('runner › all configured models rate limited', () => {
it('reports exhaustion of the user-configured model list', async () => {
const createPiProvider = vi.fn(() => ({
id: 'acme', name: 'Acme AI',
async *stream() { throw Object.assign(new Error('Too Many Requests'), { status: 429 }); },
async complete() { throw new Error('not used'); },
async validateAuth() { return true; },
}));
vi.doMock('#core-agent', async () => ({ ...(await vi.importActual<any>('#core-agent')), createPiProvider }));
const uid = 'runner-all-throttled';
const users = await import('../../../src/main/features/users');
const { clientConfig } = await import('../../../src/main/features/client_config');
users.activateUser(uid);
clientConfig.applyServerPayload({
immediate: { model_catalog: {
providers: { acme: [{ id: 'acme-free', name: 'Acme Free', contextWindow: 262_144, maxTokens: 16_384 }] },
openai_compatible_providers: { acme: { label: 'Acme AI', baseUrl: 'https://api.acme.example/v1' } },
} },
restart: {},
config_hash: `sha256:${uid}`,
}, `"${uid}"`);
const auth = await import('../../../src/main/features/auth');
await auth.addApiKeyEntry('acme', 'acme-free', 'sk-acme-free', 'Acme free');
const { buildRunner } = await loadRunner();
const { ALL_CONFIGURED_RATE_LIMITED_MESSAGE } =
await import('../../../src/main/model/core-agent/rotating-provider');
const built = await buildRunner({ sessionId: `gconv-${uid}`, userId: uid });
const provider = (built.runner as any).providers.get('acme');
const drain = async () => { for await (const _ of provider.stream({ model: built.modelId,
messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }] })) { /* drain */ } };
await expect(drain()).rejects.toMatchObject({
code: 'PROVIDER_RATE_LIMIT_EXHAUSTED',
message: ALL_CONFIGURED_RATE_LIMITED_MESSAGE,
});
});
});
describe('runner › metacognition closed loop', () => {
it('injects the originating account assessment read-only after the active account changes', async () => {
process.env.ANTHROPIC_API_KEY = 'sk-ant-test-placeholder';
@@ -749,6 +821,58 @@ describe('runner › generic host capabilities for built-in Agents', () => {
});
describe('runner › scoped tool loading', () => {
it.each(['gconv', 'gmember', 'gworker'])('starts %s with an explicit unavailable-project status and recovers the next turn', async kind => {
const uid = 'instructions-read-actor';
await configureUser(uid);
const projects = await import('../../../src/main/features/projects');
const created = await projects.createProject(uid, 'Project');
if (!created.ok) throw new Error('project fixture failed');
const pid = created.project.project_id;
const rule = 'Keep public APIs stable.';
await projects.writeProjectInstructions(uid, pid, rule);
const file = path.join(tmpDir, uid, 'cloud', 'projects', pid, 'ORKAS.md');
const originalRead = fs.readFileSync;
const nativeFs = (await import('node:fs')).default;
const read = vi.spyOn(nativeFs, 'readFileSync').mockImplementation(((target: any, ...args: any[]) => {
if (String(target) === file) throw Object.assign(new Error('injected private read failure'), { code: 'EIO' });
return (originalRead as any)(target, ...args);
}) as any);
syncBuiltinESMExports();
const { buildRunner } = await loadRunner();
const options = { userId: uid, projectId: pid, systemPrompt: 'Role.\n\n## Runtime injection\nRuntime facts.',
...(kind !== 'gconv' ? { agentId: 'named-agent', toolList: [] } : {}) };
let unavailableSurfaceChars = 0;
try {
expectTestLog({ level: 'warn', source: 'projects', message: /project instructions unavailable for prompt/ });
const built = await buildRunner({ ...options, sessionId: `${kind}-instructions-unavailable` });
expect(built.runner).toBeDefined();
const prompt = built.resolvedSystemPrompt;
expect(prompt).toContain('Status: unavailable (read_failed)');
expect(prompt).toContain('Continue the task');
expect(prompt.match(/## Project instructions \(user-authored\)/g)).toHaveLength(1);
expect(prompt.indexOf('Status: unavailable')).toBeLessThan(prompt.indexOf('## Runtime injection'));
expect(prompt).not.toContain(rule);
expect(prompt).not.toContain(file);
expect(prompt).not.toContain('injected private read failure');
// Combined host prompt + active tool definitions, measured at the actual
// build boundary; the conditional status replaces a <=4K instruction body.
expect(prompt.length + JSON.stringify(built.toolDefs).length).toBeLessThan(160_000);
unavailableSurfaceChars = prompt.length + JSON.stringify(built.toolDefs).length;
} finally { read.mockRestore(); syncBuiltinESMExports(); }
const recovered = await buildRunner({ ...options, sessionId: `${kind}-instructions-recovered` });
expect(recovered.resolvedSystemPrompt).toContain(rule);
expect(recovered.resolvedSystemPrompt).not.toContain('Status: unavailable');
await projects.writeProjectInstructions(uid, pid, 'x'.repeat(projects.PROJECT_INSTRUCTIONS_CHAR_LIMIT));
const maximal = await buildRunner({ ...options, sessionId: `${kind}-instructions-maximal` });
const maximalSurfaceChars = maximal.resolvedSystemPrompt.length + JSON.stringify(maximal.toolDefs).length;
expect(maximalSurfaceChars).toBeLessThan(160_000);
expect(unavailableSurfaceChars).toBeLessThan(maximalSurfaceChars);
const unrelated = await buildRunner({ ...options, projectId: undefined, sessionId: `${kind}-no-project` });
expect(unrelated.resolvedSystemPrompt).not.toContain('## Project instructions');
const edit = await buildRunner({ ...options, sessionId: 'agent-instructions-absence' });
expect(edit.resolvedSystemPrompt).not.toContain('## Project instructions');
});
it.each(['gconv', 'gmember'])('allows %s to update current-project context and create bound tasks', async (kind) => {
process.env.ORKAS_TOOL_LOADING_MODE = 'scoped';
const uid = 'project-context-parity';
@@ -875,10 +999,17 @@ describe('runner › scoped tool loading', () => {
expect(result.isError).toBe(kind !== 'gconv');
}
expect(memory.listEntries(uid, scope).entries).toEqual([kind === 'gconv' ? 'Corrected global fact' : 'Original global fact']);
const current = memory.listEntries(uid, scope);
expect((await actor('cross_session_memory', { action: 'batch', target, expected_revision: current.revision,
operations: [{ action: 'replace', old_text: current.entries[0], content: current.entries[0] }] })).isError).toBe(kind !== 'gconv');
}
for (const target of inProject ? ['agent', 'project'] : ['agent']) {
expect((await actor('cross_session_memory', { action: 'add', target, content: 'Local reusable fact' })).isError).toBe(false);
expect(JSON.parse((await actor('cross_session_memory', { action: 'list', target })).content).entries).toEqual(['Local reusable fact']);
const snapshot = JSON.parse((await actor('cross_session_memory', { action: 'list', target })).content);
expect((await actor('cross_session_memory', { action: 'batch', target, expected_revision: snapshot.revision,
operations: [{ action: 'replace', old_text: 'Local reusable fact', content: 'Updated local fact' }, { action: 'add', content: 'Another local fact' }] })).isError).toBe(false);
expect(JSON.parse((await actor('cross_session_memory', { action: 'list', target })).content).entries).toEqual(['Updated local fact', 'Another local fact']);
}
if (!inProject) expect((await actor('cross_session_memory', { action: 'list', target: 'project' })).isError).toBe(true);
});
@@ -1014,12 +1145,17 @@ describe('runner › scoped tool loading', () => {
expect(prompt.match(/\{"schema_version":1,"root":\{"type":"Stack"/g)).toHaveLength(1);
expect(prompt).toContain('**`create_artifact`** is a sandboxed app');
expect(prompt.indexOf('**`:::dashboard`**')).toBeLessThan(prompt.indexOf('## Runtime injection'));
const handoff = 'If the remaining step needs the user in a supported app view, pair concise instructions with its entry card.';
expect(prompt).toContain(handoff);
expect(prompt.indexOf(handoff)).toBeLessThan(prompt.indexOf('## Runtime injection'));
});
it.each([
{ agentId: CONTENT_WRITER_AGENT_ID, maximal: false, outputFormat: undefined },
{ agentId: VIDEO_STUDIO_AGENT_ID, maximal: true, outputFormat: undefined },
{ agentId: CONTENT_WRITER_AGENT_ID, maximal: false, outputFormat: 'text' },
{ agentId: CONTENT_WRITER_AGENT_ID, maximal: false, outputFormat: 'markdown_only' },
{ agentId: VIDEO_STUDIO_AGENT_ID, maximal: false, outputFormat: 'artifact' },
])('keeps progress guidance in the assembled Agent surface ($agentId, maximal=$maximal, output=$outputFormat)', async ({ agentId, maximal, outputFormat }) => {
const uid = `runner-progress-surface-${agentId}`;
await configureUser(uid);
@@ -1040,7 +1176,8 @@ describe('runner › scoped tool loading', () => {
fs.mkdirSync(dest, { recursive: true });
fs.copyFileSync(manifest, path.join(dest, 'SKILL.md'));
}
const base = await _buildAgentInGroupSystemPromptForTest({ ...agent, output_format: outputFormat ?? agent.output_format }, tmpDir, 'en');
const effectiveOutputFormat = outputFormat ?? agent.output_format;
const base = await _buildAgentInGroupSystemPromptForTest({ ...agent, output_format: effectiveOutputFormat }, tmpDir, 'en');
const { buildRunner } = await loadRunner();
const built = await buildRunner({
sessionId: `gmember-progress-surface-${agentId}`, userId: uid, agentId,
@@ -1049,8 +1186,9 @@ describe('runner › scoped tool loading', () => {
toolList: maximal ? [...AGENT_FALLBACK_TOOL_GROUP_IDS] : agent.tool_list,
});
const prompt = built.resolvedSystemPrompt;
expect(prompt.includes('**`:::dashboard`**')).toBe(outputFormat !== 'text');
expect(prompt.includes('**`create_artifact`** is a sandboxed app')).toBe(outputFormat !== 'text');
const richOutput = effectiveOutputFormat !== 'text' && effectiveOutputFormat !== 'markdown_only';
expect(prompt.includes('**`:::dashboard`**')).toBe(richOutput);
expect(prompt.includes('**`create_artifact`** is a sandboxed app')).toBe(richOutput);
expect(prompt.match(/These updates are not final replies/g)).toHaveLength(1);
expect(prompt).not.toContain('before the first tool call');
expect(prompt).not.toContain('Once you output, your turn is done.');
@@ -1063,13 +1201,9 @@ describe('runner › scoped tool loading', () => {
const toolChars = JSON.stringify(built.toolDefs).length;
const combined = prompt.length + built.turnEphemeral.length + toolChars;
console.log(`[agent-effective-progress-surface] ${agent.name} output=${outputFormat ?? agent.output_format ?? 'auto'}: system=${prompt.length}, ephemeral=${built.turnEphemeral.length}, tools=${toolChars}, combined=${combined}`);
// The merged action-specific tool contracts add 5,539 / 6,995 schema
// chars, while removing the active-group tail saves 162 / 382 chars.
// With the same OfficeCLI fixture, resident prompts remain unchanged:
// totals move from 38,370 / 91,255 to 43,747 / 97,868. Keep comparable
// headroom; the independent tool and resident-prompt budgets still apply.
// Global task and automation schemas are now eligible in the maximal explicit surface (+5.7k).
expect(combined).toBeLessThanOrEqual(maximal ? 106_000 : 45_500);
// Preserve this historical character measurement, including ephemeral and
// maximal explicit loading. The uniform default-initial token acceptance
// lives in agent-initial-overhead.test.ts and excludes those later controls.
});
it('defers unbound Commander project tasks and exposes bound reads without injecting backlog records', async () => {
@@ -1098,7 +1232,7 @@ describe('runner › scoped tool loading', () => {
expect(nonProject.resolvedSystemPrompt).not.toContain('**todo-tasks**');
expect(nonProject.toolDefs.map((tool) => tool.name)).not.toContain('todo_tasks');
expect(nonProject.resolvedSystemPrompt).toContain('`management.projects`');
expect(nonProject.resolvedSystemPrompt).toContain('`management.todos`');
expect(nonProject.resolvedSystemPrompt).toContain('`todo_tasks` —');
expect(nonProject.turnEphemeral).not.toContain('## Project status');
@@ -1171,7 +1305,8 @@ describe('runner › scoped tool loading', () => {
expect(built.turnEphemeral).toMatch(/explain that this non-project conversation cannot access them/);
expect(built.turnEphemeral.match(/## Global to-do scope/g)).toHaveLength(1);
const context = { workingDir: tmpDir, state: {} };
expect((await tools.get('tool_load').execute({ groups: ['management.projects'] }, context)).isError).toBeFalsy();
expect((await tools.get('tool_load').execute({ groups: ['management.todos'] }, context)).isError).toBeFalsy();
expect(built.runner.getActiveToolDefinitions().map(tool => tool.name)).not.toContain('project_setup');
const tool = tools.get('todo_tasks');
expect(tool.inputSchema.properties.project.enum).toEqual(['__global__']);
expect(tool.inputSchema.properties.action.enum).not.toContain('list_projects');
@@ -1206,6 +1341,26 @@ describe('runner › scoped tool loading', () => {
names.mockRestore();
});
it('keeps shared member discovery absent from each public global and private turn', async () => {
const uid = 'runner-todo-local-scope';
await configureUser(uid);
const projects = await import('../../../src/main/features/projects');
const created = await projects.createProject(uid, 'Member assignment');
if (!created.ok) throw new Error('project fixture failed');
const projectId = created.project.project_id;
const { buildRunner } = await loadRunner();
const memberSchema = async (boundProject?: string) => {
const built = await buildRunner({ sessionId: 'gconv-member-scope', userId: uid, projectId: boundProject });
return (built.runner as any).tools.get('todo_tasks').inputSchema;
};
for (const boundProject of [undefined, projectId]) {
const schema = await memberSchema(boundProject);
expect(schema.properties.action.enum).not.toContain('list_members');
expect(schema.properties).not.toHaveProperty('assignee_uid');
}
});
it('preserves bound Agent backlog access and rejects other scopes', async () => {
process.env.ORKAS_TOOL_LOADING_MODE = 'scoped';
const uid = 'runner-project-target-boundaries';
@@ -1237,6 +1392,7 @@ describe('runner › scoped tool loading', () => {
.toContainEqual(expect.objectContaining({ content: 'AGENT_BACKLOG_ON_DEMAND' }));
expect(agent.runner.getActiveToolDefinitions().map((tool) => tool.name)).toContain('todo_tasks');
expect(bound.inputSchema.properties.action.enum).toEqual(['list', 'get', 'create', 'update', 'complete', 'add_attachment', 'delete']);
expect(bound.inputSchema.properties).not.toHaveProperty('assignee_uid');
expect((await bound.execute({ action: 'update', task_id: agentTask.task.id, status: 'review' }, context)).isError).toBeFalsy();
expect(await tasks.getTask(uid, first.project.project_id, agentTask.task.id)).toMatchObject({ status: 'review' });
expect(await tasks.getTask(uid, first.project.project_id, agentTask.task.id)).not.toHaveProperty('origin_cid');
@@ -1250,16 +1406,24 @@ describe('runner › scoped tool loading', () => {
expect(added.isError).toBeFalsy();
const addedId = JSON.parse(added.content).task.id;
expect(await tasks.getTask(uid, first.project.project_id, addedId)).toMatchObject({ content: 'Agent-created item' });
expect(await tasks.getTask(uid, first.project.project_id, addedId)).not.toHaveProperty('origin_cid');
// The conversation's first open project to-do links back to it.
expect(await tasks.getTask(uid, first.project.project_id, addedId)).toMatchObject({ origin_cid: 'agent-result' });
expect((await bound.execute({ action: 'update', task_id: addedId, content: 'Edited by Agent\nNew detail' }, context)).isError).toBeFalsy();
expect(await tasks.getTask(uid, first.project.project_id, addedId)).toMatchObject({ content: 'Edited by Agent\nNew detail' });
for (const project of [second.project.project_id, '__global__']) {
expect((await bound.execute({ action: 'create', content: 'forbidden', project }, context)).isError).toBe(true);
}
expect((await agentRunner.tools.get('tool_load').execute({ groups: ['management.projects'] }, context)).isError).toBeFalsy();
const activeBeforeRetiredLoad = agent.runner.getActiveToolDefinitions().map(tool => tool.name);
const retiredLoad = await agentRunner.tools.get('tool_load').execute({ groups: ['management.projects'] }, context);
expect(retiredLoad.isError).toBe(true);
expect(JSON.parse(retiredLoad.content)).toMatchObject({
ok: false, error: 'E_TOOL_GROUP_INVALID', unavailable: ['management.projects'],
});
expect(agent.runner.getActiveToolDefinitions().map(tool => tool.name)).toEqual(activeBeforeRetiredLoad);
expect((await agentRunner.tools.get('tool_load').execute({ groups: ['management.todos'] }, context)).isError).toBeFalsy();
const unboundAgent = await buildRunner({ sessionId: 'gmember-unbound-reader', userId: uid, agentId: 'reader', toolList: [] });
const globalTools = (unboundAgent.runner as any).tools;
expect((await globalTools.get('tool_load').execute({ groups: ['management.projects', 'management.automation'] }, context)).isError).toBeFalsy();
expect((await globalTools.get('tool_load').execute({ groups: ['management.todos', 'management.automation'] }, context)).isError).toBeFalsy();
const globalTodo = globalTools.get('todo_tasks');
const createdGlobal = JSON.parse((await globalTodo.execute({ action: 'create', content: 'Global work' }, context)).content);
expect(createdGlobal.ok).toBe(true);
@@ -2358,7 +2522,7 @@ describe('runner › scoped tool loading', () => {
expect(await todos.getTask(uid, pid, receipt.todoId)).toMatchObject({
status: 'done',
});
expect(await todos.getTask(uid, pid, receipt.todoId)).not.toHaveProperty('origin_cid');
expect(await todos.getTask(uid, pid, receipt.todoId)).toMatchObject({ origin_cid: 'task-origin' });
expect(await automations.getTask(uid, receipt.autoId)).toMatchObject({
title: 'Edited automation', content: 'Full automation detail', project_id: pid, enabled: false,
});
@@ -3016,9 +3180,11 @@ describe('runner › conversation-history scope exposure', () => {
(tool) => tool.name === 'chat_history',
);
expect(agentHistory).toBeTruthy();
expect((agentHistory?.inputSchema as any).properties.action.enum).toEqual(['search', 'read']);
// The approved grouped-history contract keeps all four retrieval modes
// while the host still binds an Agent to its current conversation.
expect((agentHistory?.inputSchema as any).properties.action.enum).toEqual(['search', 'read', 'read_process', 'read_output']);
expect((agentHistory?.inputSchema as any).properties.scope.enum).toEqual(['current']);
expect((agentHistory?.inputSchema as any).properties.page).toMatchObject({
expect((agentHistory?.inputSchema as any).properties.read.properties.page).toMatchObject({
type: 'object',
additionalProperties: false,
properties: {
@@ -3028,7 +3194,8 @@ describe('runner › conversation-history scope exposure', () => {
},
required: ['mode'],
});
expect((agentHistory?.inputSchema as any).properties.include_current).toBeUndefined();
expect((agentHistory?.inputSchema as any).properties.search.properties.include_current).toBeUndefined();
expect((agentHistory?.inputSchema as any).properties.cid).toBeUndefined();
expect((nonProjectCommanderHistory?.inputSchema as any).properties.scope.enum)
.toEqual(['current', 'all']);
expect(nonProjectCommanderHistory?.description)
@@ -3049,10 +3216,16 @@ describe('runner › conversation-history scope exposure', () => {
expect(prompt.indexOf('Use supplied context first')).toBeLessThan(prompt.indexOf('## Runtime injection'));
expect(built.turnEphemeral).not.toContain('even without a user lookup request');
const schema = built.toolDefs.find((tool) => tool.name === 'chat_history')!.inputSchema as any;
expect(schema.properties.action.description).toContain('read uses cid/record_id/turn_id/tool_call_id');
expect(schema.properties.action.description).toContain('or latest for vague local references');
expect(schema.properties.action.description).toContain('Follow next_read');
expect(schema.properties.query.description).toContain('discriminative name, phrase, id, or fact');
expect(schema.properties.read.properties).toHaveProperty('record_id');
expect(schema.properties.process.required).toEqual(['record_id', 'process_index']);
expect(schema.properties.process.properties.process_index.minimum).toBe(0);
expect(schema.properties.output.required).toEqual(['record_id', 'tool_call_id']);
expect(schema.properties.search.required).toEqual(['query']);
expect(schema.properties.search.properties.query.type).toBe('string');
for (const legacy of ['page', 'query', 'record_id', 'process_index', 'tool_call_id', 'output_cursor']) {
expect(schema.properties).not.toHaveProperty(legacy);
}
}
});
@@ -3124,12 +3297,18 @@ describe('runner › conversation-history scope exposure', () => {
expect((chatHistoryDefinition?.inputSchema as any).properties.scope.enum)
.toEqual(['current', 'all']);
const records = await import('../../../src/main/features/chat-history-records');
const historyIndex = vi.spyOn(records, 'historyMessageIndex');
const invalidProject = await chatHistory.execute(
{ action: 'read', scope: 'project' },
{ action: 'read', scope: 'project', read: {} },
{ state: {} } as any,
);
expect(invalidProject.isError).toBe(true);
expect(invalidProject.content).toContain('scope "project" is not allowed');
expect(JSON.parse(invalidProject.content)).toMatchObject({
tool: 'chat_history', code: 'invalid_arguments', invalid_fields: ['scope'],
});
expect(historyIndex).not.toHaveBeenCalled();
historyIndex.mockRestore();
const pages: string[] = [];
let beforeMsgIndex: number | undefined;
@@ -3137,9 +3316,9 @@ describe('runner › conversation-history scope exposure', () => {
const result = await chatHistory.execute({
action: 'read',
scope: 'current',
page: beforeMsgIndex === undefined
read: { page: beforeMsgIndex === undefined
? { mode: 'latest', count: 10 }
: { mode: 'before', index: beforeMsgIndex, count: 10 },
: { mode: 'before', index: beforeMsgIndex, count: 10 } },
}, { state: {} } as any);
expect(result.isError).toBeFalsy();
pages.push(result.content);
+4
View File
@@ -66,6 +66,10 @@ describe('runtime text-budget calibration', () => {
const prefix = text.slice(0, end);
const estimate = estimateToolResultTokens(prefix);
expect(estimate).toBeGreaterThanOrEqual(previous);
// Preview fitting relies on this conservative UTF-16 lower bound.
expect(estimate).toBeGreaterThanOrEqual(Math.ceil(prefix.length / 4));
const suffix = text.slice(end);
expect(estimateToolResultTokens(suffix)).toBeGreaterThanOrEqual(Math.ceil(suffix.length / 4));
expect(estimateTextTokens(prefix)).toBe(estimate);
previous = estimate;
}
+29
View File
@@ -38,6 +38,35 @@ const ctx: ToolContext = { state: {} };
const makeTmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'orkas-tool-cap-'));
const cleanup = (dir: string) => fs.rmSync(dir, { recursive: true, force: true });
describe('large output preview equivalence', () => {
it.each(['ordinary words ', '1234567890', '!@#$%^&*', '界かな한', '🙂abc', '\ud800x', 'a1界!🙂 '])('preserves exact source bytes and maximal head/tail for %j', unit => {
const dir = makeTmpDir();
try {
const content = unit.repeat(3000);
// Linear search is independent of production bisection/window selection.
// These budgets and split protect the existing visible 600-token preview.
const fit = (text: string, budget: number, suffix: boolean) => {
let best = '';
for (let n = 1; n <= Math.min(text.length, 2401); n++) {
const candidate = suffix ? text.slice(-n) : text.slice(0, n);
if (estimateToolResultTokens(candidate) > budget) break;
best = candidate;
}
return best;
};
const head = fit(content, 432, false);
const tail = fit(content.slice(head.length), 168, true);
const body = `${head}\n\n... [${content.length - head.length - tail.length} chars omitted; full result is stored] ...\n\n${tail}`;
const result = capToolResult('fixture', { content, isError: true }, { state: {} }, { maxInlineTokens: 100, toolResultsDir: dir });
expect(result.content).toContain(body + '\n');
expect(result.content).toContain(`estimated_tokens="${estimateToolResultTokens(content)}" status="error"`);
expect(nativeFs.readFileSync(result.persistedOutput!.path)).toEqual(Buffer.from(content, 'utf8'));
const cli = maybeSpillToolResult({ toolResultsDir: dir, toolName: 'fixture', callId: 'one', output: content, maxInlineTokens: 100 });
expect(cli.output).toBe(result.content.replace('status="error"', 'status="success"'));
} finally { cleanup(dir); }
});
});
describe('tool-result-cap configuration', () => {
it('keeps ordinary output at 10K and Skill documents at 25K', () => {
expect(DEFAULT_INLINE_RESULT_TOKENS).toBe(10_000);