@oh-my-pi/pi-coding-agent 15.9.5 → 15.9.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/types/config/keybindings.d.ts +4 -1
- package/dist/types/config/settings-schema.d.ts +11 -1
- package/dist/types/edit/file-snapshot-store.d.ts +1 -1
- package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
- package/dist/types/eval/backend.d.ts +6 -6
- package/dist/types/eval/bridge-timeout.d.ts +27 -0
- package/dist/types/eval/idle-timeout.d.ts +16 -14
- package/dist/types/eval/js/executor.d.ts +3 -3
- package/dist/types/eval/py/executor.d.ts +2 -2
- package/dist/types/eval/py/spawn-options.d.ts +58 -0
- package/dist/types/modes/components/assistant-message.d.ts +5 -0
- package/dist/types/modes/components/copy-selector.d.ts +22 -0
- package/dist/types/modes/components/model-selector.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +0 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/modes/utils/copy-targets.d.ts +53 -0
- package/dist/types/tools/eval-render.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +25 -0
- package/dist/types/tui/code-cell.d.ts +6 -0
- package/dist/types/tui/output-block.d.ts +11 -0
- package/package.json +9 -9
- package/src/autoresearch/dashboard.ts +11 -21
- package/src/cli/claude-trace-cli.ts +13 -1
- package/src/config/keybindings.ts +58 -1
- package/src/config/settings-schema.ts +11 -1
- package/src/debug/raw-sse.ts +18 -4
- package/src/edit/file-snapshot-store.ts +1 -1
- package/src/edit/index.ts +1 -1
- package/src/edit/renderer.ts +7 -7
- package/src/edit/streaming.ts +1 -1
- package/src/eval/__tests__/agent-bridge.test.ts +28 -27
- package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
- package/src/eval/__tests__/idle-timeout.test.ts +26 -12
- package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
- package/src/eval/__tests__/llm-bridge.test.ts +10 -10
- package/src/eval/__tests__/shared-executors.test.ts +2 -2
- package/src/eval/agent-bridge.ts +4 -5
- package/src/eval/backend.ts +6 -6
- package/src/eval/bridge-timeout.ts +44 -0
- package/src/eval/idle-timeout.ts +33 -15
- package/src/eval/js/executor.ts +10 -10
- package/src/eval/llm-bridge.ts +4 -5
- package/src/eval/py/executor.ts +6 -6
- package/src/eval/py/kernel.ts +11 -1
- package/src/eval/py/spawn-options.ts +126 -0
- package/src/export/ttsr.ts +9 -0
- package/src/extensibility/extensions/runner.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +6 -5
- package/src/lsp/client.ts +80 -2
- package/src/lsp/index.ts +38 -4
- package/src/lsp/render.ts +3 -3
- package/src/main.ts +1 -1
- package/src/modes/components/agent-dashboard.ts +13 -4
- package/src/modes/components/assistant-message.ts +22 -1
- package/src/modes/components/copy-selector.ts +249 -0
- package/src/modes/components/extensions/extension-list.ts +17 -8
- package/src/modes/components/history-search.ts +19 -11
- package/src/modes/components/model-selector.ts +125 -29
- package/src/modes/components/oauth-selector.ts +28 -12
- package/src/modes/components/session-observer-overlay.ts +13 -15
- package/src/modes/components/session-selector.ts +24 -13
- package/src/modes/components/tool-execution.ts +27 -13
- package/src/modes/components/tree-selector.ts +19 -7
- package/src/modes/components/user-message-selector.ts +25 -14
- package/src/modes/controllers/command-controller.ts +0 -116
- package/src/modes/controllers/event-controller.ts +26 -10
- package/src/modes/controllers/selector-controller.ts +38 -1
- package/src/modes/interactive-mode.ts +4 -4
- package/src/modes/theme/theme.ts +46 -10
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/copy-targets.ts +254 -0
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/search.md +1 -1
- package/src/session/agent-session.ts +6 -2
- package/src/slash-commands/builtin-registry.ts +3 -11
- package/src/task/render.ts +38 -11
- package/src/tools/bash.ts +18 -8
- package/src/tools/browser/render.ts +5 -4
- package/src/tools/debug.ts +3 -3
- package/src/tools/eval-render.ts +24 -9
- package/src/tools/eval.ts +14 -19
- package/src/tools/fetch.ts +5 -5
- package/src/tools/read.ts +7 -7
- package/src/tools/render-utils.ts +46 -0
- package/src/tools/ssh.ts +21 -8
- package/src/tools/write.ts +17 -8
- package/src/tui/code-cell.ts +19 -4
- package/src/tui/output-block.ts +14 -0
- package/src/web/search/render.ts +3 -3
- package/dist/types/eval/heartbeat.d.ts +0 -45
- package/src/eval/__tests__/heartbeat.test.ts +0 -84
- package/src/eval/heartbeat.ts +0 -74
- /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
|
@@ -380,6 +380,18 @@ function isMessagesRequest(message: ParsedHttpMessage): boolean {
|
|
|
380
380
|
return pathNameFromRequestTarget(message.path ?? "") === "/v1/messages";
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
// Claude Code fires a background warmup/classification call on its small fast
|
|
384
|
+
// model (a haiku variant, ANTHROPIC_SMALL_FAST_MODEL) before sending the user's
|
|
385
|
+
// real message. Skip it so the capture lands on the actual prompt.
|
|
386
|
+
function isBackgroundModelRequest(message: ParsedHttpMessage): boolean {
|
|
387
|
+
try {
|
|
388
|
+
const parsed = JSON.parse(decodeBody(message.headers, message.body)) as { model?: unknown };
|
|
389
|
+
return typeof parsed.model === "string" && parsed.model.toLowerCase().includes("haiku");
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
383
395
|
function decodeBody(headers: readonly HeaderEntry[], body: Buffer): string {
|
|
384
396
|
const encoding = headerValue(headers, "content-encoding")?.toLowerCase().trim();
|
|
385
397
|
try {
|
|
@@ -636,7 +648,7 @@ export class ClaudeMessagesProxy {
|
|
|
636
648
|
upstreamTls.write(data);
|
|
637
649
|
const messages = requestParser.push(data);
|
|
638
650
|
for (const message of messages) {
|
|
639
|
-
if (!isMessagesRequest(message)) {
|
|
651
|
+
if (!isMessagesRequest(message) || isBackgroundModelRequest(message)) {
|
|
640
652
|
responseQueue.push(null);
|
|
641
653
|
continue;
|
|
642
654
|
}
|
|
@@ -119,7 +119,10 @@ export const KEYBINDINGS = {
|
|
|
119
119
|
description: "Open external editor",
|
|
120
120
|
},
|
|
121
121
|
"app.message.followUp": {
|
|
122
|
-
|
|
122
|
+
// Ctrl+Enter is preserved for terminals that deliver it (Kitty/iTerm2/WezTerm/Ghostty),
|
|
123
|
+
// but Windows Terminal does not emit a distinct event for Ctrl+Enter — Ctrl+Q is listed
|
|
124
|
+
// first so the default binding works there without remapping (#1903).
|
|
125
|
+
defaultKeys: ["ctrl+q", "ctrl+enter"],
|
|
123
126
|
description: "Send follow-up message",
|
|
124
127
|
},
|
|
125
128
|
"app.message.dequeue": {
|
|
@@ -439,16 +442,51 @@ function migrateKeybindingsConfigFile(agentDir: string): void {
|
|
|
439
442
|
loadKeybindingsConfig(readPath, writeBackPath);
|
|
440
443
|
}
|
|
441
444
|
|
|
445
|
+
const FOLLOW_UP_KEYBINDING: AppKeybinding = "app.message.followUp";
|
|
446
|
+
const WINDOWS_FOLLOW_UP_FALLBACK_KEY: KeyId = "ctrl+q";
|
|
447
|
+
|
|
448
|
+
function keyListIncludes(keys: KeyId | KeyId[] | undefined, target: KeyId): boolean {
|
|
449
|
+
if (keys === undefined) return false;
|
|
450
|
+
const keyList = Array.isArray(keys) ? keys : [keys];
|
|
451
|
+
for (const key of keyList) {
|
|
452
|
+
if (key.toLowerCase() === target) return true;
|
|
453
|
+
}
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function userBindingClaimsKey(config: KeybindingsConfig, target: KeyId, except: Keybinding): boolean {
|
|
458
|
+
for (const [keybinding, keys] of Object.entries(config)) {
|
|
459
|
+
if (!(keybinding in KEYBINDINGS)) continue;
|
|
460
|
+
if (keybinding === except) continue;
|
|
461
|
+
if (keyListIncludes(keys, target)) return true;
|
|
462
|
+
}
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function removeKey(keys: KeyId[], target: KeyId): KeyId[] {
|
|
467
|
+
return keys.filter(key => key !== target);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function keyConfigValue(keys: KeyId[]): KeyId | KeyId[] {
|
|
471
|
+
if (keys.length === 1) {
|
|
472
|
+
const key = keys[0];
|
|
473
|
+
if (key !== undefined) return key;
|
|
474
|
+
}
|
|
475
|
+
return [...keys];
|
|
476
|
+
}
|
|
477
|
+
|
|
442
478
|
/**
|
|
443
479
|
* Manages all keybindings (app + TUI).
|
|
444
480
|
* Extends the TUI KeybindingsManager with app-specific functionality.
|
|
445
481
|
*/
|
|
446
482
|
export class KeybindingsManager extends TuiKeybindingsManager {
|
|
447
483
|
#configPath: string | undefined;
|
|
484
|
+
#userBindings: KeybindingsConfig;
|
|
448
485
|
|
|
449
486
|
constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
|
|
450
487
|
super(KEYBINDINGS, userBindings);
|
|
451
488
|
this.#configPath = configPath;
|
|
489
|
+
this.#userBindings = userBindings;
|
|
452
490
|
}
|
|
453
491
|
|
|
454
492
|
/**
|
|
@@ -480,6 +518,25 @@ export class KeybindingsManager extends TuiKeybindingsManager {
|
|
|
480
518
|
this.setUserBindings(config);
|
|
481
519
|
}
|
|
482
520
|
|
|
521
|
+
setUserBindings(userBindings: KeybindingsConfig): void {
|
|
522
|
+
this.#userBindings = userBindings;
|
|
523
|
+
super.setUserBindings(userBindings);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
getKeys(keybinding: Keybinding): KeyId[] {
|
|
527
|
+
const keys = super.getKeys(keybinding);
|
|
528
|
+
if (keybinding !== FOLLOW_UP_KEYBINDING) return keys;
|
|
529
|
+
if (this.#userBindings[FOLLOW_UP_KEYBINDING] !== undefined) return keys;
|
|
530
|
+
if (!userBindingClaimsKey(this.#userBindings, WINDOWS_FOLLOW_UP_FALLBACK_KEY, FOLLOW_UP_KEYBINDING)) return keys;
|
|
531
|
+
return removeKey(keys, WINDOWS_FOLLOW_UP_FALLBACK_KEY);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
getResolvedBindings(): KeybindingsConfig {
|
|
535
|
+
const resolved = super.getResolvedBindings();
|
|
536
|
+
resolved[FOLLOW_UP_KEYBINDING] = keyConfigValue(this.getKeys(FOLLOW_UP_KEYBINDING));
|
|
537
|
+
return resolved;
|
|
538
|
+
}
|
|
539
|
+
|
|
483
540
|
/**
|
|
484
541
|
* Get the effective resolved bindings (defaults + user overrides).
|
|
485
542
|
*/
|
|
@@ -902,6 +902,15 @@ export const SETTINGS_SCHEMA = {
|
|
|
902
902
|
"Maximum wait between retries, in ms. When the provider asks us to wait longer than this and no credential or model fallback succeeds, the request fails fast instead of sleeping (e.g. 3-hour Anthropic rate-limit windows).",
|
|
903
903
|
},
|
|
904
904
|
},
|
|
905
|
+
"retry.modelFallback": {
|
|
906
|
+
type: "boolean",
|
|
907
|
+
default: true,
|
|
908
|
+
ui: {
|
|
909
|
+
tab: "model",
|
|
910
|
+
label: "Retry Model Fallback",
|
|
911
|
+
description: "Allow retry recovery to switch to configured fallback models",
|
|
912
|
+
},
|
|
913
|
+
},
|
|
905
914
|
"retry.fallbackChains": { type: "record", default: {} as Record<string, string[]> },
|
|
906
915
|
"retry.fallbackRevertPolicy": {
|
|
907
916
|
type: "enum",
|
|
@@ -1855,7 +1864,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
1855
1864
|
tab: "editing",
|
|
1856
1865
|
label: "Hash Lines",
|
|
1857
1866
|
description:
|
|
1858
|
-
"Include snapshot-tag headers and line numbers in read output for hashline edit mode (
|
|
1867
|
+
"Include snapshot-tag headers and line numbers in read output for hashline edit mode ([PATH#TAG] plus LINE:content)",
|
|
1859
1868
|
},
|
|
1860
1869
|
},
|
|
1861
1870
|
|
|
@@ -3307,6 +3316,7 @@ export interface RetrySettings {
|
|
|
3307
3316
|
maxRetries: number;
|
|
3308
3317
|
baseDelayMs: number;
|
|
3309
3318
|
maxDelayMs: number;
|
|
3319
|
+
modelFallback: boolean;
|
|
3310
3320
|
}
|
|
3311
3321
|
|
|
3312
3322
|
export interface MemoriesSettings {
|
package/src/debug/raw-sse.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type Component,
|
|
3
|
+
matchesKey,
|
|
4
|
+
padding,
|
|
5
|
+
replaceTabs,
|
|
6
|
+
ScrollView,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
} from "@oh-my-pi/pi-tui";
|
|
2
10
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
3
11
|
import { theme } from "../modes/theme/theme";
|
|
4
12
|
import { copyToClipboard } from "../utils/clipboard";
|
|
@@ -146,14 +154,20 @@ export class RawSseViewerComponent implements Component {
|
|
|
146
154
|
const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
|
|
147
155
|
const bodyHeight = this.#bodyHeight();
|
|
148
156
|
const rawLines = this.#renderRawLines(innerWidth);
|
|
149
|
-
const
|
|
150
|
-
|
|
157
|
+
const sv = new ScrollView(rawLines.slice(this.#scrollOffset, this.#scrollOffset + bodyHeight), {
|
|
158
|
+
height: bodyHeight,
|
|
159
|
+
scrollbar: "auto",
|
|
160
|
+
totalRows: rawLines.length,
|
|
161
|
+
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
162
|
+
});
|
|
163
|
+
sv.setScrollOffset(this.#scrollOffset);
|
|
164
|
+
const bodyRows = sv.render(innerWidth);
|
|
151
165
|
|
|
152
166
|
return [
|
|
153
167
|
this.#frameTop(innerWidth),
|
|
154
168
|
this.#frameLine(this.#summaryText(), innerWidth),
|
|
155
169
|
this.#frameSeparator(innerWidth),
|
|
156
|
-
...
|
|
170
|
+
...bodyRows.map(line => this.#frameLine(line, innerWidth)),
|
|
157
171
|
this.#frameLine(this.#statusText(), innerWidth),
|
|
158
172
|
this.#frameBottom(innerWidth),
|
|
159
173
|
];
|
|
@@ -14,7 +14,7 @@ import { normalizeToLF } from "./normalize";
|
|
|
14
14
|
/**
|
|
15
15
|
* Upper bound on the file size we snapshot. A section tag is a content hash of
|
|
16
16
|
* the *whole* file, so minting one means holding the full normalized text in
|
|
17
|
-
* the store. Files above this cap emit no
|
|
17
|
+
* the store. Files above this cap emit no `[path#tag]` header — line-anchored
|
|
18
18
|
* editing of multi-megabyte files is out of scope under the full-content model.
|
|
19
19
|
*/
|
|
20
20
|
export const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
|
package/src/edit/index.ts
CHANGED
|
@@ -275,7 +275,7 @@ function extractApprovalPath(args: unknown): string {
|
|
|
275
275
|
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
|
276
276
|
const input = typeof record.input === "string" ? record.input : undefined;
|
|
277
277
|
if (input) {
|
|
278
|
-
const hashlineMatch =
|
|
278
|
+
const hashlineMatch = /^\[([^#\r\n]+)(?:#[0-9a-fA-F]{4})?\]/m.exec(input);
|
|
279
279
|
if (hashlineMatch?.[1]) return hashlineMatch[1];
|
|
280
280
|
|
|
281
281
|
const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
|
package/src/edit/renderer.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Edit tool renderer and LSP batching helpers.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { HL_FILE_PREFIX } from "@oh-my-pi/hashline";
|
|
5
|
+
import { HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
|
|
6
6
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
7
7
|
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
|
8
8
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
@@ -328,12 +328,12 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
|
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
function parseHashlineInputPreviewHeader(line: string): string | null {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const body =
|
|
331
|
+
const trimmed = line.trimEnd();
|
|
332
|
+
if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
|
|
333
|
+
// Keep streaming previews tolerant while the closing bracket is still
|
|
334
|
+
// being generated; the parser enforces the final `[path#TAG]` shape.
|
|
335
|
+
const bodyEnd = trimmed.endsWith(HL_FILE_SUFFIX) ? trimmed.length - HL_FILE_SUFFIX.length : trimmed.length;
|
|
336
|
+
const body = trimmed.slice(HL_FILE_PREFIX.length, bodyEnd).trim();
|
|
337
337
|
const previewPath = normalizeHashlineInputPreviewPath(body);
|
|
338
338
|
return previewPath.length > 0 ? previewPath : null;
|
|
339
339
|
}
|
package/src/edit/streaming.ts
CHANGED
|
@@ -424,7 +424,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
424
424
|
return previews.length > 0 ? previews : null;
|
|
425
425
|
},
|
|
426
426
|
renderStreamingFallback() {
|
|
427
|
-
// Never leak raw hashline syntax (`64:`, `|payload`,
|
|
427
|
+
// Never leak raw hashline syntax (`64:`, `|payload`, `[path#hash]`)
|
|
428
428
|
// to the user — the streaming preview already projects every
|
|
429
429
|
// parseable op onto the real file via applyPartialTo, and an
|
|
430
430
|
// unparseable trailing chunk renders as "no preview yet" rather
|
|
@@ -10,7 +10,7 @@ import { AgentOutputManager } from "../../task/output-manager";
|
|
|
10
10
|
import type { AgentDefinition, AgentProgress, SingleResult } from "../../task/types";
|
|
11
11
|
import type { ToolSession } from "../../tools";
|
|
12
12
|
import { EVAL_AGENT_MAX_DEPTH, runEvalAgent } from "../agent-bridge";
|
|
13
|
-
import {
|
|
13
|
+
import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
|
|
14
14
|
import { IdleTimeout } from "../idle-timeout";
|
|
15
15
|
import { disposeAllVmContexts } from "../js/context-manager";
|
|
16
16
|
import { executeJs } from "../js/executor";
|
|
@@ -236,7 +236,6 @@ describe("runEvalAgent", () => {
|
|
|
236
236
|
describe("agent() through eval runtimes", () => {
|
|
237
237
|
afterEach(() => {
|
|
238
238
|
vi.restoreAllMocks();
|
|
239
|
-
setBridgeHeartbeatIntervalMs();
|
|
240
239
|
});
|
|
241
240
|
|
|
242
241
|
afterAll(async () => {
|
|
@@ -560,24 +559,20 @@ describe("agent() through eval runtimes", () => {
|
|
|
560
559
|
expect(displayAgentEvents.length).toBe(2);
|
|
561
560
|
});
|
|
562
561
|
|
|
563
|
-
it("
|
|
564
|
-
using tempDir = TempDir.createSync("@omp-eval-agent-
|
|
565
|
-
const { session } = makeEvalSession(tempDir, "js-agent-
|
|
562
|
+
it("pauses the idle watchdog while a quiet agent() runs past the budget", async () => {
|
|
563
|
+
using tempDir = TempDir.createSync("@omp-eval-agent-timeout-pause-");
|
|
564
|
+
const { session } = makeEvalSession(tempDir, "js-agent-timeout-pause");
|
|
566
565
|
mockAgents();
|
|
567
|
-
// Heartbeat cadence well under the idle budget so a working-but-silent
|
|
568
|
-
// subagent re-arms the watchdog several times before it could expire.
|
|
569
|
-
setBridgeHeartbeatIntervalMs(15);
|
|
570
566
|
|
|
571
|
-
// runSubprocess runs far past the budget and emits NO progress
|
|
572
|
-
//
|
|
573
|
-
//
|
|
567
|
+
// runSubprocess runs far past the eval timeout budget and emits NO progress
|
|
568
|
+
// of its own. The bridge pause must make that delegated time invisible to
|
|
569
|
+
// the watchdog.
|
|
574
570
|
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
|
|
575
571
|
await Bun.sleep(200);
|
|
576
572
|
return singleResult(options, { output: "done" });
|
|
577
573
|
});
|
|
578
574
|
|
|
579
|
-
|
|
580
|
-
// ONLY a bridge heartbeat re-arms it.
|
|
575
|
+
const ops: string[] = [];
|
|
581
576
|
using idle = new IdleTimeout(60);
|
|
582
577
|
const result = await runEvalAgent(
|
|
583
578
|
{ prompt: "investigate" },
|
|
@@ -585,25 +580,29 @@ describe("agent() through eval runtimes", () => {
|
|
|
585
580
|
session,
|
|
586
581
|
signal: idle.signal,
|
|
587
582
|
emitStatus: event => {
|
|
588
|
-
|
|
583
|
+
ops.push(event.op);
|
|
584
|
+
if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
|
|
585
|
+
if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
|
|
589
586
|
},
|
|
590
587
|
},
|
|
591
588
|
);
|
|
592
589
|
|
|
593
|
-
expect(idle.signal.aborted).toBe(false);
|
|
594
590
|
expect(result.text).toBe("done");
|
|
591
|
+
expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
|
|
592
|
+
expect(idle.signal.aborted).toBe(false);
|
|
593
|
+
|
|
594
|
+
await Bun.sleep(90);
|
|
595
|
+
expect(idle.signal.aborted).toBe(true);
|
|
595
596
|
});
|
|
596
597
|
|
|
597
|
-
it("
|
|
598
|
-
using tempDir = TempDir.createSync("@omp-eval-agent-progress-
|
|
599
|
-
const { session } = makeEvalSession(tempDir, "js-agent-progress-
|
|
598
|
+
it("keeps timeout paused despite agent() progress snapshots", async () => {
|
|
599
|
+
using tempDir = TempDir.createSync("@omp-eval-agent-progress-timeout-pause-");
|
|
600
|
+
const { session } = makeEvalSession(tempDir, "js-agent-progress-timeout-pause");
|
|
600
601
|
mockAgents();
|
|
601
|
-
// Heartbeat slower than the budget: only the immediate beat at call start
|
|
602
|
-
// fires, so after the budget elapses nothing re-arms the watchdog.
|
|
603
|
-
setBridgeHeartbeatIntervalMs(10_000);
|
|
604
602
|
|
|
605
603
|
// Stream frequent progress snapshots (op:"agent") for well past the budget.
|
|
606
|
-
//
|
|
604
|
+
// They render as status, but timeout accounting is controlled only by the
|
|
605
|
+
// bridge pause/resume events.
|
|
607
606
|
vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
|
|
608
607
|
for (let i = 0; i < 40; i++) {
|
|
609
608
|
options.onProgress?.({
|
|
@@ -629,21 +628,23 @@ describe("agent() through eval runtimes", () => {
|
|
|
629
628
|
|
|
630
629
|
const ops: string[] = [];
|
|
631
630
|
using idle = new IdleTimeout(80);
|
|
632
|
-
await runEvalAgent(
|
|
631
|
+
const result = await runEvalAgent(
|
|
633
632
|
{ prompt: "investigate" },
|
|
634
633
|
{
|
|
635
634
|
session,
|
|
636
635
|
signal: idle.signal,
|
|
637
636
|
emitStatus: event => {
|
|
638
637
|
ops.push(event.op);
|
|
639
|
-
if (event.op ===
|
|
638
|
+
if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
|
|
639
|
+
if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
|
|
640
640
|
},
|
|
641
641
|
},
|
|
642
642
|
);
|
|
643
643
|
|
|
644
|
-
|
|
645
|
-
|
|
644
|
+
expect(result.text).toBe("done");
|
|
645
|
+
expect(ops[0]).toBe(EVAL_TIMEOUT_PAUSE_OP);
|
|
646
646
|
expect(ops).toContain("agent");
|
|
647
|
-
expect(
|
|
647
|
+
expect(ops.at(-1)).toBe(EVAL_TIMEOUT_RESUME_OP);
|
|
648
|
+
expect(idle.signal.aborted).toBe(false);
|
|
648
649
|
});
|
|
649
650
|
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
EVAL_TIMEOUT_PAUSE_OP,
|
|
4
|
+
EVAL_TIMEOUT_RESUME_OP,
|
|
5
|
+
isEvalTimeoutControlEvent,
|
|
6
|
+
withBridgeTimeoutPause,
|
|
7
|
+
} from "../bridge-timeout";
|
|
8
|
+
import type { JsStatusEvent } from "../js/shared/types";
|
|
9
|
+
|
|
10
|
+
describe("withBridgeTimeoutPause", () => {
|
|
11
|
+
it("emits one pause before the operation and one resume after it settles", async () => {
|
|
12
|
+
const events: JsStatusEvent[] = [];
|
|
13
|
+
|
|
14
|
+
const value = await withBridgeTimeoutPause(
|
|
15
|
+
event => events.push(event),
|
|
16
|
+
async () => {
|
|
17
|
+
await Bun.sleep(80);
|
|
18
|
+
return "done";
|
|
19
|
+
},
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(value).toBe("done");
|
|
23
|
+
expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
|
|
24
|
+
|
|
25
|
+
const settledCount = events.length;
|
|
26
|
+
await Bun.sleep(40);
|
|
27
|
+
expect(events.length).toBe(settledCount);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("resumes timeout accounting even when the operation throws", async () => {
|
|
31
|
+
const events: JsStatusEvent[] = [];
|
|
32
|
+
|
|
33
|
+
await expect(
|
|
34
|
+
withBridgeTimeoutPause(
|
|
35
|
+
event => events.push(event),
|
|
36
|
+
async () => {
|
|
37
|
+
await Bun.sleep(20);
|
|
38
|
+
throw new Error("boom");
|
|
39
|
+
},
|
|
40
|
+
),
|
|
41
|
+
).rejects.toThrow("boom");
|
|
42
|
+
|
|
43
|
+
expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("runs the operation without emitting when no status sink is wired", async () => {
|
|
47
|
+
let ran = 0;
|
|
48
|
+
|
|
49
|
+
const value = await withBridgeTimeoutPause(undefined, async () => {
|
|
50
|
+
ran++;
|
|
51
|
+
await Bun.sleep(20);
|
|
52
|
+
return 42;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(value).toBe(42);
|
|
56
|
+
expect(ran).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("identifies timeout-control events as non-renderable status", () => {
|
|
60
|
+
expect(isEvalTimeoutControlEvent({ op: EVAL_TIMEOUT_PAUSE_OP })).toBe(true);
|
|
61
|
+
expect(isEvalTimeoutControlEvent({ op: EVAL_TIMEOUT_RESUME_OP })).toBe(true);
|
|
62
|
+
expect(isEvalTimeoutControlEvent({ op: "agent", id: "subagent-1" })).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -32,21 +32,34 @@ describe("IdleTimeout", () => {
|
|
|
32
32
|
expect((idle.signal.reason as DOMException).name).toBe("TimeoutError");
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
it("
|
|
36
|
-
using idle = new IdleTimeout(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
for (let i = 0; i < 6; i++) {
|
|
40
|
-
await Bun.sleep(40);
|
|
41
|
-
idle.bump();
|
|
42
|
-
}
|
|
35
|
+
it("ignores elapsed time while paused and resumes with a fresh window", async () => {
|
|
36
|
+
using idle = new IdleTimeout(80);
|
|
37
|
+
idle.pause();
|
|
38
|
+
await Bun.sleep(160);
|
|
43
39
|
expect(idle.signal.aborted).toBe(false);
|
|
44
40
|
|
|
45
|
-
|
|
46
|
-
const
|
|
41
|
+
idle.resume();
|
|
42
|
+
const firedEarly = await abortedWithin(idle.signal, 30);
|
|
43
|
+
expect(firedEarly).toBe(false);
|
|
44
|
+
const fired = await abortedWithin(idle.signal, 500);
|
|
47
45
|
expect(fired).toBe(true);
|
|
48
46
|
});
|
|
49
47
|
|
|
48
|
+
it("reference-counts overlapping pauses", async () => {
|
|
49
|
+
using idle = new IdleTimeout(60);
|
|
50
|
+
idle.pause();
|
|
51
|
+
idle.pause();
|
|
52
|
+
await Bun.sleep(120);
|
|
53
|
+
expect(idle.signal.aborted).toBe(false);
|
|
54
|
+
|
|
55
|
+
idle.resume();
|
|
56
|
+
await Bun.sleep(90);
|
|
57
|
+
expect(idle.signal.aborted).toBe(false);
|
|
58
|
+
|
|
59
|
+
idle.resume();
|
|
60
|
+
const fired = await abortedWithin(idle.signal, 500);
|
|
61
|
+
expect(fired).toBe(true);
|
|
62
|
+
});
|
|
50
63
|
it("never fires after dispose()", async () => {
|
|
51
64
|
const idle = new IdleTimeout(30);
|
|
52
65
|
idle.dispose();
|
|
@@ -55,12 +68,13 @@ describe("IdleTimeout", () => {
|
|
|
55
68
|
expect(idle.signal.aborted).toBe(false);
|
|
56
69
|
});
|
|
57
70
|
|
|
58
|
-
it("ignores
|
|
71
|
+
it("ignores pause/resume after the watchdog has already fired", async () => {
|
|
59
72
|
using idle = new IdleTimeout(30);
|
|
60
73
|
await abortedWithin(idle.signal, 500);
|
|
61
74
|
expect(idle.signal.aborted).toBe(true);
|
|
62
75
|
// Late activity must not un-abort or rearm a settled watchdog.
|
|
63
|
-
idle.
|
|
76
|
+
idle.pause();
|
|
77
|
+
idle.resume();
|
|
64
78
|
expect(idle.signal.aborted).toBe(true);
|
|
65
79
|
});
|
|
66
80
|
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
__resetWindowsConsoleProbeCache,
|
|
4
|
+
consoleAttachedViaTTY,
|
|
5
|
+
hostHasInheritableConsole,
|
|
6
|
+
shouldHideKernelWindow,
|
|
7
|
+
} from "../py/spawn-options";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `shouldHideKernelWindow` decides whether the long-lived Python kernel
|
|
11
|
+
* subprocess is spawned with `windowsHide: true`. On Windows, Bun maps that
|
|
12
|
+
* option to `CREATE_NO_WINDOW`, which detaches the child from any inherited
|
|
13
|
+
* console — breaking both (a) `LoadLibraryExW` for NumPy/pandas native
|
|
14
|
+
* extensions and (b) SIGINT delivery via `GenerateConsoleCtrlEvent`. See
|
|
15
|
+
* issue #1960. The tests below pin the three layered concerns the PR review
|
|
16
|
+
* surfaced:
|
|
17
|
+
*
|
|
18
|
+
* 1. `shouldHideKernelWindow` — pure predicate over a single boolean.
|
|
19
|
+
* 2. `consoleAttachedViaTTY` — the TTY-OR fallback used when the Win32 FFI
|
|
20
|
+
* probe is unavailable; covers the partial-redirection cases.
|
|
21
|
+
* 3. `hostHasInheritableConsole` — the integration boundary. Off-Windows it
|
|
22
|
+
* short-circuits to the TTY fallback; on Windows it is expected to
|
|
23
|
+
* consult `kernel32!GetConsoleWindow()` first, which is the authoritative
|
|
24
|
+
* signal even for the all-stdio-redirected case.
|
|
25
|
+
*/
|
|
26
|
+
describe("shouldHideKernelWindow", () => {
|
|
27
|
+
it("inherits the host console on Windows when one is attached", () => {
|
|
28
|
+
// Reporter's repro: omp launched in Windows Terminal, host has a
|
|
29
|
+
// console, kernel must inherit so `import pandas` doesn't deadlock in
|
|
30
|
+
// `_multiarray_umath` and SIGINT can recover the cell.
|
|
31
|
+
expect(shouldHideKernelWindow({ platform: "win32", hostHasInheritableConsole: true })).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("hides on Windows only when the host has no console at all (true service / daemon)", () => {
|
|
35
|
+
// CREATE_NO_WINDOW here suppresses the console window Windows would
|
|
36
|
+
// otherwise auto-allocate for the console-app Python kernel.
|
|
37
|
+
expect(shouldHideKernelWindow({ platform: "win32", hostHasInheritableConsole: false })).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("never sets windowsHide off-Windows (the option is a Win32-only flag)", () => {
|
|
41
|
+
// On POSIX `windowsHide` is a no-op; the predicate must return false
|
|
42
|
+
// everywhere off-Windows so the spawn site matches pre-fix behavior.
|
|
43
|
+
expect(shouldHideKernelWindow({ platform: "linux", hostHasInheritableConsole: true })).toBe(false);
|
|
44
|
+
expect(shouldHideKernelWindow({ platform: "linux", hostHasInheritableConsole: false })).toBe(false);
|
|
45
|
+
expect(shouldHideKernelWindow({ platform: "darwin", hostHasInheritableConsole: true })).toBe(false);
|
|
46
|
+
expect(shouldHideKernelWindow({ platform: "darwin", hostHasInheritableConsole: false })).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("consoleAttachedViaTTY (FFI fallback heuristic)", () => {
|
|
51
|
+
// The OR of three TTY signals correctly classifies the realistic shell
|
|
52
|
+
// redirection scenarios that motivated widening the check beyond stdout
|
|
53
|
+
// in the first review pass (PR #1961). The all-three-redirected case
|
|
54
|
+
// (false here) is the gap that the Win32 FFI probe in
|
|
55
|
+
// `hostHasInheritableConsole` is meant to close — this fallback is best-
|
|
56
|
+
// effort.
|
|
57
|
+
|
|
58
|
+
it("treats a fully interactive launch as console-attached", () => {
|
|
59
|
+
expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: true, stderrIsTTY: true })).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("treats `omp -p '...' > out.txt` (stdout-only redirect) as console-attached", () => {
|
|
63
|
+
// The reviewer's first-pass repro: stdout off the terminal, stdin
|
|
64
|
+
// and stderr still attached. OR keeps the console.
|
|
65
|
+
expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: false, stderrIsTTY: true })).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("treats stdin-only redirects (`< in.txt`) as console-attached", () => {
|
|
69
|
+
expect(consoleAttachedViaTTY({ stdinIsTTY: false, stdoutIsTTY: true, stderrIsTTY: true })).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("treats stderr-only redirects (`2> err.log`) as console-attached", () => {
|
|
73
|
+
expect(consoleAttachedViaTTY({ stdinIsTTY: true, stdoutIsTTY: true, stderrIsTTY: false })).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns false only when none of stdin/stdout/stderr is a TTY", () => {
|
|
77
|
+
// This is the gap: a real Windows Terminal session with all three
|
|
78
|
+
// streams redirected (`omp ... < in > out 2> err`) lands here.
|
|
79
|
+
// `hostHasInheritableConsole` uses the Win32 FFI probe to recover
|
|
80
|
+
// the right answer in that scenario; this helper is the fallback.
|
|
81
|
+
expect(consoleAttachedViaTTY({ stdinIsTTY: false, stdoutIsTTY: false, stderrIsTTY: false })).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("hostHasInheritableConsole", () => {
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
__resetWindowsConsoleProbeCache();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (process.platform !== "win32") {
|
|
91
|
+
it("matches the TTY-OR fallback off-Windows", () => {
|
|
92
|
+
// Off-Windows, `windowsHide` is a no-op anyway, but we still
|
|
93
|
+
// expose `hostHasInheritableConsole` symmetrically. Confirm it
|
|
94
|
+
// degrades to the same OR the call site would compute by hand.
|
|
95
|
+
const tty = consoleAttachedViaTTY({
|
|
96
|
+
stdinIsTTY: !!process.stdin.isTTY,
|
|
97
|
+
stdoutIsTTY: !!process.stdout.isTTY,
|
|
98
|
+
stderrIsTTY: !!process.stderr.isTTY,
|
|
99
|
+
});
|
|
100
|
+
expect(hostHasInheritableConsole()).toBe(tty);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
@@ -8,7 +8,7 @@ import type { ModelRegistry } from "../../config/model-registry";
|
|
|
8
8
|
import { Settings } from "../../config/settings";
|
|
9
9
|
import type { ToolSession } from "../../tools";
|
|
10
10
|
import { ToolError } from "../../tools/tool-errors";
|
|
11
|
-
import {
|
|
11
|
+
import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
|
|
12
12
|
import { IdleTimeout } from "../idle-timeout";
|
|
13
13
|
import { disposeAllVmContexts } from "../js/context-manager";
|
|
14
14
|
import { executeJs } from "../js/executor";
|
|
@@ -99,7 +99,6 @@ function assistant(opts: {
|
|
|
99
99
|
describe("runEvalLlm", () => {
|
|
100
100
|
afterEach(() => {
|
|
101
101
|
vi.restoreAllMocks();
|
|
102
|
-
setBridgeHeartbeatIntervalMs();
|
|
103
102
|
});
|
|
104
103
|
|
|
105
104
|
it("resolves each tier to its expected model", async () => {
|
|
@@ -217,31 +216,32 @@ describe("runEvalLlm", () => {
|
|
|
217
216
|
);
|
|
218
217
|
});
|
|
219
218
|
|
|
220
|
-
it("
|
|
221
|
-
// A oneshot completion emits no status until it returns;
|
|
222
|
-
// must
|
|
223
|
-
// awaits, re-arming the watchdog through emitStatus.
|
|
224
|
-
setBridgeHeartbeatIntervalMs(15);
|
|
219
|
+
it("pauses the idle watchdog while a slow llm() request is in flight", async () => {
|
|
220
|
+
// A oneshot completion emits no status until it returns; delegated model
|
|
221
|
+
// time must be invisible to the eval timeout budget.
|
|
225
222
|
vi.spyOn(ai, "completeSimple").mockImplementation(async () => {
|
|
226
223
|
await Bun.sleep(200);
|
|
227
224
|
return assistant({ text: "the answer" });
|
|
228
225
|
});
|
|
229
226
|
|
|
227
|
+
const ops: string[] = [];
|
|
230
228
|
using idle = new IdleTimeout(60);
|
|
231
229
|
const result = await runEvalLlm(
|
|
232
230
|
{ prompt: "q", model: "smol" },
|
|
233
231
|
{
|
|
234
232
|
session: makeSession(),
|
|
235
233
|
signal: idle.signal,
|
|
236
|
-
// Mirror the eval tool: only a bridge heartbeat re-arms the watchdog.
|
|
237
234
|
emitStatus: event => {
|
|
238
|
-
|
|
235
|
+
ops.push(event.op);
|
|
236
|
+
if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
|
|
237
|
+
if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
|
|
239
238
|
},
|
|
240
239
|
},
|
|
241
240
|
);
|
|
242
241
|
|
|
243
|
-
expect(idle.signal.aborted).toBe(false);
|
|
244
242
|
expect(result.text).toBe("the answer");
|
|
243
|
+
expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP, "llm"]);
|
|
244
|
+
expect(idle.signal.aborted).toBe(false);
|
|
245
245
|
});
|
|
246
246
|
});
|
|
247
247
|
|