@oh-my-pi/pi-coding-agent 16.5.1 → 16.5.2

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +3442 -3408
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  5. package/dist/types/eval/backend.d.ts +3 -3
  6. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  7. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  8. package/dist/types/goals/guided-setup.d.ts +12 -0
  9. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  10. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  11. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  12. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  13. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  14. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  15. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  16. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  17. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  18. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  19. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  20. package/dist/types/modes/interactive-mode.d.ts +1 -0
  21. package/dist/types/modes/types.d.ts +1 -0
  22. package/dist/types/session/messages.d.ts +15 -0
  23. package/dist/types/tools/grep.d.ts +0 -2
  24. package/dist/types/tools/read.d.ts +0 -4
  25. package/package.json +12 -12
  26. package/src/advisor/__tests__/advisor.test.ts +136 -49
  27. package/src/advisor/runtime.ts +16 -34
  28. package/src/autoresearch/dashboard.ts +2 -2
  29. package/src/cli/config-cli.ts +15 -3
  30. package/src/config/settings-schema.ts +10 -0
  31. package/src/cursor.ts +2 -0
  32. package/src/discovery/claude-plugins.ts +9 -3
  33. package/src/discovery/omp-plugins.ts +6 -2
  34. package/src/discovery/substitute-plugin-root.ts +32 -0
  35. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  36. package/src/eval/backend.ts +3 -3
  37. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  38. package/src/eval/py/prelude.py +28 -1
  39. package/src/exec/bash-executor.ts +30 -43
  40. package/src/extensibility/extensions/wrapper.ts +18 -18
  41. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  43. package/src/extensibility/plugins/manager.ts +7 -7
  44. package/src/goals/guided-setup.ts +29 -1
  45. package/src/internal-urls/history-protocol.ts +95 -15
  46. package/src/internal-urls/registry-helpers.ts +50 -1
  47. package/src/launch/broker.ts +38 -25
  48. package/src/mcp/oauth-discovery.ts +20 -1
  49. package/src/mcp/oauth-flow.ts +3 -1
  50. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  51. package/src/modes/components/agent-dashboard.ts +2 -2
  52. package/src/modes/components/agent-hub.ts +15 -2
  53. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  54. package/src/modes/components/chat-transcript-builder.ts +4 -3
  55. package/src/modes/components/dynamic-border.ts +9 -6
  56. package/src/modes/components/extensions/extension-list.ts +2 -2
  57. package/src/modes/components/hook-selector.ts +10 -4
  58. package/src/modes/components/login-dialog.ts +5 -0
  59. package/src/modes/components/mcp-add-wizard.ts +5 -0
  60. package/src/modes/components/plan-review-overlay.ts +11 -11
  61. package/src/modes/components/read-tool-group.ts +1 -8
  62. package/src/modes/controllers/input-controller.ts +4 -2
  63. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  64. package/src/modes/controllers/selector-controller.ts +9 -2
  65. package/src/modes/controllers/todo-command-controller.ts +18 -14
  66. package/src/modes/interactive-mode.ts +7 -3
  67. package/src/modes/prompt-action-autocomplete.ts +6 -1
  68. package/src/modes/types.ts +1 -1
  69. package/src/prompts/system/system-prompt.md +1 -0
  70. package/src/prompts/tools/eval.md +2 -2
  71. package/src/prompts/tools/grep.md +1 -2
  72. package/src/prompts/tools/read.md +2 -4
  73. package/src/sdk.ts +28 -31
  74. package/src/session/agent-session.ts +44 -22
  75. package/src/session/messages.test.ts +66 -0
  76. package/src/session/messages.ts +37 -0
  77. package/src/system-prompt.test.ts +36 -0
  78. package/src/system-prompt.ts +1 -1
  79. package/src/tools/browser/registry.ts +17 -3
  80. package/src/tools/eval.ts +14 -9
  81. package/src/tools/gh.ts +3 -1
  82. package/src/tools/grep.ts +5 -45
  83. package/src/tools/path-utils.ts +7 -1
  84. package/src/tools/read.ts +23 -74
  85. package/src/utils/title-generator.ts +10 -6
  86. package/src/web/search/providers/perplexity-auth.ts +20 -11
  87. package/src/web/search/providers/perplexity.ts +14 -2
@@ -3699,6 +3699,16 @@ export declare const SETTINGS_SCHEMA: {
3699
3699
  readonly description: "Enable the tts tool for on-device (Kokoro) or xAI Grok Voice speech-file synthesis";
3700
3700
  };
3701
3701
  };
3702
+ readonly "generate_image.enabled": {
3703
+ readonly type: "boolean";
3704
+ readonly default: true;
3705
+ readonly ui: {
3706
+ readonly tab: "tools";
3707
+ readonly group: "Available Tools";
3708
+ readonly label: "Generate Image";
3709
+ readonly description: "Enable the generate_image tool for text-to-image generation and editing";
3710
+ };
3711
+ };
3702
3712
  readonly "inspect_image.enabled": {
3703
3713
  readonly type: "boolean";
3704
3714
  readonly default: false;
@@ -1 +1,23 @@
1
1
  export declare function substitutePluginRoot<T>(value: T, rootPath: string): T;
2
+ /**
3
+ * Rebase relative filesystem values in a discovered plugin stdio config against
4
+ * the directory of the `.mcp.json` that declared them.
5
+ *
6
+ * External plugin configs (bundled ChatGPT/Codex plugins, Claude marketplace
7
+ * plugins) express `command`/`cwd` relative to their own config file, but MCP
8
+ * stdio spawning roots relative values at the session cwd — so a plugin shipping
9
+ * `command: "./bin/server"`, `cwd: "."` launches from the wrong directory and
10
+ * fails with ENOENT. This resolves those against `configDir` instead:
11
+ *
12
+ * - relative `cwd` → resolved against `configDir`;
13
+ * - path-like `command` (`./`, `../`, or the Windows `.\`/`..\` forms) →
14
+ * resolved against `configDir`;
15
+ * - bare executables (`npx`, `uvx`, …) and absolute paths are left untouched.
16
+ */
17
+ export declare function resolvePluginStdioPaths(config: {
18
+ command?: string;
19
+ cwd?: string;
20
+ }, configDir: string): {
21
+ command?: string;
22
+ cwd?: string;
23
+ };
@@ -13,10 +13,10 @@ export interface ExecutorBackendExecOptions {
13
13
  * driven entirely by `signal`, which the eval tool arms as a watchdog that
14
14
  * pauses on bridge timeout-control status events and fires a `TimeoutError`
15
15
  * reason only while the Python/JS runtime owns control. Backends use this
16
- * value only for timeout-annotation text and as cold-start headroom; they MUST
17
- * NOT derive a competing wall-clock timer from it.
16
+ * value only for timeout-annotation text and as cold-start headroom; undefined
17
+ * disables the cell timeout. Backends MUST NOT derive a competing wall-clock timer from it.
18
18
  */
19
- idleTimeoutMs: number;
19
+ idleTimeoutMs?: number;
20
20
  reset: boolean;
21
21
  onChunk: (chunk: string) => void;
22
22
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Tool wrappers for extensions.
3
3
  */
4
- import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import type { Static, TSchema } from "@oh-my-pi/pi-ai";
6
6
  import type { ExtensionRunner } from "./runner.js";
7
7
  import type { RegisteredTool } from "./types.js";
@@ -19,7 +19,7 @@ export declare class RegisteredToolAdapter implements AgentTool<any, any, any> {
19
19
  renderCall?: (args: any, options: any, theme: any) => any;
20
20
  renderResult?: (result: any, options: any, theme: any, args?: any) => any;
21
21
  constructor(registeredTool: RegisteredTool, runner: ExtensionRunner);
22
- execute(toolCallId: string, params: any, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<any>, _context?: AgentToolContext): Promise<import("@oh-my-pi/pi-agent-core").AgentToolResult<unknown, unknown>>;
22
+ execute(toolCallId: string, params: any, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<any>, _context?: AgentToolContext): Promise<AgentToolResult<unknown, unknown>>;
23
23
  }
24
24
  /**
25
25
  * Backward-compatible factory function wrapper.
@@ -47,8 +47,5 @@ export declare class ExtensionToolWrapper<TParameters extends TSchema = TSchema,
47
47
  * Forward browser mode changes when available.
48
48
  */
49
49
  restartForModeChange(): Promise<void>;
50
- execute(toolCallId: string, params: Static<TParameters>, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>, context?: AgentToolContext): Promise<{
51
- content: any;
52
- details?: TDetails;
53
- }>;
50
+ execute(toolCallId: string, params: Static<TParameters>, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>, context?: AgentToolContext): Promise<AgentToolResult<TDetails, TParameters>>;
54
51
  }
@@ -0,0 +1,3 @@
1
+ import type { GitSource } from "./git-url.js";
2
+ /** Fetches current heads and tags into Bun's matching cached bare clone before a plugin update. */
3
+ export declare function refreshBunGitCache(source: GitSource, cwd: string): Promise<void>;
@@ -14,5 +14,17 @@ export type GuidedGoalTurnResult = {
14
14
  export interface GuidedGoalTurnOptions {
15
15
  messages: readonly GuidedGoalMessage[];
16
16
  signal?: AbortSignal;
17
+ /**
18
+ * Stable Codex transport session id reused across every turn of one
19
+ * interview. `handleGuidedGoalCommand` runs up to six turns; minting a fresh
20
+ * id per turn opens a new websocket-only Codex socket each time (kept in
21
+ * `providerSessionState` until session dispose), which can trip
22
+ * `websocket_connection_limit_reached` and drop back to the SSE path this
23
+ * fix avoids. Callers pass one id for the whole interview; omitted for
24
+ * one-shot callers, which mint a unique id per call.
25
+ */
26
+ sideSessionId?: string;
17
27
  }
28
+ /** Mint a guided-goal Codex side-session id keyed off the main session id. */
29
+ export declare function newGuidedGoalSessionId(session: AgentSession): string;
18
30
  export declare function runGuidedGoalTurn(session: AgentSession, options: GuidedGoalTurnOptions): Promise<GuidedGoalTurnResult>;
@@ -2,8 +2,9 @@ import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } fr
2
2
  /**
3
3
  * Handler for history:// URLs.
4
4
  *
5
- * Resolves agent ids against the global AgentRegistry, serving transcripts
6
- * for both live and parked agents.
5
+ * Resolves agent ids against the global AgentRegistry, then falls back to
6
+ * on-disk `.jsonl` transcripts, serving read-only history for live, parked,
7
+ * and unregistered agents alike.
7
8
  */
8
9
  export declare class HistoryProtocolHandler implements ProtocolHandler {
9
10
  #private;
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Shared helpers for internal-url protocol handlers that resolve IDs against
3
+ * registered agent sessions.
4
+ */
1
5
  export declare function registerArtifactsDir(dir: string): () => void;
2
6
  export declare function resetRegisteredArtifactDirsForTests(): void;
3
7
  /**
@@ -12,3 +16,18 @@ export declare function resetRegisteredArtifactDirsForTests(): void;
12
16
  * single entry.
13
17
  */
14
18
  export declare function artifactsDirsFromRegistry(): string[];
19
+ /**
20
+ * Recursively scan artifacts dirs for agent session transcripts, keyed by
21
+ * agent id (the `.jsonl` basename). Used by `history://` so transcripts of
22
+ * agents no longer in the registry (unregistered one-shot helpers, released
23
+ * agents, or any agent after session resume) remain reachable — mirroring how
24
+ * `agent://` reads `.md` outputs straight off disk.
25
+ *
26
+ * Layout follows `task/index.ts`: a subagent's transcript is
27
+ * `<artifactsDir>/<AgentId>.jsonl`, and its own children nest one level deeper
28
+ * under `<artifactsDir>/<AgentId>/<AgentId>.<ChildId>.jsonl`. Advisor
29
+ * transcripts (`__advisor*.jsonl`) are observability-only and excluded;
30
+ * EPERM-rewrite backups (`.bak`) are skipped. When the same id appears in
31
+ * multiple dirs, the first hit wins (registry dirs are scanned first).
32
+ */
33
+ export declare function sessionFilesFromDisk(): Promise<Map<string, string>>;
@@ -3,6 +3,8 @@ export interface OAuthEndpoints {
3
3
  authorizationUrl: string;
4
4
  tokenUrl: string;
5
5
  clientId?: string;
6
+ /** Dynamic client registration endpoint advertised by the authorization server. */
7
+ registrationUrl?: string;
6
8
  scopes?: string;
7
9
  resource?: string;
8
10
  }
@@ -52,6 +52,8 @@ export interface MCPOAuthConfig {
52
52
  authorizationUrl: string;
53
53
  /** Token endpoint URL */
54
54
  tokenUrl: string;
55
+ /** Dynamic client registration endpoint advertised by the authorization server. */
56
+ registrationUrl?: string;
55
57
  /** Client ID (optional when already embedded in authorization URL) */
56
58
  clientId?: string;
57
59
  /** Client secret (optional for PKCE flows) */
@@ -69,6 +69,16 @@ export declare class AgentHubOverlayComponent extends Container {
69
69
  dispose(): void;
70
70
  render(width: number): readonly string[];
71
71
  handleInput(keyData: string): void;
72
+ /**
73
+ * Seed the table's left-left close detector with the current time so a single
74
+ * subsequent `←` (within {@link LEFT_TAP_WINDOW_MS}) dismisses the hub.
75
+ *
76
+ * The editor's own double-tap detector consumes the `←←` that opens the hub,
77
+ * leaving this detector at its fresh `0` — without this handoff the user would
78
+ * have to press `←←` a second time to escape. Called by the opener when the hub
79
+ * was raised by that gesture.
80
+ */
81
+ armCloseTap(): void;
72
82
  /**
73
83
  * Open the fullscreen transcript viewer for an agent id (public for table Enter
74
84
  * and tests). Mounts {@link AgentTranscriptViewer} as a `fullscreen` overlay so it
@@ -2,9 +2,11 @@ import type { Component } from "@oh-my-pi/pi-tui";
2
2
  /**
3
3
  * Dynamic border component that adjusts to viewport width.
4
4
  *
5
- * Note: When used from hooks loaded via jiti, the global `theme` may be undefined
6
- * because jiti creates a separate module cache. Always pass an explicit color
7
- * function when using DynamicBorder in components exported for hook use.
5
+ * Note: the module-level `theme` may be `undefined` — when loaded through jiti
6
+ * (separate module cache) or from a second `src` module graph in npm-package
7
+ * installs, where the host bundle assigns `theme` but this copy never sees it
8
+ * (issue #5366). Both the default color and `render()` degrade to plain,
9
+ * unstyled output instead of crashing the TUI.
8
10
  */
9
11
  export declare class DynamicBorder implements Component {
10
12
  #private;
@@ -36,5 +36,7 @@ export declare class LoginDialogComponent extends Container {
36
36
  * Called by onProgress callback
37
37
  */
38
38
  showProgress(message: string): void;
39
+ /** Route non-bracketed paste transports into the active login input. */
40
+ pasteText(text: string): void;
39
41
  handleInput(data: string): void;
40
42
  }
@@ -21,6 +21,7 @@ export interface MCPAddWizardOAuthResult {
21
21
  interface MCPAddWizardOAuthOptions {
22
22
  serverUrl?: string;
23
23
  resource?: string;
24
+ registrationUrl?: string;
24
25
  /**
25
26
  * External cancellation source. Aborting it tears down the in-flight OAuth
26
27
  * flow and surfaces a neutral cancellation error. The wizard wires its own
@@ -6,8 +6,6 @@ export declare function readArgsTargetInternalUrl(args: unknown): boolean;
6
6
  type ReadRenderArgs = {
7
7
  path?: string;
8
8
  file_path?: string;
9
- selector?: string;
10
- sel?: string;
11
9
  };
12
10
  type ReadToolGroupOptions = {
13
11
  showContentPreview?: boolean;
@@ -57,5 +57,6 @@ export declare class SelectorController {
57
57
  showDebugSelector(): Promise<void>;
58
58
  showAgentHub(observers: SessionObserverRegistry, options?: {
59
59
  requireContent?: boolean;
60
+ armCloseTap?: boolean;
60
61
  }): void;
61
62
  }
@@ -330,6 +330,7 @@ export declare class InteractiveMode implements InteractiveModeContext {
330
330
  showDebugSelector(): Promise<void>;
331
331
  showAgentHub(options?: {
332
332
  requireContent?: boolean;
333
+ armCloseTap?: boolean;
333
334
  }): void;
334
335
  resetObserverRegistry(): void;
335
336
  handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
@@ -345,6 +345,7 @@ export interface InteractiveModeContext {
345
345
  showDebugSelector(): Promise<void>;
346
346
  showAgentHub(options?: {
347
347
  requireContent?: boolean;
348
+ armCloseTap?: boolean;
348
349
  }): void;
349
350
  resetObserverRegistry(): void;
350
351
  handleCtrlC(): void;
@@ -134,6 +134,21 @@ export declare function wrapSteeringForModel(messages: AgentMessage[]): AgentMes
134
134
  * pure local mutation and intentionally does neither.
135
135
  */
136
136
  export declare function stripImagesFromMessage(message: AgentMessage): number;
137
+ /**
138
+ * Replace every `ImageContent` block in already-converted LLM {@link Message}s
139
+ * with a text placeholder, returning a new array only when something changed.
140
+ *
141
+ * Unlike {@link stripImagesFromMessage} (which mutates persisted `AgentMessage`s
142
+ * in place), this operates on the ephemeral provider-request view produced by
143
+ * {@link convertToLlm}, so history on disk keeps its images while the outbound
144
+ * request is scrubbed. Used to keep image blocks off the wire when the active
145
+ * model has no vision support (or `images.blockImages` is set) — e.g. after
146
+ * switching from a vision model to a text-only one mid-session (#5400).
147
+ *
148
+ * Consecutive placeholder texts collapse into one so a message that was nothing
149
+ * but images does not balloon into a run of identical notes.
150
+ */
151
+ export declare function replaceLlmImagesWithText(messages: Message[], placeholder: string): Message[];
137
152
  /**
138
153
  * Message type for bash executions via the ! command.
139
154
  */
@@ -8,7 +8,6 @@ import type { OutputMeta } from "./output-meta.js";
8
8
  declare const searchSchema: import("arktype/internal/variants/object.ts").ObjectType<{
9
9
  pattern: string;
10
10
  path?: string | undefined;
11
- selector?: string | undefined;
12
11
  case?: boolean | undefined;
13
12
  gitignore?: boolean | undefined;
14
13
  skip?: number | null | undefined;
@@ -67,7 +66,6 @@ export declare class GrepTool implements AgentTool<typeof searchSchema, GrepTool
67
66
  readonly parameters: import("arktype/internal/variants/object.ts").ObjectType<{
68
67
  pattern: string;
69
68
  path?: string | undefined;
70
- selector?: string | undefined;
71
69
  case?: boolean | undefined;
72
70
  gitignore?: boolean | undefined;
73
71
  skip?: number | null | undefined;
@@ -7,7 +7,6 @@ import { type TruncationResult } from "../session/streaming-output.js";
7
7
  import { type OutputMeta } from "./output-meta.js";
8
8
  declare const readSchema: import("arktype/internal/variants/object.ts").ObjectType<{
9
9
  path: string;
10
- selector?: string | undefined;
11
10
  }, {}>;
12
11
  export type ReadToolInput = typeof readSchema.infer;
13
12
  export interface ReadToolDetails {
@@ -60,7 +59,6 @@ export declare class ReadTool implements AgentTool<typeof readSchema, ReadToolDe
60
59
  readonly description: string;
61
60
  readonly parameters: import("arktype/internal/variants/object.ts").ObjectType<{
62
61
  path: string;
63
- selector?: string | undefined;
64
62
  }, {}>;
65
63
  readonly strict = true;
66
64
  constructor(session: ToolSession);
@@ -69,8 +67,6 @@ export declare class ReadTool implements AgentTool<typeof readSchema, ReadToolDe
69
67
  interface ReadRenderArgs {
70
68
  path?: unknown;
71
69
  file_path?: unknown;
72
- selector?: unknown;
73
- sel?: string;
74
70
  offset?: number;
75
71
  limit?: number;
76
72
  raw?: boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.5.1",
4
+ "version": "16.5.2",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -52,17 +52,17 @@
52
52
  "@agentclientprotocol/sdk": "1.2.1",
53
53
  "@babel/parser": "^7.29.7",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@oh-my-pi/hashline": "16.5.1",
56
- "@oh-my-pi/omp-stats": "16.5.1",
57
- "@oh-my-pi/pi-agent-core": "16.5.1",
58
- "@oh-my-pi/pi-ai": "16.5.1",
59
- "@oh-my-pi/pi-catalog": "16.5.1",
60
- "@oh-my-pi/pi-mnemopi": "16.5.1",
61
- "@oh-my-pi/pi-natives": "16.5.1",
62
- "@oh-my-pi/pi-tui": "16.5.1",
63
- "@oh-my-pi/pi-utils": "16.5.1",
64
- "@oh-my-pi/pi-wire": "16.5.1",
65
- "@oh-my-pi/snapcompact": "16.5.1",
55
+ "@oh-my-pi/hashline": "16.5.2",
56
+ "@oh-my-pi/omp-stats": "16.5.2",
57
+ "@oh-my-pi/pi-agent-core": "16.5.2",
58
+ "@oh-my-pi/pi-ai": "16.5.2",
59
+ "@oh-my-pi/pi-catalog": "16.5.2",
60
+ "@oh-my-pi/pi-mnemopi": "16.5.2",
61
+ "@oh-my-pi/pi-natives": "16.5.2",
62
+ "@oh-my-pi/pi-tui": "16.5.2",
63
+ "@oh-my-pi/pi-utils": "16.5.2",
64
+ "@oh-my-pi/pi-wire": "16.5.2",
65
+ "@oh-my-pi/snapcompact": "16.5.2",
66
66
  "@opentelemetry/api": "^1.9.1",
67
67
  "@opentelemetry/context-async-hooks": "^2.7.1",
68
68
  "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0",
@@ -1856,23 +1856,141 @@ describe("advisor", () => {
1856
1856
  expect(failures).toHaveLength(2);
1857
1857
  });
1858
1858
 
1859
- it("treats an empty stop turn without advise as a failed advisor turn", async () => {
1860
- const promptInputs: string[] = [];
1859
+ it("accepts a zero-usage empty stop as a successful silent review", async () => {
1861
1860
  const turnErrors: unknown[] = [];
1862
1861
  const failures: unknown[] = [];
1863
1862
  const adviceNotes: string[] = [];
1864
1863
  const rollbackCalls: number[] = [];
1865
- const lengthsBeforePrompt: number[] = [];
1866
- const events: string[] = [];
1867
1864
  const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
1868
1865
  let promptCalls = 0;
1869
1866
  const agent: AdvisorAgent = {
1870
1867
  prompt: async input => {
1871
1868
  promptCalls++;
1872
- promptInputs.push(input);
1873
- lengthsBeforePrompt.push(state.messages.length);
1874
- events.push(`prompt:${promptCalls}`);
1875
1869
  state.messages.push({ role: "user", content: input, timestamp: promptCalls * 2 - 1 } as AgentMessage);
1870
+ state.messages.push({
1871
+ role: "assistant",
1872
+ content: [],
1873
+ api: "mock",
1874
+ provider: "mock",
1875
+ model: "mock-advisor",
1876
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
1877
+ stopReason: "stop",
1878
+ timestamp: promptCalls * 2,
1879
+ } as unknown as AgentMessage);
1880
+ state.error = undefined;
1881
+ },
1882
+ abort: () => {},
1883
+ reset: () => {
1884
+ state.messages.length = 0;
1885
+ state.error = undefined;
1886
+ },
1887
+ rollbackTo: count => {
1888
+ rollbackCalls.push(count);
1889
+ state.messages.length = count;
1890
+ state.error = undefined;
1891
+ },
1892
+ state,
1893
+ };
1894
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
1895
+ const host: AdvisorRuntimeHost = {
1896
+ snapshotMessages: () => messages,
1897
+ enqueueAdvice: note => adviceNotes.push(note),
1898
+ onTurnError: error => {
1899
+ turnErrors.push(error);
1900
+ },
1901
+ notifyFailure: error => {
1902
+ failures.push(error);
1903
+ },
1904
+ };
1905
+ const runtime = new AdvisorRuntime(agent, host, 0);
1906
+
1907
+ // A model that says nothing and yields completed its review; no retry,
1908
+ // no rollback, no "Advisor unavailable" notification.
1909
+ runtime.onTurnEnd(messages);
1910
+ await runtime.waitForCatchup(1000, 1);
1911
+
1912
+ expect(promptCalls).toBe(1);
1913
+ expect(turnErrors).toEqual([]);
1914
+ expect(failures).toEqual([]);
1915
+ expect(rollbackCalls).toEqual([]);
1916
+ expect(adviceNotes).toEqual([]);
1917
+ expect(state.messages).toHaveLength(2);
1918
+ expect(runtime.backlog).toBe(0);
1919
+ });
1920
+
1921
+ it("never warns for consecutive zero-usage silent stops — a quiet session is a valid session", async () => {
1922
+ const turnErrors: unknown[] = [];
1923
+ const failures: unknown[] = [];
1924
+ const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
1925
+ let promptCalls = 0;
1926
+ const agent: AdvisorAgent = {
1927
+ prompt: async input => {
1928
+ promptCalls++;
1929
+ state.messages.push({ role: "user", content: input, timestamp: promptCalls * 2 - 1 } as AgentMessage);
1930
+ state.messages.push({
1931
+ role: "assistant",
1932
+ content: [],
1933
+ api: "mock",
1934
+ provider: "mock",
1935
+ model: "mock-advisor",
1936
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
1937
+ stopReason: "stop",
1938
+ timestamp: promptCalls * 2,
1939
+ } as unknown as AgentMessage);
1940
+ state.error = undefined;
1941
+ },
1942
+ abort: () => {},
1943
+ reset: () => {
1944
+ state.messages.length = 0;
1945
+ state.error = undefined;
1946
+ },
1947
+ rollbackTo: count => {
1948
+ state.messages.length = count;
1949
+ state.error = undefined;
1950
+ },
1951
+ state,
1952
+ };
1953
+ const messages: AgentMessage[] = [{ role: "user", content: "turn-0", timestamp: 1 } as AgentMessage];
1954
+ const host: AdvisorRuntimeHost = {
1955
+ snapshotMessages: () => messages,
1956
+ enqueueAdvice: () => {},
1957
+ onTurnError: error => {
1958
+ turnErrors.push(error);
1959
+ },
1960
+ notifyFailure: error => {
1961
+ failures.push(error);
1962
+ },
1963
+ };
1964
+ const runtime = new AdvisorRuntime(agent, host, 0);
1965
+
1966
+ // Five consecutive turns where the advisor has nothing to add: every one
1967
+ // completes as a single successful prompt — no retries, no rollbacks, no
1968
+ // "Advisor unavailable" notification, ever.
1969
+ for (let i = 0; i < 5; i++) {
1970
+ if (i > 0) messages.push({ role: "user", content: `turn-${i}`, timestamp: i + 1 } as AgentMessage);
1971
+ runtime.onTurnEnd(messages);
1972
+ await runtime.waitForCatchup(1000, 1);
1973
+ }
1974
+
1975
+ expect(promptCalls).toBe(5);
1976
+ expect(turnErrors).toEqual([]);
1977
+ expect(failures).toEqual([]);
1978
+ expect(runtime.backlog).toBe(0);
1979
+ });
1980
+
1981
+ it("treats a content-less stop that generated output tokens as a successful silent review", async () => {
1982
+ const turnErrors: unknown[] = [];
1983
+ const failures: unknown[] = [];
1984
+ const adviceNotes: string[] = [];
1985
+ const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
1986
+ let promptCalls = 0;
1987
+ const agent: AdvisorAgent = {
1988
+ prompt: async input => {
1989
+ promptCalls++;
1990
+ state.messages.push({ role: "user", content: input, timestamp: promptCalls * 2 - 1 } as AgentMessage);
1991
+ // A real model turn that CHOSE silence: it reasoned, spent
1992
+ // output/reasoning tokens, and emitted no `advise` call. This is
1993
+ // the documented verifier behavior, not a provider malfunction.
1876
1994
  state.messages.push({
1877
1995
  role: "assistant",
1878
1996
  content: [],
@@ -1880,11 +1998,12 @@ describe("advisor", () => {
1880
1998
  provider: "mock",
1881
1999
  model: "mock-advisor",
1882
2000
  usage: {
1883
- input: 0,
1884
- output: 0,
2001
+ input: 1200,
2002
+ output: 340,
1885
2003
  cacheRead: 0,
1886
2004
  cacheWrite: 0,
1887
- totalTokens: 0,
2005
+ totalTokens: 1540,
2006
+ reasoningTokens: 300,
1888
2007
  },
1889
2008
  stopReason: "stop",
1890
2009
  timestamp: promptCalls * 2,
@@ -1897,23 +2016,22 @@ describe("advisor", () => {
1897
2016
  state.error = undefined;
1898
2017
  },
1899
2018
  rollbackTo: count => {
1900
- rollbackCalls.push(count);
1901
2019
  state.messages.length = count;
1902
2020
  state.error = undefined;
1903
2021
  },
1904
2022
  state,
1905
2023
  };
1906
- const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
2024
+ const messages: AgentMessage[] = [
2025
+ { role: "user", content: "Reply exactly: OK", timestamp: 1 } as AgentMessage,
2026
+ ];
1907
2027
  const host: AdvisorRuntimeHost = {
1908
2028
  snapshotMessages: () => messages,
1909
2029
  enqueueAdvice: note => adviceNotes.push(note),
1910
2030
  onTurnError: error => {
1911
2031
  turnErrors.push(error);
1912
- events.push(`hook:${error instanceof Error ? error.message : String(error)}`);
1913
2032
  },
1914
2033
  notifyFailure: error => {
1915
2034
  failures.push(error);
1916
- events.push(`notify:${error instanceof Error ? error.message : String(error)}`);
1917
2035
  },
1918
2036
  };
1919
2037
  const runtime = new AdvisorRuntime(agent, host, 0);
@@ -1921,42 +2039,11 @@ describe("advisor", () => {
1921
2039
  runtime.onTurnEnd(messages);
1922
2040
  await runtime.waitForCatchup(1000, 1);
1923
2041
 
1924
- expect(promptInputs).toHaveLength(3);
1925
- expect(promptInputs[0]).toContain("aaa");
1926
- expect(promptInputs[1]).toBe(promptInputs[0]);
1927
- expect(promptInputs[2]).toBe(promptInputs[0]);
1928
- expect(lengthsBeforePrompt).toEqual([0, 0, 0]);
1929
- expect(rollbackCalls).toEqual([0, 0, 0]);
1930
- expect(turnErrors).toHaveLength(3);
1931
- const turnErrorMessages = turnErrors.map(error =>
1932
- (error instanceof Error ? error.message : String(error)).toLowerCase(),
1933
- );
1934
- for (const message of turnErrorMessages) {
1935
- expect(message).toContain("advisor");
1936
- expect(message).toContain("empty");
1937
- expect(message).toContain("response");
1938
- }
1939
- expect(failures).toHaveLength(1);
1940
- const failure = failures[0];
1941
- if (!(failure instanceof Error)) throw new Error("expected advisor failure error");
1942
- expect(failure.message.toLowerCase()).toContain("advisor");
1943
- expect(failure.message.toLowerCase()).toContain("empty");
1944
- expect(failure.message.toLowerCase()).toContain("response");
1945
- expect(events).toHaveLength(7);
1946
- expect(events[0]).toBe("prompt:1");
1947
- expect(events[1].startsWith("hook:")).toBe(true);
1948
- expect(events[1].toLowerCase()).toContain("empty");
1949
- expect(events[2]).toBe("prompt:2");
1950
- expect(events[3].startsWith("hook:")).toBe(true);
1951
- expect(events[3].toLowerCase()).toContain("empty");
1952
- expect(events[4]).toBe("prompt:3");
1953
- expect(events[5].startsWith("hook:")).toBe(true);
1954
- expect(events[5].toLowerCase()).toContain("empty");
1955
- expect(events[6].toLowerCase()).toContain("empty");
1956
- expect(events[6].startsWith("notify:")).toBe(true);
2042
+ // No retries, no failure hook, no unavailable notification.
2043
+ expect(promptCalls).toBe(1);
2044
+ expect(turnErrors).toEqual([]);
2045
+ expect(failures).toEqual([]);
1957
2046
  expect(adviceNotes).toEqual([]);
1958
- expect(state.messages).toHaveLength(0);
1959
- expect(state.error).toBeUndefined();
1960
2047
  expect(runtime.backlog).toBe(0);
1961
2048
  });
1962
2049