@dungle-scrubs/tallow 0.8.21 → 0.8.23

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 (217) hide show
  1. package/dist/cli.js +35 -4
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +1 -1
  5. package/dist/interactive-mode-patch.d.ts +2 -0
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +82 -0
  8. package/dist/interactive-mode-patch.js.map +1 -1
  9. package/dist/sdk.d.ts +17 -0
  10. package/dist/sdk.d.ts.map +1 -1
  11. package/dist/sdk.js +68 -1
  12. package/dist/sdk.js.map +1 -1
  13. package/dist/workspace-transition-relay.d.ts +40 -7
  14. package/dist/workspace-transition-relay.d.ts.map +1 -1
  15. package/dist/workspace-transition-relay.js +81 -16
  16. package/dist/workspace-transition-relay.js.map +1 -1
  17. package/extensions/__integration__/background-task-widget-ownership.test.ts +216 -0
  18. package/extensions/__integration__/claude-hooks-compat.test.ts +156 -0
  19. package/extensions/__integration__/slash-command-bridge.test.ts +169 -23
  20. package/extensions/_shared/atomic-write.ts +1 -1
  21. package/extensions/_shared/bordered-box.ts +102 -0
  22. package/extensions/_shared/interop-events.ts +5 -0
  23. package/extensions/_shared/pid-registry.ts +1 -1
  24. package/extensions/agent-commands-tool/index.ts +4 -1
  25. package/extensions/background-task-tool/__tests__/lifecycle.test.ts +50 -25
  26. package/extensions/background-task-tool/index.ts +139 -221
  27. package/extensions/bash-tool-enhanced/index.ts +1 -75
  28. package/extensions/cd-tool/index.ts +2 -2
  29. package/extensions/context-fork/spawn.ts +4 -1
  30. package/extensions/health/index.ts +6 -6
  31. package/extensions/hooks/__tests__/claude-compat.test.ts +35 -0
  32. package/extensions/hooks/__tests__/subprocess-hardening.test.ts +73 -0
  33. package/extensions/hooks/index.ts +27 -4
  34. package/extensions/loop/__tests__/loop.test.ts +168 -4
  35. package/extensions/loop/extension.json +6 -5
  36. package/extensions/loop/index.ts +242 -31
  37. package/extensions/plan-mode-tool/__tests__/agent-end-execution.test.ts +373 -0
  38. package/extensions/plan-mode-tool/index.ts +103 -41
  39. package/extensions/prompt-suggestions/__tests__/editor-compatibility.test.ts +42 -0
  40. package/extensions/prompt-suggestions/index.ts +41 -6
  41. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +267 -671
  42. package/extensions/slash-command-bridge/extension.json +1 -1
  43. package/extensions/slash-command-bridge/index.ts +230 -116
  44. package/extensions/subagent-tool/index.ts +2 -2
  45. package/extensions/subagent-tool/process.ts +4 -5
  46. package/extensions/tasks/commands/register-tasks-extension.ts +41 -0
  47. package/extensions/teams-tool/__tests__/peer-messaging.test.ts +29 -24
  48. package/extensions/teams-tool/dashboard.ts +3 -5
  49. package/extensions/teams-tool/dispatch/auto-dispatch.ts +18 -1
  50. package/extensions/teams-tool/tools/teammate-tools.ts +9 -6
  51. package/extensions/wezterm-pane-control/__tests__/index.test.ts +88 -4
  52. package/extensions/wezterm-pane-control/index.ts +113 -8
  53. package/package.json +6 -4
  54. package/packages/tallow-tui/README.md +51 -0
  55. package/packages/tallow-tui/dist/autocomplete.d.ts +48 -0
  56. package/packages/tallow-tui/dist/autocomplete.d.ts.map +1 -0
  57. package/packages/tallow-tui/dist/autocomplete.js +564 -0
  58. package/packages/tallow-tui/dist/autocomplete.js.map +1 -0
  59. package/packages/tallow-tui/dist/border-styles.d.ts +32 -0
  60. package/packages/tallow-tui/dist/border-styles.d.ts.map +1 -0
  61. package/packages/tallow-tui/dist/border-styles.js +46 -0
  62. package/packages/tallow-tui/dist/border-styles.js.map +1 -0
  63. package/packages/tallow-tui/dist/components/bordered-box.d.ts +52 -0
  64. package/packages/tallow-tui/dist/components/bordered-box.d.ts.map +1 -0
  65. package/packages/tallow-tui/dist/components/bordered-box.js +89 -0
  66. package/packages/tallow-tui/dist/components/bordered-box.js.map +1 -0
  67. package/packages/tallow-tui/dist/components/box.d.ts +22 -0
  68. package/packages/tallow-tui/dist/components/box.d.ts.map +1 -0
  69. package/packages/tallow-tui/dist/components/box.js +104 -0
  70. package/packages/tallow-tui/dist/components/box.js.map +1 -0
  71. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts +22 -0
  72. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts.map +1 -0
  73. package/packages/tallow-tui/dist/components/cancellable-loader.js +35 -0
  74. package/packages/tallow-tui/dist/components/cancellable-loader.js.map +1 -0
  75. package/packages/tallow-tui/dist/components/editor.d.ts +240 -0
  76. package/packages/tallow-tui/dist/components/editor.d.ts.map +1 -0
  77. package/packages/tallow-tui/dist/components/editor.js +1766 -0
  78. package/packages/tallow-tui/dist/components/editor.js.map +1 -0
  79. package/packages/tallow-tui/dist/components/image.d.ts +126 -0
  80. package/packages/tallow-tui/dist/components/image.d.ts.map +1 -0
  81. package/packages/tallow-tui/dist/components/image.js +245 -0
  82. package/packages/tallow-tui/dist/components/image.js.map +1 -0
  83. package/packages/tallow-tui/dist/components/input.d.ts +37 -0
  84. package/packages/tallow-tui/dist/components/input.d.ts.map +1 -0
  85. package/packages/tallow-tui/dist/components/input.js +439 -0
  86. package/packages/tallow-tui/dist/components/input.js.map +1 -0
  87. package/packages/tallow-tui/dist/components/loader.d.ts +88 -0
  88. package/packages/tallow-tui/dist/components/loader.d.ts.map +1 -0
  89. package/packages/tallow-tui/dist/components/loader.js +146 -0
  90. package/packages/tallow-tui/dist/components/loader.js.map +1 -0
  91. package/packages/tallow-tui/dist/components/markdown.d.ts +95 -0
  92. package/packages/tallow-tui/dist/components/markdown.d.ts.map +1 -0
  93. package/packages/tallow-tui/dist/components/markdown.js +633 -0
  94. package/packages/tallow-tui/dist/components/markdown.js.map +1 -0
  95. package/packages/tallow-tui/dist/components/select-list.d.ts +32 -0
  96. package/packages/tallow-tui/dist/components/select-list.d.ts.map +1 -0
  97. package/packages/tallow-tui/dist/components/select-list.js +156 -0
  98. package/packages/tallow-tui/dist/components/select-list.js.map +1 -0
  99. package/packages/tallow-tui/dist/components/settings-list.d.ts +50 -0
  100. package/packages/tallow-tui/dist/components/settings-list.d.ts.map +1 -0
  101. package/packages/tallow-tui/dist/components/settings-list.js +189 -0
  102. package/packages/tallow-tui/dist/components/settings-list.js.map +1 -0
  103. package/packages/tallow-tui/dist/components/spacer.d.ts +12 -0
  104. package/packages/tallow-tui/dist/components/spacer.d.ts.map +1 -0
  105. package/packages/tallow-tui/dist/components/spacer.js +23 -0
  106. package/packages/tallow-tui/dist/components/spacer.js.map +1 -0
  107. package/packages/tallow-tui/dist/components/text.d.ts +19 -0
  108. package/packages/tallow-tui/dist/components/text.d.ts.map +1 -0
  109. package/packages/tallow-tui/dist/components/text.js +91 -0
  110. package/packages/tallow-tui/dist/components/text.js.map +1 -0
  111. package/packages/tallow-tui/dist/components/truncated-text.d.ts +13 -0
  112. package/packages/tallow-tui/dist/components/truncated-text.d.ts.map +1 -0
  113. package/packages/tallow-tui/dist/components/truncated-text.js +51 -0
  114. package/packages/tallow-tui/dist/components/truncated-text.js.map +1 -0
  115. package/packages/tallow-tui/dist/editor-component.d.ts +50 -0
  116. package/packages/tallow-tui/dist/editor-component.d.ts.map +1 -0
  117. package/packages/tallow-tui/dist/editor-component.js +2 -0
  118. package/packages/tallow-tui/dist/editor-component.js.map +1 -0
  119. package/packages/tallow-tui/dist/fuzzy.d.ts +16 -0
  120. package/packages/tallow-tui/dist/fuzzy.d.ts.map +1 -0
  121. package/packages/tallow-tui/dist/fuzzy.js +107 -0
  122. package/packages/tallow-tui/dist/fuzzy.js.map +1 -0
  123. package/packages/tallow-tui/dist/index.d.ts +25 -0
  124. package/packages/tallow-tui/dist/index.d.ts.map +1 -0
  125. package/packages/tallow-tui/dist/index.js +35 -0
  126. package/packages/tallow-tui/dist/index.js.map +1 -0
  127. package/packages/tallow-tui/dist/keybindings.d.ts +39 -0
  128. package/packages/tallow-tui/dist/keybindings.d.ts.map +1 -0
  129. package/packages/tallow-tui/dist/keybindings.js +114 -0
  130. package/packages/tallow-tui/dist/keybindings.js.map +1 -0
  131. package/packages/tallow-tui/dist/keys.d.ts +168 -0
  132. package/packages/tallow-tui/dist/keys.d.ts.map +1 -0
  133. package/packages/tallow-tui/dist/keys.js +971 -0
  134. package/packages/tallow-tui/dist/keys.js.map +1 -0
  135. package/packages/tallow-tui/dist/kill-ring.d.ts +28 -0
  136. package/packages/tallow-tui/dist/kill-ring.d.ts.map +1 -0
  137. package/packages/tallow-tui/dist/kill-ring.js +44 -0
  138. package/packages/tallow-tui/dist/kill-ring.js.map +1 -0
  139. package/packages/tallow-tui/dist/stdin-buffer.d.ts +48 -0
  140. package/packages/tallow-tui/dist/stdin-buffer.d.ts.map +1 -0
  141. package/packages/tallow-tui/dist/stdin-buffer.js +317 -0
  142. package/packages/tallow-tui/dist/stdin-buffer.js.map +1 -0
  143. package/packages/tallow-tui/dist/terminal-image.d.ts +161 -0
  144. package/packages/tallow-tui/dist/terminal-image.d.ts.map +1 -0
  145. package/packages/tallow-tui/dist/terminal-image.js +460 -0
  146. package/packages/tallow-tui/dist/terminal-image.js.map +1 -0
  147. package/packages/tallow-tui/dist/terminal.d.ts +102 -0
  148. package/packages/tallow-tui/dist/terminal.d.ts.map +1 -0
  149. package/packages/tallow-tui/dist/terminal.js +263 -0
  150. package/packages/tallow-tui/dist/terminal.js.map +1 -0
  151. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts +14 -0
  152. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts.map +1 -0
  153. package/packages/tallow-tui/dist/test-utils/capability-env.js +55 -0
  154. package/packages/tallow-tui/dist/test-utils/capability-env.js.map +1 -0
  155. package/packages/tallow-tui/dist/tui.d.ts +239 -0
  156. package/packages/tallow-tui/dist/tui.d.ts.map +1 -0
  157. package/packages/tallow-tui/dist/tui.js +1058 -0
  158. package/packages/tallow-tui/dist/tui.js.map +1 -0
  159. package/packages/tallow-tui/dist/undo-stack.d.ts +17 -0
  160. package/packages/tallow-tui/dist/undo-stack.d.ts.map +1 -0
  161. package/packages/tallow-tui/dist/undo-stack.js +25 -0
  162. package/packages/tallow-tui/dist/undo-stack.js.map +1 -0
  163. package/packages/tallow-tui/dist/utils.d.ts +96 -0
  164. package/packages/tallow-tui/dist/utils.d.ts.map +1 -0
  165. package/packages/tallow-tui/dist/utils.js +843 -0
  166. package/packages/tallow-tui/dist/utils.js.map +1 -0
  167. package/packages/tallow-tui/package.json +24 -0
  168. package/packages/tallow-tui/src/__tests__/__snapshots__/render.test.ts.snap +121 -0
  169. package/packages/tallow-tui/src/__tests__/editor-border.test.ts +72 -0
  170. package/packages/tallow-tui/src/__tests__/editor-change-listener.test.ts +121 -0
  171. package/packages/tallow-tui/src/__tests__/editor-ghost-text.test.ts +112 -0
  172. package/packages/tallow-tui/src/__tests__/fuzzy.test.ts +91 -0
  173. package/packages/tallow-tui/src/__tests__/image-component.test.ts +113 -0
  174. package/packages/tallow-tui/src/__tests__/keys.test.ts +141 -0
  175. package/packages/tallow-tui/src/__tests__/render.test.ts +179 -0
  176. package/packages/tallow-tui/src/__tests__/stdin-buffer.test.ts +82 -0
  177. package/packages/tallow-tui/src/__tests__/terminal-image.test.ts +363 -0
  178. package/packages/tallow-tui/src/__tests__/tui-diff-regression.test.ts +454 -0
  179. package/packages/tallow-tui/src/__tests__/tui-render-scheduling.test.ts +256 -0
  180. package/packages/tallow-tui/src/__tests__/utils.test.ts +259 -0
  181. package/packages/tallow-tui/src/autocomplete.ts +716 -0
  182. package/packages/tallow-tui/src/border-styles.ts +60 -0
  183. package/packages/tallow-tui/src/components/bordered-box.ts +113 -0
  184. package/packages/tallow-tui/src/components/box.ts +137 -0
  185. package/packages/tallow-tui/src/components/cancellable-loader.ts +40 -0
  186. package/packages/tallow-tui/src/components/editor.ts +2143 -0
  187. package/packages/tallow-tui/src/components/image.ts +315 -0
  188. package/packages/tallow-tui/src/components/input.ts +522 -0
  189. package/packages/tallow-tui/src/components/loader.ts +187 -0
  190. package/packages/tallow-tui/src/components/markdown.ts +780 -0
  191. package/packages/tallow-tui/src/components/select-list.ts +197 -0
  192. package/packages/tallow-tui/src/components/settings-list.ts +264 -0
  193. package/packages/tallow-tui/src/components/spacer.ts +28 -0
  194. package/packages/tallow-tui/src/components/text.ts +113 -0
  195. package/packages/tallow-tui/src/components/truncated-text.ts +65 -0
  196. package/packages/tallow-tui/src/editor-component.ts +92 -0
  197. package/packages/tallow-tui/src/fuzzy.ts +133 -0
  198. package/packages/tallow-tui/src/index.ts +118 -0
  199. package/packages/tallow-tui/src/keybindings.ts +183 -0
  200. package/packages/tallow-tui/src/keys.ts +1189 -0
  201. package/packages/tallow-tui/src/kill-ring.ts +46 -0
  202. package/packages/tallow-tui/src/stdin-buffer.ts +386 -0
  203. package/packages/tallow-tui/src/terminal-image.ts +619 -0
  204. package/packages/tallow-tui/src/terminal.ts +350 -0
  205. package/packages/tallow-tui/src/test-utils/capability-env.ts +56 -0
  206. package/packages/tallow-tui/src/tui.ts +1336 -0
  207. package/packages/tallow-tui/src/undo-stack.ts +28 -0
  208. package/packages/tallow-tui/src/utils.ts +948 -0
  209. package/packages/tallow-tui/tsconfig.build.json +21 -0
  210. package/runtime/agent-runner.ts +20 -0
  211. package/runtime/atomic-write.ts +8 -0
  212. package/runtime/otel.ts +12 -0
  213. package/runtime/resolve-module.ts +23 -0
  214. package/runtime/runtime-path-provider.ts +12 -0
  215. package/runtime/runtime-provenance.ts +17 -0
  216. package/runtime/workspace-transition-relay.ts +21 -0
  217. package/runtime/workspace-transition.ts +29 -0
@@ -1,57 +1,142 @@
1
1
  /**
2
2
  * Unit tests for the slash-command-bridge extension.
3
3
  *
4
- * Uses ExtensionHarness for isolated testing of tool registration,
5
- * command dispatch, context injection, and error handling.
4
+ * Focuses on compact deferral, chosen lifecycle boundary, exactly-once guards,
5
+ * and deterministic timer-driven continuation behavior.
6
6
  */
7
7
 
8
8
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
- import type { ContextUsage, ExtensionContext } from "@mariozechner/pi-coding-agent";
9
+ import type { AssistantMessage, ToolResultMessage, Usage } from "@mariozechner/pi-ai";
10
+ import type {
11
+ ContextUsage,
12
+ ExtensionContext,
13
+ ExtensionUIContext,
14
+ TurnEndEvent,
15
+ } from "@mariozechner/pi-coding-agent";
10
16
  import { ExtensionHarness } from "../../../test-utils/extension-harness.js";
11
- import slashCommandBridge from "../index.js";
12
-
13
- // ── Setup ────────────────────────────────────────────────────────────────────
17
+ import { ManualTimerScheduler } from "../../../test-utils/manual-timer-scheduler.js";
18
+ import slashCommandBridge, {
19
+ resetSlashCommandBridgeStateForTests,
20
+ setSlashCommandBridgeSchedulerForTests,
21
+ } from "../index.js";
22
+
23
+ const ZERO_USAGE: Usage = {
24
+ input: 0,
25
+ output: 0,
26
+ cacheRead: 0,
27
+ cacheWrite: 0,
28
+ totalTokens: 0,
29
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
30
+ };
14
31
 
15
32
  let harness: ExtensionHarness;
33
+ let scheduler: ManualTimerScheduler;
16
34
 
17
35
  beforeEach(async () => {
36
+ scheduler = new ManualTimerScheduler();
37
+ setSlashCommandBridgeSchedulerForTests(scheduler.runtime);
18
38
  harness = ExtensionHarness.create();
19
39
  await harness.loadExtension(slashCommandBridge);
20
40
  });
21
41
 
22
- afterEach(async () => {
23
- await harness.fireEvent("session_before_switch", {
24
- type: "session_before_switch",
25
- reason: "switch",
26
- });
42
+ afterEach(() => {
43
+ resetSlashCommandBridgeStateForTests();
27
44
  });
28
45
 
29
- // ── Registration ─────────────────────────────────────────────────────────────
46
+ /**
47
+ * Builds a mock ExtensionContext with overridable methods.
48
+ *
49
+ * @param overrides - Methods/properties to override on the default stub context
50
+ * @returns Mock ExtensionContext
51
+ */
52
+ function buildContext(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
53
+ return {
54
+ ui: {} as ExtensionContext["ui"],
55
+ hasUI: false,
56
+ cwd: process.cwd(),
57
+ sessionManager: {} as ExtensionContext["sessionManager"],
58
+ modelRegistry: {} as ExtensionContext["modelRegistry"],
59
+ model: undefined,
60
+ isIdle: () => true,
61
+ abort: () => {},
62
+ hasPendingMessages: () => false,
63
+ shutdown: () => {},
64
+ getContextUsage: () => undefined,
65
+ compact: () => {},
66
+ getSystemPrompt: () => "",
67
+ ...overrides,
68
+ };
69
+ }
30
70
 
31
- describe("registration", () => {
32
- test("registers run_slash_command tool", () => {
33
- expect(harness.tools.has("run_slash_command")).toBe(true);
34
- });
71
+ /**
72
+ * Creates a realistic assistant turn_end event for compact lifecycle tests.
73
+ *
74
+ * @param stopReason - Assistant stop reason for the completed turn
75
+ * @returns TurnEnd event payload
76
+ */
77
+ function buildAssistantTurnEnd(stopReason: AssistantMessage["stopReason"]): TurnEndEvent {
78
+ return {
79
+ type: "turn_end",
80
+ turnIndex: 0,
81
+ message: {
82
+ role: "assistant",
83
+ content: [],
84
+ api: "anthropic-messages",
85
+ provider: "mock",
86
+ model: "mock-model",
87
+ stopReason,
88
+ timestamp: Date.now(),
89
+ usage: { ...ZERO_USAGE },
90
+ },
91
+ toolResults: stopReason === "toolUse" ? [buildCompactToolResult()] : [],
92
+ };
93
+ }
35
94
 
36
- test("tool has correct label", () => {
37
- const tool = harness.tools.get("run_slash_command");
38
- expect(tool?.label).toBe("run_slash_command");
39
- });
95
+ /**
96
+ * Builds the compact tool result payload recorded on the tool-use turn.
97
+ *
98
+ * @returns Tool result message for `run_slash_command({ command: "compact" })`
99
+ */
100
+ function buildCompactToolResult(): ToolResultMessage<{ command: string }> {
101
+ return {
102
+ role: "toolResult",
103
+ toolCallId: "mock-tool-call",
104
+ toolName: "run_slash_command",
105
+ content: [
106
+ { type: "text", text: "Session compaction will begin after this response completes." },
107
+ ],
108
+ details: { command: "compact" },
109
+ isError: false,
110
+ timestamp: Date.now(),
111
+ };
112
+ }
40
113
 
41
- test("tool description lists available commands", () => {
42
- const tool = harness.tools.get("run_slash_command");
43
- expect(tool?.description).toContain("show-system-prompt");
44
- expect(tool?.description).toContain("context");
45
- expect(tool?.description).toContain("compact");
46
- });
114
+ /**
115
+ * Executes the registered slash-command tool with the provided context.
116
+ *
117
+ * @param params - Tool parameters
118
+ * @param ctx - Extension context for the execution
119
+ * @returns Tool execution result
120
+ */
121
+ async function executeTool(params: { command: string }, ctx?: ExtensionContext) {
122
+ const tool = harness.tools.get("run_slash_command");
123
+ if (!tool) {
124
+ throw new Error("run_slash_command tool not registered");
125
+ }
126
+
127
+ return tool.execute("test-call-id", params, undefined, undefined, ctx ?? buildContext());
128
+ }
47
129
 
48
- test("registers before_agent_start handler", () => {
130
+ describe("registration", () => {
131
+ test("registers run_slash_command and lifecycle handlers", () => {
132
+ expect(harness.tools.has("run_slash_command")).toBe(true);
49
133
  expect(harness.handlers.has("before_agent_start")).toBe(true);
134
+ expect(harness.handlers.has("turn_end")).toBe(true);
135
+ expect(harness.handlers.has("turn_start")).toBe(true);
136
+ expect(harness.handlers.has("session_before_switch")).toBe(true);
50
137
  });
51
138
  });
52
139
 
53
- // ── Command execution: show-system-prompt ────────────────────────────────────
54
-
55
140
  describe("show-system-prompt", () => {
56
141
  test("returns the current system prompt", async () => {
57
142
  const systemPrompt = "You are a helpful assistant with custom instructions.";
@@ -60,101 +145,71 @@ describe("show-system-prompt", () => {
60
145
  const result = await executeTool({ command: "show-system-prompt" }, ctx);
61
146
 
62
147
  expect(result.content[0]).toEqual({ type: "text", text: systemPrompt });
63
- });
64
-
65
- test("includes prompt length in details", async () => {
66
- const systemPrompt = "Short prompt.";
67
- const ctx = buildContext({ getSystemPrompt: () => systemPrompt });
68
-
69
- const result = await executeTool({ command: "show-system-prompt" }, ctx);
70
-
71
148
  expect(result.details).toEqual({ command: "show-system-prompt", length: systemPrompt.length });
72
149
  });
73
-
74
- test("handles empty system prompt", async () => {
75
- const ctx = buildContext({ getSystemPrompt: () => "" });
76
-
77
- const result = await executeTool({ command: "show-system-prompt" }, ctx);
78
-
79
- expect(result.content[0]).toEqual({ type: "text", text: "" });
80
- expect(result.isError).toBeUndefined();
81
- });
82
150
  });
83
151
 
84
- // ── Command execution: context ───────────────────────────────────────────────
85
-
86
152
  describe("context", () => {
87
153
  test("returns formatted context usage", async () => {
88
154
  const usage: ContextUsage = { tokens: 45000, contextWindow: 200000 };
89
155
  const ctx = buildContext({ getContextUsage: () => usage });
90
156
 
91
157
  const result = await executeTool({ command: "context" }, ctx);
92
-
93
158
  const text = result.content[0];
159
+
94
160
  expect(text).toBeDefined();
95
161
  if (text?.type === "text") {
96
162
  expect(text.text).toContain("45,000");
97
163
  expect(text.text).toContain("200,000");
98
164
  expect(text.text).toContain("22.5%");
99
- expect(text.text).toContain("155,000"); // free tokens
100
165
  }
101
166
  });
102
167
 
103
- test("includes token data in details", async () => {
104
- const usage: ContextUsage = { tokens: 10000, contextWindow: 100000 };
105
- const ctx = buildContext({ getContextUsage: () => usage });
106
-
107
- const result = await executeTool({ command: "context" }, ctx);
108
-
109
- expect(result.details).toEqual({
110
- command: "context",
111
- tokens: 10000,
112
- contextWindow: 100000,
113
- });
114
- });
115
-
116
- test("returns error when no usage data available", async () => {
117
- const ctx = buildContext({ getContextUsage: () => undefined });
118
-
119
- const result = await executeTool({ command: "context" }, ctx);
168
+ test("returns error when usage data is unavailable", async () => {
169
+ const result = await executeTool({ command: "context" }, buildContext());
120
170
 
121
171
  expect(result.isError).toBe(true);
122
- const text = result.content[0];
123
- if (text?.type === "text") {
124
- expect(text.text).toContain("No context usage data");
125
- }
172
+ expect(result.details).toEqual({ command: "context", error: "no_usage_data" });
126
173
  });
174
+ });
127
175
 
128
- test("returns no-usage error when token count is unknown", async () => {
129
- const usage: ContextUsage = { tokens: null, contextWindow: 200000, percent: null };
130
- const ctx = buildContext({ getContextUsage: () => usage });
131
-
132
- const result = await executeTool({ command: "context" }, ctx);
176
+ describe("error handling", () => {
177
+ test("rejects unknown commands", async () => {
178
+ const result = await executeTool({ command: "reboot" }, buildContext());
133
179
 
134
180
  expect(result.isError).toBe(true);
135
- expect(result.details).toEqual({ command: "context", error: "no_usage_data" });
136
181
  const text = result.content[0];
137
182
  if (text?.type === "text") {
138
- expect(text.text).toContain("No context usage data");
139
- expect(text.text).not.toContain("0 / 200,000");
140
- expect(text.text).not.toContain("0.0%");
183
+ expect(text.text).toContain("Unknown command");
184
+ expect(text.text).toContain("reboot");
141
185
  }
142
186
  });
187
+ });
143
188
 
144
- test("handles zero context window gracefully", async () => {
145
- const usage: ContextUsage = { tokens: 0, contextWindow: 0 };
146
- const ctx = buildContext({ getContextUsage: () => usage });
189
+ describe("context injection", () => {
190
+ test("injects hidden context listing bridged commands", async () => {
191
+ const results = await harness.fireEvent("before_agent_start", {
192
+ type: "before_agent_start",
193
+ prompt: "hello",
194
+ systemPrompt: "",
195
+ });
147
196
 
148
- const result = await executeTool({ command: "context" }, ctx);
197
+ const result = results.find((entry) => entry != null) as
198
+ | {
199
+ message: { content: string; customType: string; display: boolean };
200
+ }
201
+ | undefined;
149
202
 
150
- expect(result.isError).toBeUndefined();
203
+ expect(result?.message.customType).toBe("slash-command-bridge-context");
204
+ expect(result?.message.display).toBe(false);
205
+ expect(result?.message.content).toContain("/show-system-prompt");
206
+ expect(result?.message.content).toContain("/context");
207
+ expect(result?.message.content).toContain("/compact");
151
208
  });
152
209
  });
153
210
 
154
- // ── Command execution: compact ───────────────────────────────────────────────
155
-
156
211
  describe("compact", () => {
157
- test("does NOT call ctx.compact() immediately — defers to agent_end", async () => {
212
+ test("defers compact instead of calling ctx.compact inline", async () => {
158
213
  let compactCalled = false;
159
214
  const ctx = buildContext({
160
215
  compact: () => {
@@ -162,17 +217,10 @@ describe("compact", () => {
162
217
  },
163
218
  });
164
219
 
165
- await executeTool({ command: "compact" }, ctx);
166
-
167
- expect(compactCalled).toBe(false);
168
- });
169
-
170
- test("returns message instructing model to finish response", async () => {
171
- const ctx = buildContext({ compact: () => {} });
172
-
173
220
  const result = await executeTool({ command: "compact" }, ctx);
174
221
 
175
- expect(result.isError).toBeUndefined();
222
+ expect(compactCalled).toBe(false);
223
+ expect(result.details).toEqual({ command: "compact" });
176
224
  const text = result.content[0];
177
225
  if (text?.type === "text") {
178
226
  expect(text.text).toContain("compaction will begin after this response");
@@ -180,686 +228,234 @@ describe("compact", () => {
180
228
  }
181
229
  });
182
230
 
183
- test("includes command name in details", async () => {
184
- const ctx = buildContext({ compact: () => {} });
185
-
186
- const result = await executeTool({ command: "compact" }, ctx);
187
-
188
- expect(result.details).toEqual({ command: "compact" });
189
- });
190
-
191
- test("agent_end hook triggers deferred compact with callbacks", async () => {
231
+ test("waits through the tool-use turn and compacts on the following assistant turn_end", async () => {
232
+ let compactCalls = 0;
192
233
  let compactOptions: Parameters<ExtensionContext["compact"]>[0];
193
- const toolCtx = buildContext({ compact: () => {} });
194
- const agentEndCtx = buildContext({
234
+ const ctx = buildContext({
195
235
  compact: (options) => {
236
+ compactCalls++;
196
237
  compactOptions = options;
197
238
  },
198
239
  });
199
240
 
200
- // Tool sets the deferred flag
201
- await executeTool({ command: "compact" }, toolCtx);
202
-
203
- // agent_end fires — should trigger compact
204
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
241
+ await executeTool({ command: "compact" }, buildContext());
242
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("toolUse"), ctx);
243
+ expect(compactCalls).toBe(0);
205
244
 
206
- expect(compactOptions).toBeDefined();
245
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("stop"), ctx);
246
+ expect(compactCalls).toBe(1);
207
247
  expect(typeof compactOptions?.onComplete).toBe("function");
208
248
  expect(typeof compactOptions?.onError).toBe("function");
209
-
210
- // Clean up compact progress interval to avoid cross-test leakage.
211
- compactOptions?.onError?.();
212
249
  });
213
250
 
214
- test("agent_end hook starts compact heartbeat updates with elapsed status", async () => {
215
- let compactOptions: Parameters<ExtensionContext["compact"]>[0];
216
- const workingMessages: Array<string | undefined> = [];
217
- const statusUpdates: Array<{ key: string; text: string | undefined }> = [];
218
- const toolCtx = buildContext({ compact: () => {} });
219
- const agentEndCtx = buildContext({
220
- hasUI: true,
221
- ui: {
222
- setWorkingMessage: (message?: string) => {
223
- workingMessages.push(message);
224
- },
225
- setStatus: (key: string, text?: string) => {
226
- statusUpdates.push({ key, text });
227
- },
228
- } as ExtensionContext["ui"],
229
- compact: (options) => {
230
- compactOptions = options;
251
+ test("consumes the pending request exactly once", async () => {
252
+ let compactCalls = 0;
253
+ const ctx = buildContext({
254
+ compact: () => {
255
+ compactCalls++;
231
256
  },
232
257
  });
233
258
 
234
- await executeTool({ command: "compact" }, toolCtx);
235
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
259
+ await executeTool({ command: "compact" }, buildContext());
260
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("toolUse"), ctx);
261
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("stop"), ctx);
262
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("stop"), ctx);
236
263
 
237
- expect(workingMessages[0]).toBe("Compacting session…");
238
- expect(statusUpdates[0]?.key).toBe("compact");
239
- expect(statusUpdates[0]?.text).toContain("compacting · 0s");
240
-
241
- await sleep(1100);
242
-
243
- const hasElapsedUpdate = statusUpdates.some((update) =>
244
- update.text?.includes("compacting · 1s")
245
- );
246
- expect(hasElapsedUpdate).toBe(true);
247
-
248
- compactOptions?.onError?.();
264
+ expect(compactCalls).toBe(1);
249
265
  });
250
266
 
251
- test("onComplete stops compact heartbeat and transitions to resuming", async () => {
267
+ test("drives heartbeat and continuation timers deterministically", async () => {
252
268
  let compactOptions: Parameters<ExtensionContext["compact"]>[0];
269
+ const widgetUpdates: Array<{ key: string; content: string[] | undefined }> = [];
253
270
  const workingMessages: Array<string | undefined> = [];
254
- const statusUpdates: Array<{ key: string; text: string | undefined }> = [];
255
- const toolCtx = buildContext({ compact: () => {} });
256
- const agentEndCtx = buildContext({
271
+ const ctx = buildContext({
257
272
  hasUI: true,
258
273
  ui: {
274
+ setWidget: (key: string, content?: string[]) => {
275
+ widgetUpdates.push({ key, content });
276
+ },
259
277
  setWorkingMessage: (message?: string) => {
260
278
  workingMessages.push(message);
261
279
  },
262
- setStatus: (key: string, text?: string) => {
263
- statusUpdates.push({ key, text });
264
- },
265
- } as ExtensionContext["ui"],
280
+ } as ExtensionUIContext,
266
281
  compact: (options) => {
267
282
  compactOptions = options;
268
283
  },
269
284
  isIdle: () => true,
270
285
  });
271
286
 
272
- await executeTool({ command: "compact" }, toolCtx);
273
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
274
- await sleep(1100);
275
-
276
- compactOptions?.onComplete?.();
277
- expect(workingMessages.at(-1)).toBe("Resuming task…");
278
- expect(statusUpdates.at(-1)).toEqual({ key: "compact", text: "⏳ resuming" });
279
-
280
- const updatesAfterComplete = statusUpdates.length;
281
- await sleep(1200);
282
- expect(statusUpdates).toHaveLength(updatesAfterComplete);
283
- });
287
+ await executeTool({ command: "compact" }, buildContext());
288
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("toolUse"), ctx);
289
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("stop"), ctx);
284
290
 
285
- test("onComplete sends continuation message when agent is idle and no queued messages", async () => {
286
- let compactOptions: Parameters<ExtensionContext["compact"]>[0];
287
- const toolCtx = buildContext({ compact: () => {} });
288
- const agentEndCtx = buildContext({
289
- compact: (options) => {
290
- compactOptions = options;
291
- },
292
- isIdle: () => true,
291
+ expect(workingMessages[0]).toBe("Compacting session…");
292
+ expect(widgetUpdates[0]).toEqual({
293
+ key: "compact-progress",
294
+ content: ["🧹 Compacting session · 0s"],
293
295
  });
294
296
 
295
- await executeTool({ command: "compact" }, toolCtx);
296
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
297
+ scheduler.advanceBy(1000);
298
+ expect(widgetUpdates.at(-1)).toEqual({
299
+ key: "compact-progress",
300
+ content: ["🧹 ⠙ Compacting session · 1s"],
301
+ });
297
302
 
298
- // Trigger onComplete and wait for the setTimeout(200) to fire
299
303
  compactOptions?.onComplete?.();
300
- await new Promise((resolve) => setTimeout(resolve, 300));
304
+ expect(workingMessages.at(-1)).toBe("Resuming task…");
305
+ expect(widgetUpdates.at(-1)).toEqual({
306
+ key: "compact-progress",
307
+ content: ["⏳ Resuming after compaction…"],
308
+ });
309
+
310
+ scheduler.advanceBy(199);
311
+ expect(harness.sentMessages).toHaveLength(0);
312
+ scheduler.advanceBy(1);
301
313
 
302
- const continuation = harness.sentMessages.find((m) => m.customType === "compact-continue");
303
- expect(continuation).toBeDefined();
314
+ const continuation = harness.sentMessages.find(
315
+ (message) => message.customType === "compact-continue"
316
+ );
304
317
  expect(continuation?.display).toBe(false);
305
318
  expect(continuation?.options?.triggerTurn).toBe(true);
306
319
  expect(continuation?.content).toContain("compaction is complete");
307
320
  });
308
321
 
309
- test("onComplete always schedules continuation even when compaction queue has messages", async () => {
310
- // Previously, onComplete short-circuited when hasCompactionQueuedMessages()
311
- // returned true. This caused orphaned session steering messages because the
312
- // method's false positive (checking session steering too) prevented the
313
- // continuation timer from firing. Now the timer always fires — safety nets
314
- // (turn_start cancellation, isIdle() check) prevent duplicate prompts.
315
- // See plan 160.
322
+ test("turn_start cancels the delayed continuation and clears the inline widget", async () => {
316
323
  let compactOptions: Parameters<ExtensionContext["compact"]>[0];
317
- const toolCtx = buildContext({ compact: () => {} });
318
- const agentEndCtx = buildContext({
324
+ const widgetUpdates: Array<{ key: string; content: string[] | undefined }> = [];
325
+ const ctx = buildContext({
319
326
  hasUI: true,
320
327
  ui: {
328
+ setWidget: (key: string, content?: string[]) => {
329
+ widgetUpdates.push({ key, content });
330
+ },
321
331
  setWorkingMessage: () => {},
322
- setStatus: () => {},
323
- // Even with hasCompactionQueuedMessages exposed, onComplete
324
- // no longer checks it.
325
- hasCompactionQueuedMessages: () => true,
326
- } as unknown as ExtensionContext["ui"],
332
+ } as ExtensionUIContext,
327
333
  compact: (options) => {
328
334
  compactOptions = options;
329
335
  },
330
336
  isIdle: () => true,
331
337
  });
332
338
 
333
- await executeTool({ command: "compact" }, toolCtx);
334
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
335
-
339
+ await executeTool({ command: "compact" }, buildContext());
340
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("toolUse"), ctx);
341
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("stop"), ctx);
336
342
  compactOptions?.onComplete?.();
337
- await new Promise((resolve) => setTimeout(resolve, 300));
338
343
 
339
- // Continuation fires regardless safety nets prevent duplicates
340
- const continuation = harness.sentMessages.find((m) => m.customType === "compact-continue");
341
- expect(continuation).toBeDefined();
342
- expect(continuation?.content).toContain("compaction is complete");
344
+ await harness.fireEvent("turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, ctx);
345
+ scheduler.advanceBy(200);
346
+
347
+ expect(harness.sentMessages).toHaveLength(0);
348
+ expect(widgetUpdates.at(-1)).toEqual({ key: "compact-progress", content: undefined });
343
349
  });
344
350
 
345
- test("onComplete skips continuation and clears indicators when agent is not idle", async () => {
351
+ test("skips continuation and clears indicators when the session is no longer idle", async () => {
346
352
  let compactOptions: Parameters<ExtensionContext["compact"]>[0];
347
- const statusUpdates: Array<{ key: string; text: string | undefined }> = [];
353
+ const widgetUpdates: Array<{ key: string; content: string[] | undefined }> = [];
348
354
  const workingMessages: Array<string | undefined> = [];
349
- const toolCtx = buildContext({ compact: () => {} });
350
- const agentEndCtx = buildContext({
355
+ const ctx = buildContext({
351
356
  hasUI: true,
352
357
  ui: {
358
+ setWidget: (key: string, content?: string[]) => {
359
+ widgetUpdates.push({ key, content });
360
+ },
353
361
  setWorkingMessage: (message?: string) => {
354
362
  workingMessages.push(message);
355
363
  },
356
- setStatus: (key: string, text?: string) => {
357
- statusUpdates.push({ key, text });
358
- },
359
- } as ExtensionContext["ui"],
364
+ } as ExtensionUIContext,
360
365
  compact: (options) => {
361
366
  compactOptions = options;
362
367
  },
363
368
  isIdle: () => false,
364
369
  });
365
370
 
366
- await executeTool({ command: "compact" }, toolCtx);
367
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
368
-
371
+ await executeTool({ command: "compact" }, buildContext());
372
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("toolUse"), ctx);
373
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("stop"), ctx);
369
374
  compactOptions?.onComplete?.();
370
- await new Promise((resolve) => setTimeout(resolve, 300));
371
-
372
- const continuation = harness.sentMessages.find((m) => m.customType === "compact-continue");
373
- expect(continuation).toBeUndefined();
375
+ scheduler.advanceBy(200);
374
376
 
375
- // When not idle, the !isIdle() branch clears indicators
376
- expect(statusUpdates.at(-1)).toEqual({ key: "compact", text: undefined });
377
+ expect(harness.sentMessages).toHaveLength(0);
378
+ expect(widgetUpdates.at(-1)).toEqual({ key: "compact-progress", content: undefined });
377
379
  expect(workingMessages.at(-1)).toBeUndefined();
378
380
  });
379
381
 
380
- test("onError stops compact heartbeat, clears status, and sends no continuation", async () => {
382
+ test("onError clears compact UI and sends no continuation", async () => {
381
383
  let compactOptions: Parameters<ExtensionContext["compact"]>[0];
382
- const statusUpdates: Array<{ key: string; text: string | undefined }> = [];
383
- const toolCtx = buildContext({ compact: () => {} });
384
- const agentEndCtx = buildContext({
385
- hasUI: true,
386
- ui: {
387
- setWorkingMessage: () => {},
388
- setStatus: (key: string, text?: string) => {
389
- statusUpdates.push({ key, text });
390
- },
391
- } as ExtensionContext["ui"],
392
- compact: (options) => {
393
- compactOptions = options;
394
- },
395
- isIdle: () => true,
396
- });
397
-
398
- await executeTool({ command: "compact" }, toolCtx);
399
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
400
- await sleep(1100);
401
-
402
- compactOptions?.onError?.();
403
- expect(statusUpdates.at(-1)).toEqual({ key: "compact", text: undefined });
404
-
405
- const updatesAfterError = statusUpdates.length;
406
- await sleep(1200);
407
- expect(statusUpdates).toHaveLength(updatesAfterError);
408
-
409
- const continuation = harness.sentMessages.find((m) => m.customType === "compact-continue");
410
- expect(continuation).toBeUndefined();
411
- });
412
-
413
- test("agent_end hook is a no-op when no compact is pending", async () => {
414
- let compactCalled = false;
384
+ const widgetUpdates: Array<{ key: string; content: string[] | undefined }> = [];
385
+ const workingMessages: Array<string | undefined> = [];
415
386
  const ctx = buildContext({
416
- compact: () => {
417
- compactCalled = true;
418
- },
419
- });
420
-
421
- // Fire agent_end without a preceding compact tool call
422
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, ctx);
423
-
424
- expect(compactCalled).toBe(false);
425
- });
426
-
427
- test("turn_start cancels continuation timer before it fires", async () => {
428
- let compactOptions: Parameters<ExtensionContext["compact"]>[0];
429
- const toolCtx = buildContext({ compact: () => {} });
430
- const agentEndCtx = buildContext({
431
387
  hasUI: true,
432
388
  ui: {
433
- setWorkingMessage: () => {},
434
- setStatus: () => {},
435
- } as ExtensionContext["ui"],
436
- compact: (options) => {
437
- compactOptions = options;
438
- },
439
- isIdle: () => true,
440
- });
441
-
442
- await executeTool({ command: "compact" }, toolCtx);
443
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
444
-
445
- // Trigger onComplete — starts the 200ms timer
446
- compactOptions?.onComplete?.();
447
-
448
- // Fire turn_start before the timer expires (simulates flushCompactionQueue
449
- // prompting the agent first)
450
- const turnCtx = buildContext({
451
- hasUI: true,
452
- ui: {
453
- setStatus: () => {},
454
- } as ExtensionContext["ui"],
455
- });
456
- await harness.fireEvent("turn_start", { type: "turn_start" }, turnCtx);
457
-
458
- // Wait longer than the 200ms timeout
459
- await new Promise((resolve) => setTimeout(resolve, 300));
460
-
461
- // Timer was cancelled — no duplicate continuation message sent
462
- const continuation = harness.sentMessages.find((m) => m.customType === "compact-continue");
463
- expect(continuation).toBeUndefined();
464
- });
465
-
466
- test("turn_start clears footer status when resuming after compact", async () => {
467
- let compactOptions: Parameters<ExtensionContext["compact"]>[0];
468
- const statusUpdates: Array<{ key: string; text: string | undefined }> = [];
469
- const toolCtx = buildContext({ compact: () => {} });
470
- const agentEndCtx = buildContext({
471
- hasUI: true,
472
- ui: {
473
- setWorkingMessage: () => {},
474
- setStatus: (key: string, text?: string) => {
475
- statusUpdates.push({ key, text });
476
- },
477
- } as ExtensionContext["ui"],
478
- compact: (options) => {
479
- compactOptions = options;
480
- },
481
- });
482
-
483
- await executeTool({ command: "compact" }, toolCtx);
484
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
485
- compactOptions?.onComplete?.();
486
-
487
- // Resuming status should be set
488
- expect(statusUpdates.at(-1)).toEqual({ key: "compact", text: "⏳ resuming" });
489
-
490
- // turn_start fires — should clear the footer status
491
- const turnCtx = buildContext({
492
- hasUI: true,
493
- ui: {
494
- setStatus: (key: string, text?: string) => {
495
- statusUpdates.push({ key, text });
389
+ setWidget: (key: string, content?: string[]) => {
390
+ widgetUpdates.push({ key, content });
496
391
  },
497
- } as ExtensionContext["ui"],
498
- });
499
- await harness.fireEvent("turn_start", { type: "turn_start" }, turnCtx);
500
-
501
- expect(statusUpdates.at(-1)).toEqual({ key: "compact", text: undefined });
502
- });
503
-
504
- test("turn_start is a no-op when not resuming after compact", async () => {
505
- const statusUpdates: Array<{ key: string; text: string | undefined }> = [];
506
- const turnCtx = buildContext({
507
- hasUI: true,
508
- ui: {
509
- setStatus: (key: string, text?: string) => {
510
- statusUpdates.push({ key, text });
511
- },
512
- } as ExtensionContext["ui"],
513
- });
514
-
515
- // Fire turn_start without any preceding compaction
516
- await harness.fireEvent("turn_start", { type: "turn_start" }, turnCtx);
517
-
518
- // No status updates should have been made
519
- expect(statusUpdates).toHaveLength(0);
520
- });
521
-
522
- test("session_before_switch clears active compact heartbeat state", async () => {
523
- let compactOptions: Parameters<ExtensionContext["compact"]>[0];
524
- const statusUpdates: Array<{ key: string; text: string | undefined }> = [];
525
- const toolCtx = buildContext({ compact: () => {} });
526
- const agentEndCtx = buildContext({
527
- hasUI: true,
528
- ui: {
529
- setWorkingMessage: () => {},
530
- setStatus: (key: string, text?: string) => {
531
- statusUpdates.push({ key, text });
392
+ setWorkingMessage: (message?: string) => {
393
+ workingMessages.push(message);
532
394
  },
533
- } as ExtensionContext["ui"],
395
+ } as ExtensionUIContext,
534
396
  compact: (options) => {
535
397
  compactOptions = options;
536
398
  },
399
+ isIdle: () => true,
537
400
  });
538
401
 
539
- await executeTool({ command: "compact" }, toolCtx);
540
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
541
- await sleep(1100);
542
-
543
- expect(compactOptions).toBeDefined();
544
- expect(statusUpdates.some((update) => update.text?.includes("compacting"))).toBe(true);
545
-
546
- const switchCtx = buildContext({
547
- hasUI: true,
548
- ui: {
549
- setStatus: (key: string, text?: string) => {
550
- statusUpdates.push({ key, text });
551
- },
552
- } as ExtensionContext["ui"],
553
- });
554
- await harness.fireEvent(
555
- "session_before_switch",
556
- { type: "session_before_switch", reason: "switch" },
557
- switchCtx
558
- );
559
-
560
- expect(statusUpdates.at(-1)).toEqual({ key: "compact", text: undefined });
402
+ await executeTool({ command: "compact" }, buildContext());
403
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("toolUse"), ctx);
404
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("stop"), ctx);
405
+ scheduler.advanceBy(1000);
406
+ compactOptions?.onError?.(new Error("boom"));
407
+ scheduler.advanceBy(200);
561
408
 
562
- const updatesAfterSwitch = statusUpdates.length;
563
- await sleep(1200);
564
- expect(statusUpdates).toHaveLength(updatesAfterSwitch);
409
+ expect(harness.sentMessages).toHaveLength(0);
410
+ expect(widgetUpdates.at(-1)).toEqual({ key: "compact-progress", content: undefined });
411
+ expect(workingMessages.at(-1)).toBeUndefined();
565
412
  });
566
413
 
567
- test("session_before_switch clears resuming state and footer status", async () => {
414
+ test("session_before_switch clears pending compact, timers, and UI state", async () => {
415
+ let compactCalls = 0;
568
416
  let compactOptions: Parameters<ExtensionContext["compact"]>[0];
569
- const statusUpdates: Array<{ key: string; text: string | undefined }> = [];
570
- const toolCtx = buildContext({ compact: () => {} });
571
- const agentEndCtx = buildContext({
417
+ const widgetUpdates: Array<{ key: string; content: string[] | undefined }> = [];
418
+ const workingMessages: Array<string | undefined> = [];
419
+ const ctx = buildContext({
572
420
  hasUI: true,
573
421
  ui: {
574
- setWorkingMessage: () => {},
575
- setStatus: (key: string, text?: string) => {
576
- statusUpdates.push({ key, text });
422
+ setWidget: (key: string, content?: string[]) => {
423
+ widgetUpdates.push({ key, content });
577
424
  },
578
- } as ExtensionContext["ui"],
425
+ setWorkingMessage: (message?: string) => {
426
+ workingMessages.push(message);
427
+ },
428
+ } as ExtensionUIContext,
579
429
  compact: (options) => {
430
+ compactCalls++;
580
431
  compactOptions = options;
581
432
  },
433
+ isIdle: () => true,
582
434
  });
583
435
 
584
- await executeTool({ command: "compact" }, toolCtx);
585
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
586
- compactOptions?.onComplete?.();
587
-
588
- // Resuming status should be set
589
- expect(statusUpdates.at(-1)).toEqual({ key: "compact", text: "⏳ resuming" });
590
-
591
- // Session switch fires — should clear resuming state
592
- const switchCtx = buildContext({
593
- hasUI: true,
594
- ui: {
595
- setStatus: (key: string, text?: string) => {
596
- statusUpdates.push({ key, text });
597
- },
598
- } as ExtensionContext["ui"],
599
- });
436
+ await executeTool({ command: "compact" }, buildContext());
600
437
  await harness.fireEvent(
601
438
  "session_before_switch",
602
439
  { type: "session_before_switch", reason: "switch" },
603
- switchCtx
440
+ ctx
604
441
  );
442
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("toolUse"), ctx);
443
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("stop"), ctx);
444
+ expect(compactCalls).toBe(0);
605
445
 
606
- expect(statusUpdates.at(-1)).toEqual({ key: "compact", text: undefined });
607
-
608
- // Subsequent turn_start should be a no-op (flag was cleared)
609
- const turnStatusUpdates: Array<{ key: string; text: string | undefined }> = [];
610
- const turnCtx = buildContext({
611
- hasUI: true,
612
- ui: {
613
- setStatus: (key: string, text?: string) => {
614
- turnStatusUpdates.push({ key, text });
615
- },
616
- } as ExtensionContext["ui"],
617
- });
618
- await harness.fireEvent("turn_start", { type: "turn_start" }, turnCtx);
619
-
620
- expect(turnStatusUpdates).toHaveLength(0);
621
- });
622
-
623
- test("session_before_switch cancels continuation timer", async () => {
624
- let compactOptions: Parameters<ExtensionContext["compact"]>[0];
625
- const toolCtx = buildContext({ compact: () => {} });
626
- const agentEndCtx = buildContext({
627
- hasUI: true,
628
- ui: {
629
- setWorkingMessage: () => {},
630
- setStatus: () => {},
631
- } as ExtensionContext["ui"],
632
- compact: (options) => {
633
- compactOptions = options;
634
- },
635
- isIdle: () => true,
636
- });
637
-
638
- await executeTool({ command: "compact" }, toolCtx);
639
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
640
-
641
- // Trigger onComplete — starts the 200ms timer
446
+ await executeTool({ command: "compact" }, buildContext());
447
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("toolUse"), ctx);
448
+ await harness.fireEvent("turn_end", buildAssistantTurnEnd("stop"), ctx);
642
449
  compactOptions?.onComplete?.();
643
-
644
- // Session switch fires before timer expires
645
- const switchCtx = buildContext({
646
- hasUI: true,
647
- ui: {
648
- setStatus: () => {},
649
- } as ExtensionContext["ui"],
650
- });
651
450
  await harness.fireEvent(
652
451
  "session_before_switch",
653
452
  { type: "session_before_switch", reason: "switch" },
654
- switchCtx
453
+ ctx
655
454
  );
455
+ scheduler.advanceBy(200);
656
456
 
657
- // Wait longer than the 200ms timeout
658
- await new Promise((resolve) => setTimeout(resolve, 300));
659
-
660
- // Timer was cancelled — no continuation message sent
661
- const continuation = harness.sentMessages.find((m) => m.customType === "compact-continue");
662
- expect(continuation).toBeUndefined();
663
- });
664
-
665
- test("repeated compact lifecycle does not leave duplicate heartbeat intervals", async () => {
666
- const originalSetInterval = globalThis.setInterval;
667
- const originalClearInterval = globalThis.clearInterval;
668
- const createdHandles: unknown[] = [];
669
- const clearedHandles: unknown[] = [];
670
- let handleIndex = 0;
671
-
672
- globalThis.setInterval = ((callback: Parameters<typeof setInterval>[0], _ms?: number) => {
673
- void callback;
674
- handleIndex += 1;
675
- const handle = { id: handleIndex };
676
- createdHandles.push(handle);
677
- return handle as unknown as ReturnType<typeof setInterval>;
678
- }) as typeof setInterval;
679
- globalThis.clearInterval = ((handle?: ReturnType<typeof setInterval>) => {
680
- clearedHandles.push(handle);
681
- }) as typeof clearInterval;
682
-
683
- try {
684
- const toolCtx = buildContext({ compact: () => {} });
685
- const agentEndCtx = buildContext({
686
- hasUI: true,
687
- ui: {
688
- setWorkingMessage: () => {},
689
- setStatus: () => {},
690
- } as ExtensionContext["ui"],
691
- compact: () => {},
692
- });
693
-
694
- await executeTool({ command: "compact" }, toolCtx);
695
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
696
-
697
- await executeTool({ command: "compact" }, toolCtx);
698
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
699
-
700
- await harness.fireEvent("session_before_switch", {
701
- type: "session_before_switch",
702
- reason: "switch",
703
- });
704
-
705
- expect(createdHandles).toHaveLength(2);
706
- expect(clearedHandles).toContain(createdHandles[0]);
707
- expect(clearedHandles).toContain(createdHandles[1]);
708
- } finally {
709
- globalThis.setInterval = originalSetInterval;
710
- globalThis.clearInterval = originalClearInterval;
711
- }
712
- });
713
-
714
- test("session_before_switch clears pending compact", async () => {
715
- let compactCalled = false;
716
- const toolCtx = buildContext({ compact: () => {} });
717
- const agentEndCtx = buildContext({
718
- compact: () => {
719
- compactCalled = true;
720
- },
721
- });
722
-
723
- // Set pending compact
724
- await executeTool({ command: "compact" }, toolCtx);
725
-
726
- // Session switch fires — should clear the flag
727
- await harness.fireEvent("session_before_switch", {
728
- type: "session_before_switch",
729
- reason: "switch",
730
- });
731
-
732
- // agent_end should now be a no-op
733
- await harness.fireEvent("agent_end", { type: "agent_end", messages: [] }, agentEndCtx);
734
-
735
- expect(compactCalled).toBe(false);
736
- });
737
- });
738
-
739
- // ── Error handling ───────────────────────────────────────────────────────────
740
-
741
- describe("error handling", () => {
742
- test("rejects unknown commands", async () => {
743
- const ctx = buildContext();
744
-
745
- const result = await executeTool({ command: "nonexistent" }, ctx);
746
-
747
- expect(result.isError).toBe(true);
748
- const text = result.content[0];
749
- if (text?.type === "text") {
750
- expect(text.text).toContain("Unknown command");
751
- expect(text.text).toContain("nonexistent");
752
- expect(text.text).toContain("show-system-prompt");
753
- expect(text.text).toContain("context");
754
- expect(text.text).toContain("compact");
755
- }
756
- });
757
-
758
- test("rejects commands with / prefix", async () => {
759
- const ctx = buildContext();
760
-
761
- const result = await executeTool({ command: "/compact" }, ctx);
762
-
763
- expect(result.isError).toBe(true);
764
- });
765
-
766
- test("rejects empty command string", async () => {
767
- const ctx = buildContext();
768
-
769
- const result = await executeTool({ command: "" }, ctx);
770
-
771
- expect(result.isError).toBe(true);
772
- });
773
- });
774
-
775
- // ── Context injection ────────────────────────────────────────────────────────
776
-
777
- describe("context injection", () => {
778
- test("injects hidden message listing bridged commands", async () => {
779
- const results = await harness.fireEvent("before_agent_start", {
780
- type: "before_agent_start",
781
- prompt: "hello",
782
- systemPrompt: "",
783
- });
784
-
785
- const result = results.find((r) => r != null) as
786
- | {
787
- message: { customType: string; content: string; display: boolean };
788
- }
789
- | undefined;
790
-
791
- expect(result).toBeDefined();
792
- expect(result?.message.customType).toBe("slash-command-bridge-context");
793
- expect(result?.message.display).toBe(false);
794
- expect(result?.message.content).toContain("run_slash_command");
795
- });
796
-
797
- test("context message mentions available commands", async () => {
798
- const results = await harness.fireEvent("before_agent_start", {
799
- type: "before_agent_start",
800
- prompt: "hello",
801
- systemPrompt: "",
802
- });
803
-
804
- const result = results.find((r) => r != null) as
805
- | {
806
- message: { content: string };
807
- }
808
- | undefined;
809
-
810
- expect(result?.message.content).toContain("/show-system-prompt");
811
- expect(result?.message.content).toContain("/context");
812
- expect(result?.message.content).toContain("/compact");
457
+ expect(harness.sentMessages).toHaveLength(0);
458
+ expect(widgetUpdates.at(-1)).toEqual({ key: "compact-progress", content: undefined });
459
+ expect(workingMessages.at(-1)).toBeUndefined();
813
460
  });
814
461
  });
815
-
816
- // ── Helpers ──────────────────────────────────────────────────────────────────
817
-
818
- /**
819
- * Build a mock ExtensionContext with overridable methods.
820
- *
821
- * @param overrides - Methods to override on the default stub context
822
- * @returns Mock ExtensionContext
823
- */
824
- function buildContext(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
825
- return {
826
- ui: {} as ExtensionContext["ui"],
827
- hasUI: false,
828
- cwd: process.cwd(),
829
- sessionManager: {} as ExtensionContext["sessionManager"],
830
- modelRegistry: {} as ExtensionContext["modelRegistry"],
831
- model: undefined,
832
- isIdle: () => true,
833
- abort: () => {},
834
- hasPendingMessages: () => false,
835
- shutdown: () => {},
836
- getContextUsage: () => undefined,
837
- compact: () => {},
838
- getSystemPrompt: () => "",
839
- ...overrides,
840
- };
841
- }
842
-
843
- /**
844
- * Waits for a given number of milliseconds.
845
- *
846
- * @param milliseconds - Delay duration in milliseconds
847
- * @returns Promise that resolves after the delay
848
- */
849
- function sleep(milliseconds: number): Promise<void> {
850
- return new Promise((resolve) => setTimeout(resolve, milliseconds));
851
- }
852
-
853
- /**
854
- * Execute the run_slash_command tool with the given params and context.
855
- *
856
- * @param params - Tool parameters
857
- * @param ctx - Extension context (optional, uses default stub)
858
- * @returns Tool execution result
859
- */
860
- async function executeTool(params: { command: string }, ctx?: ExtensionContext) {
861
- const tool = harness.tools.get("run_slash_command");
862
- if (!tool) throw new Error("run_slash_command tool not registered");
863
-
864
- return tool.execute("test-call-id", params, undefined, undefined, ctx ?? buildContext());
865
- }