@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,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
- });