@oh-my-pi/pi-coding-agent 17.1.5 → 17.1.6

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 (207) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/{CHANGELOG-qs3vd6xf.md → CHANGELOG-x9zt79k8.md} +31 -0
  3. package/dist/cli.js +2989 -2978
  4. package/dist/types/capability/index.d.ts +1 -1
  5. package/dist/types/capability/types.d.ts +23 -1
  6. package/dist/types/config/settings-schema.d.ts +23 -1
  7. package/dist/types/internal-urls/mcp-protocol.d.ts +3 -2
  8. package/dist/types/internal-urls/parse.d.ts +12 -0
  9. package/dist/types/internal-urls/router.d.ts +6 -0
  10. package/dist/types/internal-urls/types.d.ts +6 -0
  11. package/dist/types/lsp/config.d.ts +1 -0
  12. package/dist/types/lsp/types.d.ts +2 -0
  13. package/dist/types/mcp/manager.d.ts +5 -0
  14. package/dist/types/mcp/tool-bridge.d.ts +13 -0
  15. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  16. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  17. package/dist/types/sdk.d.ts +3 -1
  18. package/dist/types/session/agent-session-types.d.ts +3 -1
  19. package/dist/types/session/agent-session.d.ts +2 -0
  20. package/dist/types/session/model-controls.d.ts +3 -0
  21. package/dist/types/session/session-advisors.d.ts +7 -1
  22. package/dist/types/session/tool-choice-queue.d.ts +6 -4
  23. package/dist/types/session/turn-recovery.d.ts +4 -2
  24. package/dist/types/task/index.d.ts +1 -1
  25. package/dist/types/task/types.d.ts +3 -11
  26. package/dist/types/thinking.d.ts +21 -2
  27. package/dist/types/utils/title-generator.d.ts +17 -16
  28. package/package.json +12 -12
  29. package/src/capability/index.ts +43 -12
  30. package/src/capability/mcp.ts +21 -0
  31. package/src/capability/types.ts +20 -1
  32. package/src/cli/read-cli.ts +44 -2
  33. package/src/config/settings-schema.ts +27 -1
  34. package/src/eval/py/runner.py +16 -2
  35. package/src/extensibility/extensions/runner.ts +91 -5
  36. package/src/extensibility/extensions/types.ts +0 -1
  37. package/src/extensibility/hooks/types.ts +0 -1
  38. package/src/internal-urls/mcp-protocol.ts +17 -3
  39. package/src/internal-urls/parse.ts +31 -0
  40. package/src/internal-urls/router.ts +24 -4
  41. package/src/internal-urls/types.ts +6 -0
  42. package/src/live/transport.ts +2 -2
  43. package/src/lsp/client.ts +2 -2
  44. package/src/lsp/config.ts +4 -0
  45. package/src/lsp/types.ts +2 -0
  46. package/src/mcp/config.ts +26 -14
  47. package/src/mcp/manager.ts +26 -9
  48. package/src/mcp/tool-bridge.ts +52 -1
  49. package/src/memories/index.ts +25 -6
  50. package/src/modes/components/status-line/segments.ts +3 -1
  51. package/src/modes/controllers/command-controller.ts +10 -10
  52. package/src/modes/controllers/extension-ui-controller.ts +3 -3
  53. package/src/modes/rpc/rpc-mode.ts +60 -47
  54. package/src/prompts/steering/user-interjection.md +2 -5
  55. package/src/prompts/tools/task.md +4 -2
  56. package/src/sdk.ts +17 -8
  57. package/src/session/agent-session-types.ts +3 -0
  58. package/src/session/agent-session.ts +21 -4
  59. package/src/session/model-controls.ts +43 -7
  60. package/src/session/session-advisors.ts +30 -14
  61. package/src/session/session-tools.ts +4 -2
  62. package/src/session/tool-choice-queue.ts +19 -4
  63. package/src/session/turn-recovery.ts +21 -2
  64. package/src/task/executor.ts +11 -3
  65. package/src/task/index.ts +43 -32
  66. package/src/task/types.ts +12 -17
  67. package/src/thinking.ts +68 -5
  68. package/src/tools/read.ts +2 -2
  69. package/src/utils/title-generator.ts +88 -34
  70. package/dist/types/advisor/__tests__/advisor.test.d.ts +0 -1
  71. package/dist/types/advisor/__tests__/config.test.d.ts +0 -1
  72. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +0 -1
  73. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +0 -1
  74. package/dist/types/cli/update-cli.test.d.ts +0 -1
  75. package/dist/types/config/__tests__/model-registry.test.d.ts +0 -1
  76. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +0 -1
  77. package/dist/types/eval/__tests__/bridge-timeout.test.d.ts +0 -1
  78. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +0 -1
  79. package/dist/types/eval/__tests__/completion-bridge.test.d.ts +0 -1
  80. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +0 -1
  81. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +0 -1
  82. package/dist/types/eval/__tests__/js-context-manager.test.d.ts +0 -1
  83. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +0 -1
  84. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +0 -1
  85. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +0 -1
  86. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +0 -1
  87. package/dist/types/eval/py/__tests__/prelude.test.d.ts +0 -1
  88. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +0 -1
  89. package/dist/types/hindsight/client.test.d.ts +0 -1
  90. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +0 -1
  91. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +0 -1
  92. package/dist/types/launch/broker-list-order.test.d.ts +0 -1
  93. package/dist/types/launch/broker-output-snapshot.test.d.ts +0 -1
  94. package/dist/types/launch/protocol.test.d.ts +0 -1
  95. package/dist/types/launch/spawn-options.test.d.ts +0 -1
  96. package/dist/types/launch/terminal-output.test.d.ts +0 -1
  97. package/dist/types/live/protocol.test.d.ts +0 -1
  98. package/dist/types/mcp/config-writer.test.d.ts +0 -1
  99. package/dist/types/mcp/smithery-auth.test.d.ts +0 -1
  100. package/dist/types/mcp/smithery-registry.test.d.ts +0 -1
  101. package/dist/types/mcp/transports/stdio.test.d.ts +0 -1
  102. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +0 -1
  103. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +0 -1
  104. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +0 -1
  105. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +0 -1
  106. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +0 -1
  107. package/dist/types/modes/components/custom-editor.test.d.ts +0 -1
  108. package/dist/types/modes/components/login-dialog.test.d.ts +0 -1
  109. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +0 -1
  110. package/dist/types/modes/components/status-line/component.test.d.ts +0 -1
  111. package/dist/types/modes/components/tool-execution.test.d.ts +0 -1
  112. package/dist/types/modes/controllers/extension-ui-controller.test.d.ts +0 -1
  113. package/dist/types/modes/noninteractive-dispose.test.d.ts +0 -1
  114. package/dist/types/modes/print-mode.test.d.ts +0 -1
  115. package/dist/types/modes/session-teardown.test.d.ts +0 -1
  116. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +0 -1
  117. package/dist/types/modes/utils/transcript-render-helpers.test.d.ts +0 -1
  118. package/dist/types/modes/warp-events.test.d.ts +0 -1
  119. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +0 -1
  120. package/dist/types/plan-mode/model-transition.test.d.ts +0 -1
  121. package/dist/types/plan-mode/reentry-prompt.test.d.ts +0 -1
  122. package/dist/types/session/agent-session-error-log.test.d.ts +0 -1
  123. package/dist/types/session/blob-store.test.d.ts +0 -1
  124. package/dist/types/session/messages.test.d.ts +0 -1
  125. package/dist/types/session/session-context.test.d.ts +0 -1
  126. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +0 -1
  127. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +0 -1
  128. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +0 -1
  129. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +0 -1
  130. package/dist/types/system-prompt.test.d.ts +0 -1
  131. package/dist/types/task/render.test.d.ts +0 -1
  132. package/dist/types/task/spawn-policy.test.d.ts +0 -1
  133. package/dist/types/tools/__tests__/eval-description.test.d.ts +0 -1
  134. package/dist/types/tools/__tests__/glob.test.d.ts +0 -1
  135. package/dist/types/tools/__tests__/json-tree.test.d.ts +0 -1
  136. package/dist/types/tools/__tests__/vibe-render.test.d.ts +0 -1
  137. package/dist/types/tools/hub/launch-compat.test.d.ts +0 -1
  138. package/dist/types/vibe/__tests__/token-rate.test.d.ts +0 -1
  139. package/src/advisor/__tests__/advisor.test.ts +0 -4889
  140. package/src/advisor/__tests__/config.test.ts +0 -349
  141. package/src/advisor/__tests__/emission-guard.test.ts +0 -147
  142. package/src/cli/__tests__/auth-gateway-catalog.test.ts +0 -111
  143. package/src/cli/update-cli.test.ts +0 -28
  144. package/src/config/__tests__/model-registry.test.ts +0 -182
  145. package/src/eval/__tests__/agent-bridge.test.ts +0 -1509
  146. package/src/eval/__tests__/bridge-timeout.test.ts +0 -170
  147. package/src/eval/__tests__/budget-bridge.test.ts +0 -80
  148. package/src/eval/__tests__/completion-bridge.test.ts +0 -412
  149. package/src/eval/__tests__/helpers-local-roots.test.ts +0 -55
  150. package/src/eval/__tests__/idle-timeout.test.ts +0 -80
  151. package/src/eval/__tests__/js-context-manager.test.ts +0 -456
  152. package/src/eval/__tests__/julia-prelude.test.ts +0 -66
  153. package/src/eval/__tests__/kernel-spawn.test.ts +0 -115
  154. package/src/eval/__tests__/prelude-agent.test.ts +0 -156
  155. package/src/eval/__tests__/process-entry-import.test.ts +0 -137
  156. package/src/eval/py/__tests__/prelude.test.ts +0 -104
  157. package/src/eval/py/__tests__/runner-shell-output.test.ts +0 -157
  158. package/src/hindsight/client.test.ts +0 -75
  159. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +0 -141
  160. package/src/internal-urls/__tests__/ssh-protocol.test.ts +0 -331
  161. package/src/launch/broker-list-order.test.ts +0 -89
  162. package/src/launch/broker-output-snapshot.test.ts +0 -126
  163. package/src/launch/protocol.test.ts +0 -59
  164. package/src/launch/spawn-options.test.ts +0 -31
  165. package/src/launch/terminal-output.test.ts +0 -107
  166. package/src/live/protocol.test.ts +0 -140
  167. package/src/mcp/config-writer.test.ts +0 -43
  168. package/src/mcp/smithery-auth.test.ts +0 -29
  169. package/src/mcp/smithery-registry.test.ts +0 -51
  170. package/src/mcp/transports/stdio.test.ts +0 -427
  171. package/src/modes/components/__tests__/dynamic-border.test.ts +0 -55
  172. package/src/modes/components/__tests__/move-overlay.test.ts +0 -252
  173. package/src/modes/components/__tests__/pause-screen.test.ts +0 -143
  174. package/src/modes/components/__tests__/skill-message.test.ts +0 -94
  175. package/src/modes/components/custom-editor-plugin-ctor.test.ts +0 -36
  176. package/src/modes/components/custom-editor.test.ts +0 -510
  177. package/src/modes/components/login-dialog.test.ts +0 -56
  178. package/src/modes/components/status-line/component.jj-cache.test.ts +0 -229
  179. package/src/modes/components/status-line/component.test.ts +0 -84
  180. package/src/modes/components/tool-execution.test.ts +0 -162
  181. package/src/modes/controllers/extension-ui-controller.test.ts +0 -250
  182. package/src/modes/noninteractive-dispose.test.ts +0 -73
  183. package/src/modes/print-mode.test.ts +0 -71
  184. package/src/modes/session-teardown.test.ts +0 -219
  185. package/src/modes/theme/mermaid-rendering.test.ts +0 -53
  186. package/src/modes/utils/transcript-render-helpers.test.ts +0 -38
  187. package/src/modes/warp-events.test.ts +0 -794
  188. package/src/plan-mode/approved-plan-prompt.test.ts +0 -36
  189. package/src/plan-mode/model-transition.test.ts +0 -60
  190. package/src/plan-mode/reentry-prompt.test.ts +0 -41
  191. package/src/session/agent-session-error-log.test.ts +0 -59
  192. package/src/session/blob-store.test.ts +0 -56
  193. package/src/session/messages.test.ts +0 -282
  194. package/src/session/session-context.test.ts +0 -384
  195. package/src/ssh/__tests__/connection-manager-args.test.ts +0 -191
  196. package/src/ssh/__tests__/connection-manager-timeout.test.ts +0 -61
  197. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +0 -105
  198. package/src/ssh/__tests__/sshfs-mount.test.ts +0 -13
  199. package/src/system-prompt.test.ts +0 -236
  200. package/src/task/render.test.ts +0 -290
  201. package/src/task/spawn-policy.test.ts +0 -62
  202. package/src/tools/__tests__/eval-description.test.ts +0 -18
  203. package/src/tools/__tests__/glob.test.ts +0 -37
  204. package/src/tools/__tests__/json-tree.test.ts +0 -35
  205. package/src/tools/__tests__/vibe-render.test.ts +0 -210
  206. package/src/tools/hub/launch-compat.test.ts +0 -40
  207. package/src/vibe/__tests__/token-rate.test.ts +0 -96
@@ -1,170 +0,0 @@
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 { executeWithKernelBase, type GenericKernel } from "../executor-base";
9
- import type { JsStatusEvent } from "../js/shared/types";
10
- import type { KernelDisplayOutput } from "../py/display";
11
-
12
- describe("withBridgeTimeoutPause", () => {
13
- it("emits one pause before the operation and one resume after it settles", async () => {
14
- const events: JsStatusEvent[] = [];
15
-
16
- const value = await withBridgeTimeoutPause(
17
- event => events.push(event),
18
- async () => {
19
- await Bun.sleep(80);
20
- return "done";
21
- },
22
- { deferExternalAbort: true },
23
- );
24
-
25
- expect(value).toBe("done");
26
- expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
27
- expect(events.every(event => event.deferExternalAbort === true)).toBe(true);
28
-
29
- const settledCount = events.length;
30
- await Bun.sleep(40);
31
- expect(events.length).toBe(settledCount);
32
- });
33
-
34
- it("resumes timeout accounting even when the operation throws", async () => {
35
- const events: JsStatusEvent[] = [];
36
-
37
- await expect(
38
- withBridgeTimeoutPause(
39
- event => events.push(event),
40
- async () => {
41
- await Bun.sleep(20);
42
- throw new Error("boom");
43
- },
44
- ),
45
- ).rejects.toThrow("boom");
46
-
47
- expect(events.map(event => event.op)).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
48
- });
49
-
50
- it("runs the operation without emitting when no status sink is wired", async () => {
51
- let ran = 0;
52
-
53
- const value = await withBridgeTimeoutPause(undefined, async () => {
54
- ran++;
55
- await Bun.sleep(20);
56
- return 42;
57
- });
58
-
59
- expect(value).toBe(42);
60
- expect(ran).toBe(1);
61
- });
62
-
63
- it("identifies timeout-control events as non-renderable status", () => {
64
- expect(isEvalTimeoutControlEvent({ op: EVAL_TIMEOUT_PAUSE_OP })).toBe(true);
65
- expect(isEvalTimeoutControlEvent({ op: EVAL_TIMEOUT_RESUME_OP })).toBe(true);
66
- expect(isEvalTimeoutControlEvent({ op: "agent", id: "subagent-1" })).toBe(false);
67
- });
68
- });
69
-
70
- class TestCancelledError extends Error {
71
- readonly timedOut: boolean;
72
-
73
- constructor(timedOut: boolean) {
74
- super(timedOut ? "timed out" : "cancelled");
75
- this.name = "TestCancelledError";
76
- this.timedOut = timedOut;
77
- }
78
- }
79
-
80
- it("defers external aborts until an in-flight agent bridge call resumes", async () => {
81
- const abortController = new AbortController();
82
- const entered = Promise.withResolvers<void>();
83
- const triggerAbort = Promise.withResolvers<void>();
84
- const observed = Promise.withResolvers<boolean>();
85
- const release = Promise.withResolvers<void>();
86
- const kernel: GenericKernel<Record<string, string | null>> = {
87
- async execute(_code, options) {
88
- entered.resolve();
89
- await triggerAbort.promise;
90
- options.onDisplay({
91
- type: "status",
92
- event: { op: EVAL_TIMEOUT_PAUSE_OP, deferExternalAbort: true },
93
- } satisfies KernelDisplayOutput);
94
- abortController.abort(new Error("external interrupt"));
95
- observed.resolve(options.signal?.aborted ?? false);
96
- await release.promise;
97
- options.onDisplay({
98
- type: "status",
99
- event: { op: EVAL_TIMEOUT_RESUME_OP, deferExternalAbort: true },
100
- } satisfies KernelDisplayOutput);
101
- return { status: "ok", cancelled: false, timedOut: false };
102
- },
103
- };
104
-
105
- const resultPromise = executeWithKernelBase({
106
- kernel,
107
- code: "agent('slow')",
108
- options: { signal: abortController.signal },
109
- runIdPrefix: "test",
110
- errorLogLabel: "test",
111
- cancelledErrorClass: TestCancelledError,
112
- buildKernelEnvPatch: () => ({}),
113
- formatKernelTimeoutAnnotation: () => "kernel timed out",
114
- formatTimeoutAnnotation: () => "timed out",
115
- });
116
-
117
- await entered.promise;
118
- triggerAbort.resolve();
119
- expect(await observed.promise).toBe(false);
120
- release.resolve();
121
- const result = await resultPromise;
122
- expect(result.cancelled).toBe(true);
123
- expect(result.exitCode).toBeUndefined();
124
- });
125
-
126
- it("does not defer external aborts for a completion bridge call", async () => {
127
- const abortController = new AbortController();
128
- const entered = Promise.withResolvers<void>();
129
- const triggerAbort = Promise.withResolvers<void>();
130
- const observed = Promise.withResolvers<boolean>();
131
- const release = Promise.withResolvers<void>();
132
- const kernel: GenericKernel<Record<string, string | null>> = {
133
- async execute(_code, options) {
134
- entered.resolve();
135
- await triggerAbort.promise;
136
- options.onDisplay({
137
- type: "status",
138
- event: { op: EVAL_TIMEOUT_PAUSE_OP },
139
- } satisfies KernelDisplayOutput);
140
- abortController.abort(new Error("external interrupt"));
141
- observed.resolve(options.signal?.aborted ?? false);
142
- await release.promise;
143
- options.onDisplay({
144
- type: "status",
145
- event: { op: EVAL_TIMEOUT_RESUME_OP },
146
- } satisfies KernelDisplayOutput);
147
- return { status: "ok", cancelled: false, timedOut: false };
148
- },
149
- };
150
-
151
- const resultPromise = executeWithKernelBase({
152
- kernel,
153
- code: "completion('slow')",
154
- options: { signal: abortController.signal },
155
- runIdPrefix: "test",
156
- errorLogLabel: "test",
157
- cancelledErrorClass: TestCancelledError,
158
- buildKernelEnvPatch: () => ({}),
159
- formatKernelTimeoutAnnotation: () => "kernel timed out",
160
- formatTimeoutAnnotation: () => "timed out",
161
- });
162
-
163
- await entered.promise;
164
- triggerAbort.resolve();
165
- expect(await observed.promise).toBe(true);
166
- release.resolve();
167
- const result = await resultPromise;
168
- expect(result.cancelled).toBe(true);
169
- expect(result.exitCode).toBeUndefined();
170
- });
@@ -1,80 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import type { GoalModeState } from "../../goals/state";
3
- import type { UsageStatistics } from "../../session/session-entries";
4
- import type { ToolSession } from "../../tools";
5
- import { runEvalBudget } from "../budget-bridge";
6
-
7
- type TurnBudget = { total: number | null; spent: number; hard: boolean };
8
-
9
- function makeSession(parts: { turn?: TurnBudget; goal?: GoalModeState; usage?: UsageStatistics }): ToolSession {
10
- return {
11
- getTurnBudget: parts.turn ? () => parts.turn as TurnBudget : undefined,
12
- getGoalModeState: parts.goal ? () => parts.goal : undefined,
13
- getUsageStatistics: parts.usage ? () => parts.usage as UsageStatistics : undefined,
14
- } as unknown as ToolSession;
15
- }
16
-
17
- function goalState(extra: Partial<GoalModeState["goal"]>): GoalModeState {
18
- return {
19
- enabled: true,
20
- mode: "active",
21
- goal: { id: "g1", status: "active", tokensUsed: 0, timeUsedSeconds: 0, ...extra },
22
- } as GoalModeState;
23
- }
24
-
25
- function usage(output: number): UsageStatistics {
26
- return {
27
- input: 0,
28
- output,
29
- cacheRead: 0,
30
- cacheWrite: 0,
31
- totalTokens: output,
32
- orchestrationInput: 0,
33
- orchestrationOutput: 0,
34
- orchestrationCacheRead: 0,
35
- premiumRequests: 0,
36
- cost: 0,
37
- };
38
- }
39
-
40
- describe("runEvalBudget", () => {
41
- it("prefers an active +Nk turn directive over Goal Mode", async () => {
42
- const session = makeSession({
43
- turn: { total: 200_000, spent: 5_000, hard: true },
44
- goal: goalState({ tokenBudget: 100_000, tokensUsed: 4_200 }),
45
- });
46
- expect(await runEvalBudget({}, { session })).toEqual({ total: 200_000, spent: 5_000, hard: true });
47
- });
48
-
49
- it("reports an advisory turn budget as hard:false", async () => {
50
- const session = makeSession({ turn: { total: 50_000, spent: 1_000, hard: false } });
51
- expect(await runEvalBudget({}, { session })).toEqual({ total: 50_000, spent: 1_000, hard: false });
52
- });
53
-
54
- it("falls through to Goal Mode when no turn directive set a ceiling", async () => {
55
- const session = makeSession({
56
- turn: { total: null, spent: 7_777, hard: false },
57
- goal: goalState({ tokenBudget: 100_000, tokensUsed: 4_200 }),
58
- });
59
- expect(await runEvalBudget({}, { session })).toEqual({ total: 100_000, spent: 4_200, hard: true });
60
- });
61
-
62
- it("treats a Goal Mode budget as hard, and a budgetless goal as no ceiling", async () => {
63
- const withBudget = makeSession({ goal: goalState({ tokenBudget: 80_000, tokensUsed: 9_000 }) });
64
- expect(await runEvalBudget({}, { session: withBudget })).toEqual({ total: 80_000, spent: 9_000, hard: true });
65
-
66
- const noBudget = makeSession({ goal: goalState({ tokenBudget: undefined, tokensUsed: 1_234 }) });
67
- expect(await runEvalBudget({}, { session: noBudget })).toEqual({ total: null, spent: 1_234, hard: false });
68
- });
69
-
70
- it("reports no ceiling but still surfaces spend", async () => {
71
- const fromTurn = makeSession({ turn: { total: null, spent: 333, hard: false } });
72
- expect(await runEvalBudget({}, { session: fromTurn })).toEqual({ total: null, spent: 333, hard: false });
73
-
74
- const fromUsage = makeSession({ usage: usage(777) });
75
- expect(await runEvalBudget({}, { session: fromUsage })).toEqual({ total: null, spent: 777, hard: false });
76
-
77
- const empty = makeSession({});
78
- expect(await runEvalBudget({}, { session: empty })).toEqual({ total: null, spent: 0, hard: false });
79
- });
80
- });
@@ -1,412 +0,0 @@
1
- import { afterAll, afterEach, describe, expect, it, vi } from "bun:test";
2
- import * as path from "node:path";
3
- import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
4
- import * as ai from "@oh-my-pi/pi-ai";
5
- import { Effort } from "@oh-my-pi/pi-ai";
6
- import { TempDir } from "@oh-my-pi/pi-utils";
7
- import { $ } from "bun";
8
- import type { ModelRegistry } from "../../config/model-registry";
9
- import { Settings } from "../../config/settings";
10
- import type { ToolSession } from "../../tools";
11
- import { ToolError } from "../../tools/tool-errors";
12
- import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../bridge-timeout";
13
- import { runEvalCompletion } from "../completion-bridge";
14
- import { IdleTimeout } from "../idle-timeout";
15
- import { disposeAllVmContexts } from "../js/context-manager";
16
- import { executeJs } from "../js/executor";
17
- import { disposeAllKernelSessions, type PythonResult } from "../py/executor";
18
-
19
- function makeModel(provider: string, id: string, extra: Partial<Model<Api>> = {}): Model<Api> {
20
- return {
21
- id,
22
- name: id,
23
- api: "openai-responses",
24
- provider,
25
- baseUrl: "https://example.test/v1",
26
- reasoning: false,
27
- input: ["text"],
28
- cost: { input: 1, output: 1, cacheRead: 0, cacheWrite: 1 },
29
- contextWindow: 128000,
30
- maxTokens: 4096,
31
- ...extra,
32
- } as Model<Api>;
33
- }
34
-
35
- const SMOL = makeModel("p", "smol");
36
- const DEFAULT = makeModel("p", "default");
37
- const SLOW = makeModel("p", "slow");
38
- const REASONING_SLOW = makeModel("p", "slow", {
39
- api: "anthropic-messages",
40
- reasoning: true,
41
- thinking: { efforts: [Effort.Low, Effort.Medium, Effort.High], mode: "anthropic-adaptive" },
42
- });
43
-
44
- interface SessionOptions {
45
- available?: Model<Api>[];
46
- apiKey?: string | null;
47
- activeModel?: string;
48
- roles?: Partial<Record<"smol" | "default" | "slow", string>>;
49
- }
50
-
51
- function makeSession(opts: SessionOptions = {}): ToolSession {
52
- const settings = Settings.isolated({ "async.enabled": false, "task.isolation.mode": "none" });
53
- const roles = opts.roles ?? { smol: "p/smol", slow: "p/slow" };
54
- for (const role in roles) {
55
- const value = roles[role as keyof typeof roles];
56
- if (value) settings.setModelRole(role, value);
57
- }
58
- const modelRegistry = {
59
- getAvailable: () => opts.available ?? [SMOL, DEFAULT, SLOW],
60
- getApiKey: async () => (opts.apiKey === undefined ? "test-key" : opts.apiKey),
61
- resolver: () => async () => (opts.apiKey === undefined ? "test-key" : opts.apiKey),
62
- } as unknown as ModelRegistry;
63
- return {
64
- settings,
65
- modelRegistry,
66
- getActiveModelString: () => opts.activeModel ?? "p/default",
67
- } as unknown as ToolSession;
68
- }
69
-
70
- function assistant(opts: {
71
- text?: string;
72
- toolCall?: { name: string; arguments: Record<string, unknown> };
73
- stopReason?: AssistantMessage["stopReason"];
74
- errorMessage?: string;
75
- }): AssistantMessage {
76
- const content: AssistantMessage["content"] = [];
77
- if (opts.text) content.push({ type: "text", text: opts.text });
78
- if (opts.toolCall) {
79
- content.push({ type: "toolCall", id: "tc-1", name: opts.toolCall.name, arguments: opts.toolCall.arguments });
80
- }
81
- return {
82
- role: "assistant",
83
- content,
84
- api: "openai-responses",
85
- provider: "p",
86
- model: "default",
87
- usage: {
88
- input: 0,
89
- output: 0,
90
- cacheRead: 0,
91
- cacheWrite: 0,
92
- totalTokens: 0,
93
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
94
- },
95
- stopReason: opts.stopReason ?? "stop",
96
- errorMessage: opts.errorMessage,
97
- timestamp: Date.now(),
98
- };
99
- }
100
-
101
- async function runPythonCompletionInSubprocess(options: {
102
- structured: boolean;
103
- tempDir: TempDir;
104
- }): Promise<PythonResult> {
105
- const repoRoot = path.resolve(import.meta.dir, "../../../..");
106
- const scriptPath = path.join(options.tempDir.path(), "run-python-completion.ts");
107
- const resultPath = path.join(options.tempDir.path(), "python-completion-result.json");
108
- const aiPath = path.resolve(import.meta.dir, "../../../../ai/src/index.ts");
109
- const executorPath = path.resolve(import.meta.dir, "../py/executor.ts");
110
- const settingsPath = path.resolve(import.meta.dir, "../../config/settings.ts");
111
- const code = options.structured
112
- ? 'import json\nprint(json.dumps(completion("hi", schema={"type": "object"})))'
113
- : 'print(completion("hi", model="smol"))';
114
- const responseContent = options.structured
115
- ? '[{ type: "toolCall", id: "tc-1", name: "respond", arguments: { ok: true } }]'
116
- : '[{ type: "text", text: "hello from python" }]';
117
- await Bun.write(
118
- scriptPath,
119
- `
120
- import { vi } from "bun:test";
121
- import * as ai from ${JSON.stringify(aiPath)};
122
- import { executePython } from ${JSON.stringify(executorPath)};
123
- import { Settings } from ${JSON.stringify(settingsPath)};
124
-
125
- const SMOL = {
126
- id: "smol",
127
- name: "smol",
128
- api: "openai-responses",
129
- provider: "p",
130
- baseUrl: "https://example.test/v1",
131
- reasoning: false,
132
- input: ["text"],
133
- cost: { input: 1, output: 1, cacheRead: 0, cacheWrite: 1 },
134
- contextWindow: 128000,
135
- maxTokens: 4096,
136
- };
137
- const settings = Settings.isolated({ "async.enabled": false, "task.isolation.mode": "none" });
138
- settings.setModelRole("smol", "p/smol");
139
- settings.setModelRole("slow", "p/slow");
140
- const session = {
141
- settings,
142
- modelRegistry: {
143
- getAvailable: () => [SMOL],
144
- getApiKey: async () => "test-key",
145
- resolver: () => async () => "test-key",
146
- },
147
- getActiveModelString: () => "p/smol",
148
- };
149
- vi.spyOn(ai, "completeSimple").mockResolvedValue({
150
- role: "assistant",
151
- api: "openai-responses",
152
- provider: "p",
153
- model: "smol",
154
- stopReason: "stop",
155
- content: ${responseContent},
156
- });
157
- const result = await executePython(${JSON.stringify(code)}, {
158
- cwd: ${JSON.stringify(options.tempDir.path())},
159
- sessionId: ${JSON.stringify(`py-completion:${options.structured ? "struct" : "plain"}`)},
160
- sessionFile: ${JSON.stringify(path.join(options.tempDir.path(), "session.jsonl"))},
161
- toolSession: session,
162
- kernelMode: "per-call",
163
- });
164
- await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify(result));
165
- process.exit(0);
166
- `,
167
- );
168
- const child = await $`bun ${scriptPath}`.cwd(repoRoot).quiet().nothrow();
169
- const stdout = child.stdout.toString();
170
- const stderr = child.stderr.toString();
171
- if (child.exitCode !== 0)
172
- throw new Error(stderr || stdout || `Python completion subprocess exited with ${child.exitCode}`);
173
- return (await Bun.file(resultPath).json()) as PythonResult;
174
- }
175
-
176
- describe("runEvalCompletion", () => {
177
- afterEach(() => {
178
- vi.restoreAllMocks();
179
- });
180
-
181
- it("resolves each tier to its expected model", async () => {
182
- const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
183
- const session = makeSession();
184
-
185
- await runEvalCompletion({ prompt: "q", model: "smol" }, { session });
186
- await runEvalCompletion({ prompt: "q", model: "default" }, { session });
187
- await runEvalCompletion({ prompt: "q", model: "slow" }, { session });
188
-
189
- const resolved = spy.mock.calls.map(call => {
190
- const model = call[0] as Model<Api>;
191
- return `${model.provider}/${model.id}`;
192
- });
193
- expect(resolved).toEqual(["p/smol", "p/default", "p/slow"]);
194
- });
195
-
196
- it("prefers the session active model for the default tier, falling back to @default", async () => {
197
- const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
198
- const session = makeSession({ available: [SMOL, DEFAULT, SLOW], activeModel: "p/slow" });
199
-
200
- await runEvalCompletion({ prompt: "q", model: "default" }, { session });
201
-
202
- const model = spy.mock.calls[0]?.[0] as Model<Api>;
203
- expect(`${model.provider}/${model.id}`).toBe("p/slow");
204
- });
205
-
206
- it("returns the completion text in plain mode", async () => {
207
- vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "the answer" }));
208
- const result = await runEvalCompletion({ prompt: "q", model: "smol" }, { session: makeSession() });
209
- expect(result.text).toBe("the answer");
210
- expect(result.details).toEqual({ model: "p/smol", tier: "smol", structured: false });
211
- });
212
-
213
- it("supplies a non-empty systemPrompt when system is omitted (codex 'Instructions are required' guard)", async () => {
214
- // The openai-codex Responses transformer drops `instructions` when no
215
- // system prompt is provided, and the remote endpoint then 400s with
216
- // "Instructions are required". runEvalCompletion must always carry a non-empty
217
- // systemPrompt so `completion("…")` without a `system` argument works.
218
- const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
219
- await runEvalCompletion({ prompt: "q", model: "smol" }, { session: makeSession() });
220
- const ctx = spy.mock.calls[0]?.[1] as { systemPrompt?: string[] };
221
- expect(ctx.systemPrompt).toBeDefined();
222
- expect(ctx.systemPrompt?.length).toBeGreaterThan(0);
223
- expect(ctx.systemPrompt?.[0]).toMatch(/.+/);
224
- });
225
-
226
- it("honors an explicit system prompt instead of overriding it", async () => {
227
- const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
228
- await runEvalCompletion({ prompt: "q", model: "smol", system: "Be terse." }, { session: makeSession() });
229
- const ctx = spy.mock.calls[0]?.[1] as { systemPrompt?: string[] };
230
- expect(ctx.systemPrompt).toEqual(["Be terse."]);
231
- });
232
-
233
- it("forces a respond tool call and returns its arguments in structured mode", async () => {
234
- const spy = vi
235
- .spyOn(ai, "completeSimple")
236
- .mockResolvedValue(assistant({ toolCall: { name: "respond", arguments: { answer: 42 } } }));
237
- const result = await runEvalCompletion(
238
- { prompt: "q", model: "smol", schema: { type: "object", properties: { answer: { type: "number" } } } },
239
- { session: makeSession() },
240
- );
241
-
242
- expect(JSON.parse(result.text)).toEqual({ answer: 42 });
243
- expect(result.details.structured).toBe(true);
244
-
245
- const ctx = spy.mock.calls[0]?.[1] as { tools?: Array<{ name: string }> };
246
- const opts = spy.mock.calls[0]?.[2] as { toolChoice?: unknown };
247
- expect(ctx.tools?.[0]?.name).toBe("respond");
248
- expect(opts.toolChoice).toEqual({ type: "tool", name: "respond" });
249
- });
250
-
251
- it("falls back to JSON embedded in text when the model skips the respond tool", async () => {
252
- vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: 'here: {"answer": 7}' }));
253
- const result = await runEvalCompletion(
254
- { prompt: "q", model: "smol", schema: { type: "object" } },
255
- { session: makeSession() },
256
- );
257
- expect(JSON.parse(result.text)).toEqual({ answer: 7 });
258
- });
259
-
260
- it("requests reasoning only for the slow tier on a reasoning-capable model", async () => {
261
- const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
262
- const session = makeSession({ available: [SMOL, DEFAULT, REASONING_SLOW] });
263
-
264
- await runEvalCompletion({ prompt: "q", model: "smol" }, { session });
265
- await runEvalCompletion({ prompt: "q", model: "slow" }, { session });
266
-
267
- const smolOpts = spy.mock.calls[0]?.[2] as { reasoning?: unknown };
268
- const slowOpts = spy.mock.calls[1]?.[2] as { reasoning?: unknown };
269
- expect(smolOpts.reasoning).toBeUndefined();
270
- expect(slowOpts.reasoning).toBe(Effort.High);
271
- });
272
-
273
- it("does not request reasoning for the slow tier on a non-reasoning model", async () => {
274
- const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
275
- // SLOW is reasoning:false — must not trip requireSupportedEffort downstream.
276
- const result = await runEvalCompletion({ prompt: "q", model: "slow" }, { session: makeSession() });
277
- expect(result.text).toBe("ok");
278
- const opts = spy.mock.calls[0]?.[2] as { reasoning?: unknown };
279
- expect(opts.reasoning).toBeUndefined();
280
- });
281
-
282
- it("throws ToolError on invalid arguments", async () => {
283
- await expect(runEvalCompletion({ prompt: "" }, { session: makeSession() })).rejects.toBeInstanceOf(ToolError);
284
- await expect(
285
- runEvalCompletion({ prompt: "q", model: "huge" }, { session: makeSession() }),
286
- ).rejects.toBeInstanceOf(ToolError);
287
- });
288
-
289
- it("throws ToolError when no model resolves for the tier", async () => {
290
- const session = makeSession({ available: [DEFAULT], roles: { smol: "missing/model" } });
291
- await expect(runEvalCompletion({ prompt: "q", model: "smol" }, { session })).rejects.toBeInstanceOf(ToolError);
292
- });
293
-
294
- it("throws ToolError when the resolved model has no API key", async () => {
295
- const session = makeSession({ apiKey: null });
296
- await expect(runEvalCompletion({ prompt: "q", model: "smol" }, { session })).rejects.toBeInstanceOf(ToolError);
297
- });
298
-
299
- it("maps error and aborted stop reasons to ToolError", async () => {
300
- vi.spyOn(ai, "completeSimple").mockResolvedValueOnce(assistant({ stopReason: "error", errorMessage: "boom" }));
301
- await expect(runEvalCompletion({ prompt: "q", model: "smol" }, { session: makeSession() })).rejects.toThrow(
302
- "boom",
303
- );
304
-
305
- vi.spyOn(ai, "completeSimple").mockResolvedValueOnce(assistant({ stopReason: "aborted" }));
306
- await expect(
307
- runEvalCompletion({ prompt: "q", model: "smol" }, { session: makeSession() }),
308
- ).rejects.toBeInstanceOf(ToolError);
309
- });
310
-
311
- it("throws ToolError when plain mode produces no text", async () => {
312
- vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "" }));
313
- await expect(
314
- runEvalCompletion({ prompt: "q", model: "smol" }, { session: makeSession() }),
315
- ).rejects.toBeInstanceOf(ToolError);
316
- });
317
-
318
- it("pauses the idle watchdog while a slow completion() request is in flight", async () => {
319
- // A oneshot completion emits no status until it returns; delegated model
320
- // time must be invisible to the eval timeout budget.
321
- vi.spyOn(ai, "completeSimple").mockImplementation(async () => {
322
- await Bun.sleep(200);
323
- return assistant({ text: "the answer" });
324
- });
325
-
326
- const ops: string[] = [];
327
- using idle = new IdleTimeout(60);
328
- const result = await runEvalCompletion(
329
- { prompt: "q", model: "smol" },
330
- {
331
- session: makeSession(),
332
- signal: idle.signal,
333
- emitStatus: event => {
334
- ops.push(event.op);
335
- if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause();
336
- if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume();
337
- },
338
- },
339
- );
340
-
341
- expect(result.text).toBe("the answer");
342
- expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP, "completion"]);
343
- expect(idle.signal.aborted).toBe(false);
344
- });
345
- });
346
-
347
- describe("completion() through eval runtimes", () => {
348
- afterEach(() => {
349
- vi.restoreAllMocks();
350
- });
351
-
352
- afterAll(async () => {
353
- await disposeAllVmContexts();
354
- await disposeAllKernelSessions();
355
- });
356
-
357
- it("exposes completion() in the JavaScript runtime", async () => {
358
- using tempDir = TempDir.createSync("@omp-eval-completion-js-");
359
- const sessionFile = path.join(tempDir.path(), "session.jsonl");
360
- const sessionId = `js-completion:${crypto.randomUUID()}`;
361
- vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "hello from smol" }));
362
-
363
- const result = await executeJs('return await completion("hi", { model: "smol" });', {
364
- cwd: tempDir.path(),
365
- sessionId,
366
- session: makeSession(),
367
- sessionFile,
368
- });
369
-
370
- expect(result.exitCode).toBe(0);
371
- expect(result.output.trim()).toBe("hello from smol");
372
- });
373
-
374
- it("parses structured completion() output in the JavaScript runtime", async () => {
375
- using tempDir = TempDir.createSync("@omp-eval-completion-js-struct-");
376
- const sessionFile = path.join(tempDir.path(), "session.jsonl");
377
- const sessionId = `js-completion-struct:${crypto.randomUUID()}`;
378
- vi.spyOn(ai, "completeSimple").mockResolvedValue(
379
- assistant({ toolCall: { name: "respond", arguments: { ok: true, n: 3 } } }),
380
- );
381
-
382
- const result = await executeJs(
383
- 'const r = await completion("hi", { schema: { type: "object" } }); return JSON.stringify(r);',
384
- { cwd: tempDir.path(), sessionId, session: makeSession(), sessionFile },
385
- );
386
-
387
- expect(result.exitCode).toBe(0);
388
- expect(JSON.parse(result.output.trim())).toEqual({ ok: true, n: 3 });
389
- });
390
-
391
- it("exposes completion() in the Python runtime", async () => {
392
- const tempDir = TempDir.createSync("@omp-eval-completion-py-");
393
- try {
394
- const result = await runPythonCompletionInSubprocess({ structured: false, tempDir });
395
- expect(result.exitCode).toBe(0);
396
- expect(result.output.trim()).toBe("hello from python");
397
- } finally {
398
- tempDir.removeSync();
399
- }
400
- });
401
-
402
- it("parses structured completion() output in the Python runtime", async () => {
403
- const tempDir = TempDir.createSync("@omp-eval-completion-py-struct-");
404
- try {
405
- const result = await runPythonCompletionInSubprocess({ structured: true, tempDir });
406
- expect(result.exitCode).toBe(0);
407
- expect(JSON.parse(result.output.trim())).toEqual({ ok: true });
408
- } finally {
409
- tempDir.removeSync();
410
- }
411
- });
412
- });