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

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 (244) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/{CHANGELOG-qs3vd6xf.md → CHANGELOG-5k4dq4g6.md} +54 -0
  3. package/dist/cli.js +3342 -3274
  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 +39 -2
  7. package/dist/types/cursor.d.ts +2 -1
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -0
  9. package/dist/types/extensibility/shared-events.d.ts +18 -1
  10. package/dist/types/internal-urls/mcp-protocol.d.ts +3 -2
  11. package/dist/types/internal-urls/parse.d.ts +12 -0
  12. package/dist/types/internal-urls/router.d.ts +6 -0
  13. package/dist/types/internal-urls/types.d.ts +6 -0
  14. package/dist/types/lsp/config.d.ts +1 -0
  15. package/dist/types/lsp/types.d.ts +2 -0
  16. package/dist/types/mcp/manager.d.ts +5 -0
  17. package/dist/types/mcp/tool-bridge.d.ts +13 -0
  18. package/dist/types/modes/components/custom-editor.d.ts +5 -0
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  20. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  21. package/dist/types/sdk.d.ts +3 -1
  22. package/dist/types/session/agent-session-types.d.ts +8 -6
  23. package/dist/types/session/agent-session.d.ts +28 -1
  24. package/dist/types/session/model-controls.d.ts +4 -1
  25. package/dist/types/session/session-advisors.d.ts +7 -1
  26. package/dist/types/session/session-tools.d.ts +44 -6
  27. package/dist/types/session/streaming-output.d.ts +7 -2
  28. package/dist/types/session/tool-choice-queue.d.ts +6 -4
  29. package/dist/types/session/turn-recovery.d.ts +5 -3
  30. package/dist/types/task/index.d.ts +1 -1
  31. package/dist/types/task/types.d.ts +3 -11
  32. package/dist/types/thinking.d.ts +21 -2
  33. package/dist/types/tools/index.d.ts +8 -3
  34. package/dist/types/tools/output-meta.d.ts +5 -0
  35. package/dist/types/tools/read.d.ts +9 -1
  36. package/dist/types/tools/xdev.d.ts +53 -67
  37. package/dist/types/utils/cpuprofile.d.ts +51 -0
  38. package/dist/types/utils/inspect-image-mode.d.ts +29 -0
  39. package/dist/types/utils/profile-tree.d.ts +47 -0
  40. package/dist/types/utils/sample-profile.d.ts +67 -0
  41. package/dist/types/utils/title-generator.d.ts +17 -16
  42. package/package.json +12 -12
  43. package/src/capability/index.ts +43 -12
  44. package/src/capability/mcp.ts +21 -0
  45. package/src/capability/types.ts +20 -1
  46. package/src/cli/read-cli.ts +44 -2
  47. package/src/config/settings-schema.ts +42 -2
  48. package/src/config/settings.ts +35 -0
  49. package/src/cursor.ts +4 -3
  50. package/src/discovery/builtin-rules/index.ts +2 -0
  51. package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
  52. package/src/eval/py/runner.py +16 -2
  53. package/src/extensibility/extensions/runner.ts +117 -5
  54. package/src/extensibility/extensions/types.ts +0 -1
  55. package/src/extensibility/extensions/wrapper.ts +74 -42
  56. package/src/extensibility/hooks/tool-wrapper.ts +11 -4
  57. package/src/extensibility/hooks/types.ts +0 -1
  58. package/src/extensibility/shared-events.ts +18 -1
  59. package/src/internal-urls/mcp-protocol.ts +17 -3
  60. package/src/internal-urls/parse.ts +31 -0
  61. package/src/internal-urls/router.ts +24 -4
  62. package/src/internal-urls/types.ts +6 -0
  63. package/src/live/transport.ts +2 -2
  64. package/src/lsp/client.ts +2 -2
  65. package/src/lsp/config.ts +4 -0
  66. package/src/lsp/types.ts +2 -0
  67. package/src/mcp/config.ts +26 -14
  68. package/src/mcp/manager.ts +26 -9
  69. package/src/mcp/tool-bridge.ts +52 -1
  70. package/src/memories/index.ts +25 -6
  71. package/src/modes/components/custom-editor.ts +39 -16
  72. package/src/modes/components/status-line/segments.ts +3 -1
  73. package/src/modes/components/tips.txt +2 -1
  74. package/src/modes/components/tool-execution.ts +8 -7
  75. package/src/modes/controllers/command-controller.ts +10 -10
  76. package/src/modes/controllers/extension-ui-controller.ts +7 -7
  77. package/src/modes/controllers/selector-controller.ts +7 -2
  78. package/src/modes/rpc/rpc-mode.ts +60 -47
  79. package/src/prompts/steering/user-interjection.md +2 -5
  80. package/src/prompts/tools/task.md +4 -2
  81. package/src/sdk.ts +64 -58
  82. package/src/session/agent-session-types.ts +8 -5
  83. package/src/session/agent-session.ts +144 -11
  84. package/src/session/model-controls.ts +48 -12
  85. package/src/session/session-advisors.ts +30 -14
  86. package/src/session/session-listing.ts +66 -4
  87. package/src/session/session-tools.ts +162 -54
  88. package/src/session/streaming-output.ts +18 -6
  89. package/src/session/tool-choice-queue.ts +19 -4
  90. package/src/session/turn-recovery.ts +25 -6
  91. package/src/slash-commands/builtin-registry.ts +69 -0
  92. package/src/task/executor.ts +11 -3
  93. package/src/task/index.ts +43 -32
  94. package/src/task/types.ts +12 -17
  95. package/src/thinking.ts +68 -5
  96. package/src/tools/bash.ts +16 -9
  97. package/src/tools/index.ts +36 -16
  98. package/src/tools/output-meta.ts +20 -0
  99. package/src/tools/read.ts +89 -15
  100. package/src/tools/write.ts +16 -7
  101. package/src/tools/xdev.ts +198 -210
  102. package/src/utils/cpuprofile.ts +235 -0
  103. package/src/utils/inspect-image-mode.ts +39 -0
  104. package/src/utils/profile-tree.ts +111 -0
  105. package/src/utils/sample-profile.ts +437 -0
  106. package/src/utils/title-generator.ts +88 -34
  107. package/dist/types/advisor/__tests__/advisor.test.d.ts +0 -1
  108. package/dist/types/advisor/__tests__/config.test.d.ts +0 -1
  109. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +0 -1
  110. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +0 -1
  111. package/dist/types/cli/update-cli.test.d.ts +0 -1
  112. package/dist/types/config/__tests__/model-registry.test.d.ts +0 -1
  113. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +0 -1
  114. package/dist/types/eval/__tests__/bridge-timeout.test.d.ts +0 -1
  115. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +0 -1
  116. package/dist/types/eval/__tests__/completion-bridge.test.d.ts +0 -1
  117. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +0 -1
  118. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +0 -1
  119. package/dist/types/eval/__tests__/js-context-manager.test.d.ts +0 -1
  120. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +0 -1
  121. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +0 -1
  122. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +0 -1
  123. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +0 -1
  124. package/dist/types/eval/py/__tests__/prelude.test.d.ts +0 -1
  125. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +0 -1
  126. package/dist/types/hindsight/client.test.d.ts +0 -1
  127. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +0 -1
  128. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +0 -1
  129. package/dist/types/launch/broker-list-order.test.d.ts +0 -1
  130. package/dist/types/launch/broker-output-snapshot.test.d.ts +0 -1
  131. package/dist/types/launch/protocol.test.d.ts +0 -1
  132. package/dist/types/launch/spawn-options.test.d.ts +0 -1
  133. package/dist/types/launch/terminal-output.test.d.ts +0 -1
  134. package/dist/types/live/protocol.test.d.ts +0 -1
  135. package/dist/types/mcp/config-writer.test.d.ts +0 -1
  136. package/dist/types/mcp/smithery-auth.test.d.ts +0 -1
  137. package/dist/types/mcp/smithery-registry.test.d.ts +0 -1
  138. package/dist/types/mcp/transports/stdio.test.d.ts +0 -1
  139. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +0 -1
  140. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +0 -1
  141. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +0 -1
  142. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +0 -1
  143. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +0 -1
  144. package/dist/types/modes/components/custom-editor.test.d.ts +0 -1
  145. package/dist/types/modes/components/login-dialog.test.d.ts +0 -1
  146. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +0 -1
  147. package/dist/types/modes/components/status-line/component.test.d.ts +0 -1
  148. package/dist/types/modes/components/tool-execution.test.d.ts +0 -1
  149. package/dist/types/modes/controllers/extension-ui-controller.test.d.ts +0 -1
  150. package/dist/types/modes/noninteractive-dispose.test.d.ts +0 -1
  151. package/dist/types/modes/print-mode.test.d.ts +0 -1
  152. package/dist/types/modes/session-teardown.test.d.ts +0 -1
  153. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +0 -1
  154. package/dist/types/modes/utils/transcript-render-helpers.test.d.ts +0 -1
  155. package/dist/types/modes/warp-events.test.d.ts +0 -1
  156. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +0 -1
  157. package/dist/types/plan-mode/model-transition.test.d.ts +0 -1
  158. package/dist/types/plan-mode/reentry-prompt.test.d.ts +0 -1
  159. package/dist/types/session/agent-session-error-log.test.d.ts +0 -1
  160. package/dist/types/session/blob-store.test.d.ts +0 -1
  161. package/dist/types/session/messages.test.d.ts +0 -1
  162. package/dist/types/session/session-context.test.d.ts +0 -1
  163. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +0 -1
  164. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +0 -1
  165. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +0 -1
  166. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +0 -1
  167. package/dist/types/system-prompt.test.d.ts +0 -1
  168. package/dist/types/task/render.test.d.ts +0 -1
  169. package/dist/types/task/spawn-policy.test.d.ts +0 -1
  170. package/dist/types/tools/__tests__/eval-description.test.d.ts +0 -1
  171. package/dist/types/tools/__tests__/glob.test.d.ts +0 -1
  172. package/dist/types/tools/__tests__/json-tree.test.d.ts +0 -1
  173. package/dist/types/tools/__tests__/vibe-render.test.d.ts +0 -1
  174. package/dist/types/tools/hub/launch-compat.test.d.ts +0 -1
  175. package/dist/types/vibe/__tests__/token-rate.test.d.ts +0 -1
  176. package/src/advisor/__tests__/advisor.test.ts +0 -4889
  177. package/src/advisor/__tests__/config.test.ts +0 -349
  178. package/src/advisor/__tests__/emission-guard.test.ts +0 -147
  179. package/src/cli/__tests__/auth-gateway-catalog.test.ts +0 -111
  180. package/src/cli/update-cli.test.ts +0 -28
  181. package/src/config/__tests__/model-registry.test.ts +0 -182
  182. package/src/eval/__tests__/agent-bridge.test.ts +0 -1509
  183. package/src/eval/__tests__/bridge-timeout.test.ts +0 -170
  184. package/src/eval/__tests__/budget-bridge.test.ts +0 -80
  185. package/src/eval/__tests__/completion-bridge.test.ts +0 -412
  186. package/src/eval/__tests__/helpers-local-roots.test.ts +0 -55
  187. package/src/eval/__tests__/idle-timeout.test.ts +0 -80
  188. package/src/eval/__tests__/js-context-manager.test.ts +0 -456
  189. package/src/eval/__tests__/julia-prelude.test.ts +0 -66
  190. package/src/eval/__tests__/kernel-spawn.test.ts +0 -115
  191. package/src/eval/__tests__/prelude-agent.test.ts +0 -156
  192. package/src/eval/__tests__/process-entry-import.test.ts +0 -137
  193. package/src/eval/py/__tests__/prelude.test.ts +0 -104
  194. package/src/eval/py/__tests__/runner-shell-output.test.ts +0 -157
  195. package/src/hindsight/client.test.ts +0 -75
  196. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +0 -141
  197. package/src/internal-urls/__tests__/ssh-protocol.test.ts +0 -331
  198. package/src/launch/broker-list-order.test.ts +0 -89
  199. package/src/launch/broker-output-snapshot.test.ts +0 -126
  200. package/src/launch/protocol.test.ts +0 -59
  201. package/src/launch/spawn-options.test.ts +0 -31
  202. package/src/launch/terminal-output.test.ts +0 -107
  203. package/src/live/protocol.test.ts +0 -140
  204. package/src/mcp/config-writer.test.ts +0 -43
  205. package/src/mcp/smithery-auth.test.ts +0 -29
  206. package/src/mcp/smithery-registry.test.ts +0 -51
  207. package/src/mcp/transports/stdio.test.ts +0 -427
  208. package/src/modes/components/__tests__/dynamic-border.test.ts +0 -55
  209. package/src/modes/components/__tests__/move-overlay.test.ts +0 -252
  210. package/src/modes/components/__tests__/pause-screen.test.ts +0 -143
  211. package/src/modes/components/__tests__/skill-message.test.ts +0 -94
  212. package/src/modes/components/custom-editor-plugin-ctor.test.ts +0 -36
  213. package/src/modes/components/custom-editor.test.ts +0 -510
  214. package/src/modes/components/login-dialog.test.ts +0 -56
  215. package/src/modes/components/status-line/component.jj-cache.test.ts +0 -229
  216. package/src/modes/components/status-line/component.test.ts +0 -84
  217. package/src/modes/components/tool-execution.test.ts +0 -162
  218. package/src/modes/controllers/extension-ui-controller.test.ts +0 -250
  219. package/src/modes/noninteractive-dispose.test.ts +0 -73
  220. package/src/modes/print-mode.test.ts +0 -71
  221. package/src/modes/session-teardown.test.ts +0 -219
  222. package/src/modes/theme/mermaid-rendering.test.ts +0 -53
  223. package/src/modes/utils/transcript-render-helpers.test.ts +0 -38
  224. package/src/modes/warp-events.test.ts +0 -794
  225. package/src/plan-mode/approved-plan-prompt.test.ts +0 -36
  226. package/src/plan-mode/model-transition.test.ts +0 -60
  227. package/src/plan-mode/reentry-prompt.test.ts +0 -41
  228. package/src/session/agent-session-error-log.test.ts +0 -59
  229. package/src/session/blob-store.test.ts +0 -56
  230. package/src/session/messages.test.ts +0 -282
  231. package/src/session/session-context.test.ts +0 -384
  232. package/src/ssh/__tests__/connection-manager-args.test.ts +0 -191
  233. package/src/ssh/__tests__/connection-manager-timeout.test.ts +0 -61
  234. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +0 -105
  235. package/src/ssh/__tests__/sshfs-mount.test.ts +0 -13
  236. package/src/system-prompt.test.ts +0 -236
  237. package/src/task/render.test.ts +0 -290
  238. package/src/task/spawn-policy.test.ts +0 -62
  239. package/src/tools/__tests__/eval-description.test.ts +0 -18
  240. package/src/tools/__tests__/glob.test.ts +0 -37
  241. package/src/tools/__tests__/json-tree.test.ts +0 -35
  242. package/src/tools/__tests__/vibe-render.test.ts +0 -210
  243. package/src/tools/hub/launch-compat.test.ts +0 -40
  244. package/src/vibe/__tests__/token-rate.test.ts +0 -96
@@ -1,384 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
- import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
- import * as snapcompact from "@oh-my-pi/snapcompact";
5
- import { type CompactionSummaryMessage, INTERRUPTED_THINKING_MESSAGE_TYPE } from "./messages";
6
- import { buildSessionContext, type StrippedToolCallsMarker } from "./session-context";
7
- import type { SessionEntry } from "./session-entries";
8
-
9
- const timestamp = "2026-07-09T00:00:00.000Z";
10
-
11
- const compactedEntries = [
12
- {
13
- type: "message",
14
- id: "m1",
15
- parentId: null,
16
- timestamp,
17
- message: { role: "user", content: [{ type: "text", text: "before compaction" }], timestamp: 1 },
18
- },
19
- {
20
- type: "compaction",
21
- id: "c1",
22
- parentId: "m1",
23
- timestamp,
24
- summary: "summary",
25
- firstKeptEntryId: "m1",
26
- tokensBefore: 123,
27
- preserveData: {
28
- [snapcompact.PRESERVE_KEY]: {
29
- frames: [{ data: "base64-frame", mimeType: "image/png", cols: 10, rows: 10, chars: 100 }],
30
- totalChars: 100,
31
- truncatedChars: 0,
32
- textHead: "head",
33
- textTail: "tail",
34
- },
35
- },
36
- },
37
- {
38
- type: "message",
39
- id: "m2",
40
- parentId: "c1",
41
- timestamp,
42
- message: { role: "user", content: [{ type: "text", text: "after compaction" }], timestamp: 2 },
43
- },
44
- ] satisfies SessionEntry[];
45
-
46
- function compactionSummary(messages: AgentMessage[]): CompactionSummaryMessage {
47
- const summary = messages.find(
48
- (message): message is CompactionSummaryMessage => message.role === "compactionSummary",
49
- );
50
- if (!summary) throw new Error("Expected a compaction summary message");
51
- return summary;
52
- }
53
-
54
- describe("buildSessionContext snapcompact archives", () => {
55
- it("omits snapcompact archive blocks from collapsed transcript summaries", () => {
56
- const context = buildSessionContext(compactedEntries, undefined, undefined, {
57
- transcript: true,
58
- collapseCompactedHistory: true,
59
- });
60
-
61
- const summary = compactionSummary(context.messages);
62
-
63
- expect(summary.images).toBeUndefined();
64
- expect(summary.blocks).toBeUndefined();
65
- });
66
-
67
- it("keeps snapcompact archive blocks in full transcript summaries", () => {
68
- const context = buildSessionContext(compactedEntries, undefined, undefined, { transcript: true });
69
-
70
- const summary = compactionSummary(context.messages);
71
-
72
- expect(summary.images?.map(image => image.data)).toEqual(["base64-frame"]);
73
- expect(summary.blocks?.map(block => block.type)).toEqual(["text", "image", "text"]);
74
- });
75
-
76
- it("keeps snapcompact archive blocks in provider context summaries", () => {
77
- const context = buildSessionContext(compactedEntries);
78
-
79
- const summary = compactionSummary(context.messages);
80
-
81
- expect(summary.images?.map(image => image.data)).toEqual(["base64-frame"]);
82
- expect(summary.blocks?.map(block => block.type)).toEqual(["text", "image", "text"]);
83
- });
84
- });
85
-
86
- // A turn whose tool is still executing at rebuild time: the assistant message
87
- // (with its toolCall) is persisted at message_end, the toolResult is not.
88
- const danglingToolCallEntries = [
89
- {
90
- type: "message",
91
- id: "m1",
92
- parentId: null,
93
- timestamp,
94
- message: { role: "user", content: [{ type: "text", text: "run it" }], timestamp: 1 },
95
- },
96
- {
97
- type: "message",
98
- id: "m2",
99
- parentId: "m1",
100
- timestamp,
101
- message: {
102
- role: "assistant",
103
- content: [{ type: "toolCall", id: "call-1", name: "bash", arguments: { command: "sleep 60" } }],
104
- api: "anthropic-messages",
105
- provider: "anthropic",
106
- model: "claude-sonnet-4-5",
107
- usage: {
108
- input: 1,
109
- output: 1,
110
- cacheRead: 0,
111
- cacheWrite: 0,
112
- totalTokens: 2,
113
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
114
- },
115
- stopReason: "toolUse",
116
- timestamp: 2,
117
- },
118
- },
119
- ] satisfies SessionEntry[];
120
-
121
- function danglingCallIds(messages: AgentMessage[]): string[] {
122
- const ids: string[] = [];
123
- for (const message of messages) {
124
- if (message.role !== "assistant") continue;
125
- for (const block of message.content) {
126
- if (block.type === "toolCall") ids.push(block.id);
127
- }
128
- }
129
- return ids;
130
- }
131
-
132
- describe("buildSessionContext dangling toolCalls", () => {
133
- it("strips a dangling toolCall from the transcript but keeps the turn with a stripped marker", () => {
134
- const context = buildSessionContext(danglingToolCallEntries, undefined, undefined, { transcript: true });
135
-
136
- expect(danglingCallIds(context.messages)).toEqual([]);
137
- // The turn survives (even content-less) carrying the marker so the TUI
138
- // renders a placeholder row instead of silently erasing the activity.
139
- const assistant = context.messages.find(message => message.role === "assistant");
140
- expect(assistant).toBeDefined();
141
- expect(assistant?.content).toEqual([]);
142
- expect((assistant as AgentMessage & StrippedToolCallsMarker).strippedToolCalls).toBe(1);
143
- });
144
-
145
- it("keeps a dangling toolCall in transcript mode with keepDanglingToolCalls", () => {
146
- const context = buildSessionContext(danglingToolCallEntries, undefined, undefined, {
147
- transcript: true,
148
- keepDanglingToolCalls: true,
149
- });
150
-
151
- expect(danglingCallIds(context.messages)).toEqual(["call-1"]);
152
- });
153
-
154
- it("always strips dangling toolCalls from the LLM context and drops the emptied turn", () => {
155
- const context = buildSessionContext(danglingToolCallEntries, undefined, undefined, {
156
- keepDanglingToolCalls: true,
157
- });
158
-
159
- expect(danglingCallIds(context.messages)).toEqual([]);
160
- expect(context.messages.some(message => message.role === "assistant")).toBe(false);
161
- });
162
- });
163
-
164
- const assistantUsage: AssistantMessage["usage"] = {
165
- input: 0,
166
- output: 0,
167
- cacheRead: 0,
168
- cacheWrite: 0,
169
- totalTokens: 0,
170
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
171
- };
172
-
173
- function userEntry(id: string, parentId: string | null, content: string, messageTimestamp: number): SessionEntry {
174
- return {
175
- type: "message",
176
- id,
177
- parentId,
178
- timestamp,
179
- message: { role: "user", content, timestamp: messageTimestamp } as AgentMessage,
180
- };
181
- }
182
-
183
- function assistantEntry(
184
- id: string,
185
- parentId: string | null,
186
- stopReason: AssistantMessage["stopReason"],
187
- text: string,
188
- messageTimestamp: number,
189
- ): SessionEntry {
190
- return {
191
- type: "message",
192
- id,
193
- parentId,
194
- timestamp,
195
- message: {
196
- role: "assistant",
197
- content: [{ type: "text", text }],
198
- api: "anthropic-messages",
199
- provider: "anthropic",
200
- model: "claude-sonnet-4-5",
201
- usage: assistantUsage,
202
- stopReason,
203
- timestamp: messageTimestamp,
204
- } satisfies AssistantMessage,
205
- };
206
- }
207
-
208
- function toolCallAssistantEntry(
209
- id: string,
210
- parentId: string | null,
211
- stopReason: AssistantMessage["stopReason"],
212
- toolCallId: string,
213
- messageTimestamp: number,
214
- ): SessionEntry {
215
- return {
216
- type: "message",
217
- id,
218
- parentId,
219
- timestamp,
220
- message: {
221
- role: "assistant",
222
- content: [{ type: "toolCall", id: toolCallId, name: "write", arguments: { path: "plan.md", content: "x" } }],
223
- api: "anthropic-messages",
224
- provider: "anthropic",
225
- model: "claude-sonnet-4-5",
226
- usage: assistantUsage,
227
- stopReason,
228
- timestamp: messageTimestamp,
229
- } satisfies AssistantMessage,
230
- };
231
- }
232
-
233
- function syntheticToolResultEntry(
234
- id: string,
235
- parentId: string | null,
236
- toolCallId: string,
237
- messageTimestamp: number,
238
- ): SessionEntry {
239
- return {
240
- type: "message",
241
- id,
242
- parentId,
243
- timestamp,
244
- message: {
245
- role: "toolResult",
246
- toolCallId,
247
- toolName: "write",
248
- content: [
249
- { type: "text", text: "Tool call was not executed because the provider stream ended with an error." },
250
- ],
251
- details: { __synthetic: true, source: "assistant_stop_error", executed: false },
252
- isError: true,
253
- timestamp: messageTimestamp,
254
- } as AgentMessage,
255
- };
256
- }
257
-
258
- function hiddenContinuityEntry(id: string, parentId: string | null): SessionEntry {
259
- return {
260
- type: "custom_message",
261
- id,
262
- parentId,
263
- timestamp,
264
- customType: INTERRUPTED_THINKING_MESSAGE_TYPE,
265
- content: "preserved interrupted thinking",
266
- display: false,
267
- attribution: "agent",
268
- };
269
- }
270
-
271
- function expectUserTail(messages: AgentMessage[], content: string): void {
272
- const tail = messages.at(-1);
273
- expect(tail?.role).toBe("user");
274
- if (tail?.role !== "user") {
275
- throw new Error(`Expected user tail, received ${tail?.role ?? "none"}`);
276
- }
277
- expect(tail.content).toBe(content);
278
- }
279
-
280
- describe("buildSessionContext failed replay tails", () => {
281
- it("terminates on cyclic parent links and includes each reachable message once", () => {
282
- const entries = [userEntry("A", "B", "from A", 1), userEntry("B", "A", "from B", 2)];
283
-
284
- const context = buildSessionContext(entries, "A");
285
-
286
- expect(context.messages.map(message => (message.role === "user" ? message.content : message.role))).toEqual([
287
- "from B",
288
- "from A",
289
- ]);
290
- });
291
-
292
- it("omits a terminal aborted assistant from normal context", () => {
293
- const context = buildSessionContext([
294
- userEntry("user", null, "continue", 1),
295
- assistantEntry("assistant", "user", "aborted", "partial unsafe replay", 2),
296
- ]);
297
-
298
- expect(context.messages.some(message => message.role === "assistant")).toBe(false);
299
- expectUserTail(context.messages, "continue");
300
- });
301
-
302
- it("omits an earlier aborted assistant before a later user from normal context", () => {
303
- const context = buildSessionContext([
304
- userEntry("user-1", null, "first prompt", 1),
305
- assistantEntry("assistant", "user-1", "aborted", "partial unsafe replay", 2),
306
- userEntry("user-2", "assistant", "retry", 3),
307
- ]);
308
-
309
- expect(context.messages.some(message => message.role === "assistant")).toBe(false);
310
- expectUserTail(context.messages, "retry");
311
- });
312
-
313
- it("preserves a terminal aborted assistant in transcript mode", () => {
314
- const context = buildSessionContext(
315
- [
316
- userEntry("user", null, "continue", 1),
317
- assistantEntry("assistant", "user", "aborted", "visible transcript error", 2),
318
- ],
319
- undefined,
320
- undefined,
321
- { transcript: true },
322
- );
323
-
324
- const assistant = context.messages.find(message => message.role === "assistant");
325
- expect(assistant?.role).toBe("assistant");
326
- if (assistant?.role !== "assistant") {
327
- throw new Error(`Expected transcript assistant, received ${assistant?.role ?? "none"}`);
328
- }
329
- expect(assistant.stopReason).toBe("aborted");
330
- expect(assistant.content).toEqual([{ type: "text", text: "visible transcript error" }]);
331
- });
332
-
333
- it("omits a terminal error assistant from normal context", () => {
334
- const context = buildSessionContext([
335
- userEntry("user", null, "retry with smaller input", 1),
336
- assistantEntry("assistant", "user", "error", "provider rejected the request", 2),
337
- ]);
338
-
339
- expect(context.messages.some(message => message.role === "assistant")).toBe(false);
340
- expectUserTail(context.messages, "retry with smaller input");
341
- });
342
-
343
- it("keeps an aborted assistant when hidden interrupted-thinking continuity follows it", () => {
344
- const context = buildSessionContext([
345
- userEntry("user", null, "keep reasoning continuity", 1),
346
- assistantEntry("assistant", "user", "aborted", "partial answer before interrupt", 2),
347
- hiddenContinuityEntry("continuity", "assistant"),
348
- ]);
349
-
350
- const assistant = context.messages.find(message => message.role === "assistant");
351
- expect(assistant?.role).toBe("assistant");
352
- if (assistant?.role !== "assistant") {
353
- throw new Error(`Expected assistant before continuity, received ${assistant?.role ?? "none"}`);
354
- }
355
- expect(assistant.stopReason).toBe("aborted");
356
- expect(context.messages.at(-1)?.role).toBe("custom");
357
- });
358
-
359
- it("drops synthetic tool results paired with a dropped failed tool-call turn", () => {
360
- const context = buildSessionContext([
361
- userEntry("user", null, "write the plan", 1),
362
- toolCallAssistantEntry("assistant", "user", "error", "call-1", 2),
363
- syntheticToolResultEntry("result", "assistant", "call-1", 3),
364
- ]);
365
-
366
- expect(context.messages.map(message => message.role)).toEqual(["user"]);
367
- expectUserTail(context.messages, "write the plan");
368
- });
369
-
370
- it("keeps the failed tool-call turn and its result in transcript mode", () => {
371
- const context = buildSessionContext(
372
- [
373
- userEntry("user", null, "write the plan", 1),
374
- toolCallAssistantEntry("assistant", "user", "error", "call-1", 2),
375
- syntheticToolResultEntry("result", "assistant", "call-1", 3),
376
- ],
377
- undefined,
378
- undefined,
379
- { transcript: true, keepDanglingToolCalls: true },
380
- );
381
-
382
- expect(context.messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult"]);
383
- });
384
- });
@@ -1,191 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import * as fs from "node:fs";
3
- import * as path from "node:path";
4
- import { getRemoteHostDir } from "@oh-my-pi/pi-utils";
5
- import {
6
- buildRemoteCommand,
7
- extractProbePayload,
8
- findProbeMarker,
9
- getHostInfo,
10
- HOST_PROBE_MARKER,
11
- osFromUname,
12
- parseHostInfo,
13
- type SSHConnectionTarget,
14
- type SSHHostShell,
15
- TRANSFER_PROBE_MARKER,
16
- } from "../connection-manager";
17
- import { buildSshTarget, sanitizeHostName } from "../utils";
18
-
19
- const TARGET: SSHConnectionTarget = { name: "h", host: "h" };
20
-
21
- describe("buildRemoteCommand stdin handling", () => {
22
- it("includes -n by default so ssh reads stdin from /dev/null", async () => {
23
- const args = await buildRemoteCommand(TARGET, "cat");
24
- expect(args).toContain("-n");
25
- });
26
-
27
- it("omits -n when allowStdin is set so the remote command reads piped stdin", async () => {
28
- const args = await buildRemoteCommand(TARGET, "cat", { allowStdin: true });
29
- expect(args).not.toContain("-n");
30
- });
31
- });
32
-
33
- describe("buildSshTarget argument-injection guard", () => {
34
- it("rejects a host that begins with '-' (ssh would parse it as an option)", () => {
35
- expect(() => buildSshTarget(undefined, "-oProxyCommand=touch /tmp/pwned")).toThrow(/must not begin with/);
36
- });
37
-
38
- it("rejects a username that begins with '-'", () => {
39
- expect(() => buildSshTarget("-oProxyCommand=x", "host")).toThrow(/must not begin with/);
40
- });
41
-
42
- it("renders a normal destination unchanged", () => {
43
- expect(buildSshTarget("user", "host")).toBe("user@host");
44
- expect(buildSshTarget(undefined, "host")).toBe("host");
45
- });
46
-
47
- it("rejects a dash-leading host through the real buildRemoteCommand path", async () => {
48
- await expect(buildRemoteCommand({ name: "x", host: "-oProxyCommand=x" }, "cat")).rejects.toThrow(
49
- /must not begin with/,
50
- );
51
- });
52
- });
53
-
54
- describe("ssh host shell classification", () => {
55
- it("treats fish/csh/tcsh as non-POSIX (unknown) and keeps real sh-family as sh", async () => {
56
- // parseHostInfo re-runs parseShell on the stored shell field, so getHostInfo
57
- // exercises the classifier through a public seam. The ensurePosixRemote
58
- // whitelist then refuses anything that isn't sh/bash/zsh.
59
- const cases: Array<[string, SSHHostShell]> = [
60
- ["/usr/bin/fish", "unknown"],
61
- ["/bin/csh", "unknown"],
62
- ["/bin/tcsh", "unknown"],
63
- ["/bin/dash", "sh"],
64
- ["/bin/sh", "sh"],
65
- ["/usr/bin/bash", "bash"],
66
- ["/usr/bin/zsh", "zsh"],
67
- ];
68
- for (const [shellValue, expected] of cases) {
69
- const name = `omp-shellclf-${crypto.randomUUID()}`;
70
- const file = path.join(getRemoteHostDir(), `${sanitizeHostName(name)}.json`);
71
- await Bun.write(file, JSON.stringify({ version: 3, os: "linux", shell: shellValue, compatEnabled: false }));
72
- try {
73
- const info = await getHostInfo(name);
74
- expect(info?.shell).toBe(expected);
75
- } finally {
76
- await fs.promises.rm(file, { force: true });
77
- }
78
- }
79
- });
80
- });
81
-
82
- describe("extractProbePayload (host probe framing)", () => {
83
- it("returns the text after the first marker line, ignoring login banners", async () => {
84
- // Real-world failure shape: noisy dotfiles print a banner before the
85
- // echo we asked for, so the legacy first-line parser would have read
86
- // `Last login: ...` and classified the host as unknown (#3719).
87
- const stdout = [
88
- "Last login: Wed Mar 19 09:14:22 2025 from 10.0.0.1",
89
- "Welcome to fancybox 1.0",
90
- `${HOST_PROBE_MARKER}linux-gnu|/bin/bash|5.2.21`,
91
- ].join("\n");
92
- expect(extractProbePayload(stdout, "")).toBe("linux-gnu|/bin/bash|5.2.21");
93
- });
94
-
95
- it("falls back to stderr when the payload only shows up there", async () => {
96
- // Some shells redirect every echo to stderr after a dotfile error; the
97
- // parser needs to recover the marker line from either stream.
98
- const stderr = `noise\n${HOST_PROBE_MARKER}darwin|/bin/zsh|\n`;
99
- expect(extractProbePayload("", stderr)).toBe("darwin|/bin/zsh|");
100
- });
101
-
102
- it("returns null when no marker line is present", async () => {
103
- expect(extractProbePayload("just login banner\n", "and stderr noise\n")).toBeNull();
104
- });
105
- });
106
-
107
- describe("findProbeMarker (transfer-shell probe recovery)", () => {
108
- it("returns the tail after the marker when it appears in stdout", () => {
109
- // Happy path: `sh -lc 'printf "PI_TRANSFER_OK|"; uname -s'` lands in
110
- // stdout. The tail is the uname output the caller uses to refine OS.
111
- const stdout = `${TRANSFER_PROBE_MARKER}Linux\n`;
112
- expect(findProbeMarker(stdout, "", TRANSFER_PROBE_MARKER)).toBe("Linux\n");
113
- });
114
-
115
- it("falls back to stderr when a broken dotfile swaps fd 1/2", () => {
116
- // Some remotes have dotfiles that redirect every shell write to stderr.
117
- // The transfer probe must still recognize the marker so ssh:// doesn't
118
- // refuse a POSIX-capable host (#3722 review).
119
- const stderr = `dotfile noise\n${TRANSFER_PROBE_MARKER}Darwin\n`;
120
- expect(findProbeMarker("", stderr, TRANSFER_PROBE_MARKER)).toBe("Darwin\n");
121
- });
122
-
123
- it("prefers stdout over stderr when the marker is in both", () => {
124
- // Order matters: stdout is the canonical path, stderr is the rescue.
125
- // A reordering bug would silently use stale stderr fragments first.
126
- const stdout = `${TRANSFER_PROBE_MARKER}Linux`;
127
- const stderr = `${TRANSFER_PROBE_MARKER}stale`;
128
- expect(findProbeMarker(stdout, stderr, TRANSFER_PROBE_MARKER)).toBe("Linux");
129
- });
130
-
131
- it("returns null when the marker is in neither stream", () => {
132
- expect(findProbeMarker("noise", "more noise", TRANSFER_PROBE_MARKER)).toBeNull();
133
- });
134
- });
135
-
136
- describe("osFromUname (transfer-shell probe OS recovery)", () => {
137
- it("classifies common POSIX uname payloads", () => {
138
- // The markerless host-info fallback uses the transfer-shell probe's
139
- // uname output to avoid returning a durable `os: "unknown"` when csh/tcsh
140
- // killed the first marker probe before it could echo anything (#3722 review).
141
- expect(osFromUname("Linux")).toBe("linux");
142
- expect(osFromUname("GNU/Linux")).toBe("linux");
143
- expect(osFromUname("Darwin")).toBe("macos");
144
- });
145
-
146
- it("classifies Windows compat unames as windows so ssh:// still refuses them", () => {
147
- expect(osFromUname("MINGW64_NT-10.0")).toBe("windows");
148
- expect(osFromUname("MSYS_NT-10.0")).toBe("windows");
149
- expect(osFromUname("CYGWIN_NT-10.0")).toBe("windows");
150
- });
151
-
152
- it("returns undefined when uname is not recognized", () => {
153
- expect(osFromUname("")).toBeUndefined();
154
- expect(osFromUname("SunOS")).toBeUndefined();
155
- });
156
- });
157
-
158
- describe("parseHostInfo transferShell handling", () => {
159
- it("round-trips a verified transferShell value", () => {
160
- // Cache writers persist `transferShell` so callers don't re-probe
161
- // every session; parseHostInfo must thread it back through (#3719).
162
- const parsed = parseHostInfo({
163
- version: 4,
164
- os: "linux",
165
- shell: "unknown",
166
- transferShell: "bash",
167
- compatEnabled: false,
168
- });
169
- expect(parsed?.transferShell).toBe("bash");
170
- });
171
-
172
- it("drops a transferShell value outside the sh/bash/zsh allowlist", () => {
173
- // Anything we couldn't have probed (fish, csh, garbage) must not slip
174
- // into the cache and bypass the ssh:// transfer guard.
175
- const parsed = parseHostInfo({
176
- version: 4,
177
- os: "linux",
178
- shell: "sh",
179
- transferShell: "fish",
180
- compatEnabled: false,
181
- });
182
- expect(parsed?.transferShell).toBeUndefined();
183
- });
184
-
185
- it("returns transferShell undefined when the field is missing", () => {
186
- // A pre-v4 cache file lacks transferShell entirely; the parsed value
187
- // must be undefined so shouldRefreshHostInfo treats it as stale.
188
- const parsed = parseHostInfo({ version: 3, os: "linux", shell: "sh", compatEnabled: false });
189
- expect(parsed?.transferShell).toBeUndefined();
190
- });
191
- });
@@ -1,61 +0,0 @@
1
- /**
2
- * Regression for #4232: `runSshSync` / `runSshCaptureSync` sit on the
3
- * `ensureHostInfo` → `probeHostInfo` / `ensureConnection` path that runs before
4
- * `SshTool.execute` applies the user's command timeout. Previously they invoked
5
- * `ssh` through `$`ssh ${args}`.quiet().nothrow()` with no timeout and no
6
- * abort signal, so an unreachable host or wedged control-master hung forever.
7
- *
8
- * The contract now is: each helper is bounded by `timeoutMs`, aborts a stalled
9
- * child, and returns a failure result (`exitCode !== 0`, non-empty
10
- * `stderr`) instead of throwing or blocking.
11
- */
12
- import { afterAll, beforeAll, describe, expect, it } from "bun:test";
13
- import * as fs from "node:fs/promises";
14
- import * as os from "node:os";
15
- import * as path from "node:path";
16
- import { _sshHelpersForTests } from "../connection-manager";
17
-
18
- const { runSshSync, runSshCaptureSync } = _sshHelpersForTests;
19
-
20
- let binDir: string;
21
- let originalPath: string | undefined;
22
-
23
- beforeAll(async () => {
24
- binDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-ssh-timeout-"));
25
- // Fake `ssh` that traps SIGTERM and sleeps far past any test bound.
26
- // Simulates a wedged control-master / unreachable host.
27
- const fake = path.join(binDir, "ssh");
28
- await fs.writeFile(fake, "#!/usr/bin/env bash\ntrap '' TERM\nsleep 300\n", { mode: 0o755 });
29
- originalPath = process.env.PATH;
30
- process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`;
31
- });
32
-
33
- afterAll(async () => {
34
- if (originalPath === undefined) delete process.env.PATH;
35
- else process.env.PATH = originalPath;
36
- await fs.rm(binDir, { recursive: true, force: true });
37
- });
38
-
39
- describe("SSH pre-command helpers bound their own runtime (#4232)", () => {
40
- it("runSshSync returns a failure result within the timeout on a wedged host", async () => {
41
- const timeoutMs = 200;
42
- const started = Date.now();
43
- const result = await runSshSync(["-o", "BatchMode=yes", "unreachable", "true"], timeoutMs);
44
- const elapsed = Date.now() - started;
45
-
46
- expect(elapsed).toBeLessThan(5_000);
47
- // timeout → aborted child, so exit code is null (aborted) or non-zero.
48
- expect(result.exitCode).not.toBe(0);
49
- }, 10_000);
50
-
51
- it("runSshCaptureSync returns a failure result within the timeout on a wedged host", async () => {
52
- const timeoutMs = 200;
53
- const started = Date.now();
54
- const result = await runSshCaptureSync(["-o", "BatchMode=yes", "unreachable", "true"], timeoutMs);
55
- const elapsed = Date.now() - started;
56
-
57
- expect(elapsed).toBeLessThan(5_000);
58
- expect(result.exitCode).not.toBe(0);
59
- expect(result.stdout).toBe("");
60
- }, 10_000);
61
- });