@hayasaka7/haya-pet 0.2.8 → 0.3.1

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 (30) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/apps/cli/src/haya-pet.js +35 -1
  3. package/apps/cli/test/haya-pet.test.mjs +50 -0
  4. package/apps/companion/src/main/index.js +2 -2
  5. package/apps/companion/src/main/pet-loader.js +5 -1
  6. package/apps/companion/src/renderer/pet-hit-test.js +23 -0
  7. package/apps/companion/src/renderer/pet-window.js +62 -6
  8. package/apps/companion/src/renderer/styles.css +4 -1
  9. package/apps/companion/test/pet-hit-test.test.mjs +30 -0
  10. package/docs/architecture.md +10 -3
  11. package/docs/known-issues.md +26 -0
  12. package/package.json +1 -1
  13. package/packages/adapters/src/claude-transcript.js +16 -0
  14. package/packages/adapters/src/codex-transcript.js +13 -2
  15. package/packages/adapters/test/claude-transcript.test.mjs +38 -0
  16. package/packages/adapters/test/codex-transcript.test.mjs +22 -0
  17. package/packages/cli-core/src/claude-transcript-watcher.js +3 -0
  18. package/packages/cli-core/test/claude-transcript-watcher.test.mjs +28 -0
  19. package/packages/cli-core/test/codex-transcript-watcher.test.mjs +29 -0
  20. package/packages/pet-core/src/atlas.js +1 -0
  21. package/packages/pet-core/src/discovery.js +29 -2
  22. package/packages/pet-core/test/discovery.test.mjs +63 -1
  23. package/packages/protocol/src/messages.js +3 -0
  24. package/packages/protocol/test/messages.test.mjs +3 -0
  25. package/packages/session-core/src/bubble-view.js +3 -0
  26. package/packages/session-core/src/pet-state.js +6 -4
  27. package/packages/session-core/src/summaries.js +1 -0
  28. package/packages/session-core/test/bubble-linger.test.mjs +16 -0
  29. package/packages/session-core/test/bubble-view.test.mjs +8 -0
  30. package/packages/session-core/test/pet-state.test.mjs +23 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ All notable changes to HAYA Pet are documented here. This project adheres to
7
7
  > 0.2.0 npm publish; they are listed under 0.2.1, which is the first version that
8
8
  > ships them.
9
9
 
10
+ ## [0.3.1]
11
+
12
+ ### Fixed
13
+ - **Clicks pass through the empty space around the pet.** The overlay used the
14
+ pet's whole rectangular sprite cell as its click target, so the transparent
15
+ margins around the character still swallowed clicks meant for the window
16
+ behind it. The pet now intercepts the mouse only where the current frame
17
+ actually has opaque pixels — sampled live from the canvas, with no per-frame
18
+ cost — so the catch area hugs the visible silhouette. The resize grip is
19
+ unaffected: it still reveals and works across the pet's full bounding box.
20
+ - **Interrupting a turn no longer leaves the pet stuck "thinking".** When you
21
+ press Esc while the agent is working — especially mid-thought, with no tool
22
+ running — neither Claude Code nor Codex fires a hook (`Stop` only fires on a
23
+ normal turn end), so the pet kept spinning on *thinking* until the 30 s stale
24
+ sweep. The transcript watchers now recognise each client's interrupt marker
25
+ (Claude's `[Request interrupted by user]` message; Codex's `turn_aborted`
26
+ record) and report a new `interrupted` status — a red ✕ that, unlike a real
27
+ failure, is **not** treated as a finished session, so the bubble stays put
28
+ (the session is still alive) instead of disappearing, and returns to the live
29
+ status as soon as you continue.
30
+
31
+ ## [0.3.0]
32
+
33
+ ### Fixed
34
+ - **A fresh install now shows a real pet.** The package has always shipped a
35
+ ready-to-use pet (`assets/fallback-pet`), but discovery only scanned the
36
+ user's pet folders (`~/.codex/pets`, `~/.haya-pet/pets`), so a new user with
37
+ no pets got a blue "dev placeholder" box and an empty pet list instead. The
38
+ bundled pet is now composed into discovery as a last resort — appended after
39
+ any of the user's own pets (which still win and stay the default) and deduped
40
+ by id — so the overlay always renders a real character out of the box.
41
+
10
42
  ## [0.2.8]
11
43
 
12
44
  ### Fixed
@@ -14,7 +14,7 @@ import { watchCodexGuardianReviews as defaultWatchCodexGuardianReviews } from ".
14
14
  import { ensureCompanionConnection } from "../../../packages/cli-core/src/companion-launcher.js";
15
15
  import { createIpcClient as defaultCreateIpcClient } from "../../../packages/daemon-core/src/ipc-server.js";
16
16
  import { getDefaultPaths } from "../../../packages/platform-core/src/paths.js";
17
- import { discoverPets as defaultDiscoverPets } from "../../../packages/pet-core/src/discovery.js";
17
+ import { discoverPetsWithFallback as defaultDiscoverPets } from "../../../packages/pet-core/src/discovery.js";
18
18
  import { createStateFile as defaultCreateStateFile } from "../../../packages/app-state/src/state-file.js";
19
19
  import { getSelectedPetId, setSelectedPet, getHooksEnabled, setHooksEnabled } from "../../../packages/app-state/src/state.js";
20
20
  import { checkForUpdate, UPDATE_COMMAND } from "../../../packages/app-state/src/update-check.js";
@@ -241,6 +241,22 @@ async function runRunCommand(parsed, dependencies) {
241
241
  updatedAt: now()
242
242
  })
243
243
  .catch(() => {});
244
+ },
245
+ // Esc-interrupt fires no Stop hook, so without this the pet stays stuck on
246
+ // "thinking"/"running". The transcript's interrupt marker is the only signal.
247
+ onInterrupt: () => {
248
+ hookDebugLog(env, now, { source: "transcript", event: "interrupted", state: "interrupted" });
249
+ messageSender
250
+ .send({
251
+ type: "state",
252
+ sessionId,
253
+ state: "interrupted",
254
+ summary: "interrupted",
255
+ confidence: 0.9,
256
+ source: "client_log",
257
+ updatedAt: now()
258
+ })
259
+ .catch(() => {});
244
260
  }
245
261
  });
246
262
  stopWatcher = watcher.stop;
@@ -277,6 +293,24 @@ async function runRunCommand(parsed, dependencies) {
277
293
  state: event.state
278
294
  });
279
295
 
296
+ // Esc-interrupt fires no Stop hook, so without this the pet stays stuck
297
+ // on "thinking"/"running" until the stale sweep.
298
+ if (event.type === "turn_aborted") {
299
+ activeToolCalls.clear();
300
+ messageSender
301
+ .send({
302
+ type: "state",
303
+ sessionId,
304
+ state: "interrupted",
305
+ summary: "interrupted",
306
+ confidence: 0.9,
307
+ source: "client_log",
308
+ updatedAt: now()
309
+ })
310
+ .catch(() => {});
311
+ return;
312
+ }
313
+
280
314
  if (event.type === "tool_started") {
281
315
  activeToolCalls.add(event.toolCallId);
282
316
  messageSender
@@ -683,6 +683,56 @@ test("a transcript denial clears the stuck approval to idle", async () => {
683
683
  assert.equal(idle.updatedAt, 42);
684
684
  });
685
685
 
686
+ test("a transcript interrupt reports a failed status for Claude", async () => {
687
+ const sent = [];
688
+ let fireInterrupt;
689
+ await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
690
+ cwd: process.cwd(),
691
+ env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
692
+ now: () => 42,
693
+ heartbeatIntervalMs: 10,
694
+ send: async (message) => sent.push(message),
695
+ injectClaudeHooks: () => ({ settingsPath: "/tmp/s.json", cleanup: () => {} }),
696
+ watchClaudeTranscript: ({ onInterrupt }) => { fireInterrupt = onInterrupt; return { stop: () => {} }; },
697
+ runGenericCommand: async (options) => {
698
+ // Simulate the user pressing Esc to interrupt mid-turn.
699
+ fireInterrupt({ type: "interrupted" });
700
+ return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
701
+ }
702
+ });
703
+
704
+ const interrupted = sent.find((m) => m.type === "state" && m.source === "client_log");
705
+ assert.ok(interrupted, "a client_log state was sent on interrupt");
706
+ assert.equal(interrupted.state, "interrupted");
707
+ assert.equal(interrupted.summary, "interrupted");
708
+ assert.equal(interrupted.updatedAt, 42);
709
+ });
710
+
711
+ test("a transcript turn_aborted reports a failed status for Codex", async () => {
712
+ const sent = [];
713
+ let fireToolEvent;
714
+ await runAiPet(["run", "--client", "codex", "--", "codex"], {
715
+ cwd: process.cwd(),
716
+ env: { USERPROFILE: "C:\\Users\\A" },
717
+ now: () => 42,
718
+ heartbeatIntervalMs: 10,
719
+ send: async (message) => sent.push(message),
720
+ createStateFile: hooksStateFile(true),
721
+ injectCodexHooks: () => ({ profileName: "haya-pet", cleanup: () => {} }),
722
+ watchCodexTranscript: ({ onToolEvent }) => { fireToolEvent = onToolEvent; return { stop: () => {} }; },
723
+ runGenericCommand: async (options) => {
724
+ // Simulate the user pressing Esc: Codex writes a turn_aborted record.
725
+ fireToolEvent({ type: "turn_aborted", reason: "interrupted" });
726
+ return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
727
+ }
728
+ });
729
+
730
+ const interrupted = sent.find((m) => m.type === "state" && m.source === "client_log" && m.state === "interrupted");
731
+ assert.ok(interrupted, "a client_log interrupted state was sent on turn_aborted");
732
+ assert.equal(interrupted.summary, "interrupted");
733
+ assert.equal(interrupted.updatedAt, 42);
734
+ });
735
+
686
736
  test("non-hook-capable clients are never injected even with HAYA_PET_HOOKS=1", async () => {
687
737
  const calls = [];
688
738
  await runAiPet(["run", "--client", "generic", "--", "aider"], {
@@ -18,7 +18,7 @@ import { resolveSavedPosition } from "./display-manager.js";
18
18
  import { getPetScale, setPetScale, setSelectedPet, updateGlobalPetPosition } from "./position-store.js";
19
19
  import { buildTrayMenu, buildTrayTooltip } from "./tray-menu.js";
20
20
  import { createStateFile } from "./state-file.js";
21
- import { discoverPets } from "./pet-loader.js";
21
+ import { discoverPetsWithFallback } from "./pet-loader.js";
22
22
  import { checkForUpdate, UPDATE_PAGE_URL } from "../../../../packages/app-state/src/update-check.js";
23
23
 
24
24
  const STALE_SWEEP_INTERVAL_MS = 10_000;
@@ -67,7 +67,7 @@ if (!app.requestSingleInstanceLock()) {
67
67
  async function bootstrap() {
68
68
  positionState = await stateFile.load();
69
69
  petScale = clampScale(getPetScale(positionState));
70
- pets = await discoverPets(paths.petSearchPaths);
70
+ pets = await discoverPetsWithFallback(paths.petSearchPaths);
71
71
 
72
72
  // Clients fire no event at the moment the user ACCEPTS a permission prompt
73
73
  // (only denial/finish are observable), so a waiting_approval session would
@@ -1,2 +1,6 @@
1
1
  // Re-exported from pet-core so the companion and CLI share one discovery path.
2
- export { discoverPets, loadPetFromDir } from "../../../../packages/pet-core/src/discovery.js";
2
+ export {
3
+ discoverPets,
4
+ discoverPetsWithFallback,
5
+ loadPetFromDir
6
+ } from "../../../../packages/pet-core/src/discovery.js";
@@ -0,0 +1,23 @@
1
+ // Pure helpers for the pet's pixel-precise click-through. The browser glue in
2
+ // pet-window.js supplies the live pixel alpha and the canvas bounding box; these
3
+ // functions hold the (testable) decisions.
4
+
5
+ // Default minimum alpha for a pixel to count as "the pet" rather than the
6
+ // transparent margin of its sprite cell. A small threshold ignores faint
7
+ // anti-aliased edges so the click-through hugs the visible silhouette.
8
+ export const DEFAULT_ALPHA_THRESHOLD = 16;
9
+
10
+ // Top-left inclusive, bottom-right exclusive — matches canvas pixel indexing so
11
+ // the same predicate works for both bounding-box and per-pixel tests.
12
+ export function isPointInsideRect(point, rect) {
13
+ return (
14
+ point.x >= rect.left &&
15
+ point.x < rect.right &&
16
+ point.y >= rect.top &&
17
+ point.y < rect.bottom
18
+ );
19
+ }
20
+
21
+ export function isOpaqueAlpha(alpha, threshold = DEFAULT_ALPHA_THRESHOLD) {
22
+ return alpha >= threshold;
23
+ }
@@ -19,12 +19,15 @@ import { resolvePanelPlacement } from "../main/panel-placement.js";
19
19
  import { resolveBubbleListMaxHeight } from "../main/bubble-list-viewport.js";
20
20
  import { createInteractionController } from "./interaction-controller.js";
21
21
  import { createBubbleList } from "./session-bubbles.js";
22
+ import { isOpaqueAlpha, isPointInsideRect } from "./pet-hit-test.js";
22
23
 
23
24
  const bridge = window.aiPet;
24
25
  const petEl = document.getElementById("pet");
25
26
  const canvas = document.getElementById("pet-canvas");
26
27
  const gripEl = document.getElementById("pet-resize-grip");
27
- const ctx = canvas.getContext("2d");
28
+ // willReadFrequently: the click-through hit-test samples a single pixel on every
29
+ // pointer move (see pointerHitsPetPixel), so keep the canvas CPU-backed.
30
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
28
31
  const panelEl = document.getElementById("bubbles");
29
32
 
30
33
  // The sprite's natural cell size; the canvas is this times the user's scale.
@@ -190,6 +193,10 @@ function playOneShot(action) {
190
193
 
191
194
  canvas.addEventListener("pointerdown", (event) => {
192
195
  canvas.setPointerCapture(event.pointerId);
196
+ // Hold click-through off for the whole press: a drag swaps to the running
197
+ // frames, whose opaque pixels differ from the grabbed one, so re-running the
198
+ // pixel test mid-gesture could flip the window to pass-through and drop it.
199
+ petPressed = true;
193
200
  dragOffset = { x: event.offsetX, y: event.offsetY };
194
201
  // Window-local (clientX/Y) coords throughout — the overlay covers the work area.
195
202
  controller.pointerDown({ x: event.clientX, y: event.clientY, time: performance.now() });
@@ -206,11 +213,18 @@ canvas.addEventListener("pointermove", (event) => {
206
213
  canvas.addEventListener("pointerup", (event) => {
207
214
  // Click / double-click are delivered asynchronously via onAction; only the
208
215
  // synchronous drag-end is handled here.
216
+ petPressed = false;
209
217
  const result = controller.pointerUp({ x: event.clientX, y: event.clientY, time: performance.now() });
210
218
  if (result?.type === "drag-end") {
211
219
  animationState = clearDragAction(animationState);
212
220
  bridge?.savePetPosition?.(petLocal);
213
221
  }
222
+ refreshMouseIgnore(event.clientX, event.clientY);
223
+ });
224
+
225
+ canvas.addEventListener("pointercancel", () => {
226
+ petPressed = false;
227
+ animationState = clearDragAction(animationState);
214
228
  });
215
229
 
216
230
  // --- Resize grip: drag to scale the pet, double-click to reset ---
@@ -310,23 +324,54 @@ function renderBubbles() {
310
324
  // The overlay window covers a large area but should only intercept the mouse
311
325
  // over the pet and the bubble chips; everywhere else must pass through to the
312
326
  // desktop. The window is created ignoring mouse events (with forwarding), and
313
- // we flip it back on whenever the cursor is over an `.interactive` element.
327
+ // we flip it back on whenever the cursor is over an `.interactive` element. Over
328
+ // the pet canvas we go further and only intercept where the sprite has opaque
329
+ // pixels, so the transparent margins of the cell pass clicks through too.
314
330
 
315
331
  let mouseIgnored;
332
+ let petPressed = false; // a press/drag is in progress on the pet canvas
316
333
  let lastPointer = { x: -1, y: -1 };
317
334
 
335
+ // True when the cursor is over a non-transparent pixel of the current frame. The
336
+ // canvas already holds the current frame at the current scale, so sampling it
337
+ // directly needs no atlas math. (Electron lets us read a file://-drawn canvas;
338
+ // if a future version ever taints it, getImageData throws and we fall back to
339
+ // treating the whole canvas box as interactive.)
340
+ function pointerHitsPetPixel(x, y) {
341
+ if (!spritesheet) {
342
+ return true; // dev placeholder has no sprite: keep the whole box interactive
343
+ }
344
+ const rect = canvas.getBoundingClientRect();
345
+ if (!isPointInsideRect({ x, y }, rect)) {
346
+ return false;
347
+ }
348
+ const lx = Math.floor(x - rect.left);
349
+ const ly = Math.floor(y - rect.top);
350
+ try {
351
+ return isOpaqueAlpha(ctx.getImageData(lx, ly, 1, 1).data[3]);
352
+ } catch {
353
+ return true; // tainted canvas: degrade to full-box interactivity
354
+ }
355
+ }
356
+
318
357
  function refreshMouseIgnore(x, y) {
319
358
  // While the grip is captured, the pointer can briefly leave it (the pet only
320
359
  // approximately tracks the diagonal); flipping click-through mid-drag would
321
- // drop the pointerup, so hold interaction until the drag ends.
322
- if (resizeDrag) {
360
+ // drop the pointerup, so hold interaction until the drag ends. The same holds
361
+ // for a press/drag on the pet body (petPressed).
362
+ if (resizeDrag || petPressed) {
323
363
  return;
324
364
  }
325
365
  if (!Number.isFinite(x) || !Number.isFinite(y)) {
326
366
  return;
327
367
  }
328
- const target = document.elementFromPoint(x, y);
329
- const interactive = Boolean(target && target.closest(".interactive"));
368
+ const interactiveEl = document.elementFromPoint(x, y)?.closest(".interactive");
369
+ let interactive = Boolean(interactiveEl);
370
+ // Pixel precision applies only to the pet body; the grip and bubbles (their
371
+ // own .interactive elements) keep their full hit area.
372
+ if (interactiveEl === canvas) {
373
+ interactive = pointerHitsPetPixel(x, y);
374
+ }
330
375
  const ignore = !interactive;
331
376
  if (ignore !== mouseIgnored) {
332
377
  mouseIgnored = ignore;
@@ -334,9 +379,20 @@ function refreshMouseIgnore(x, y) {
334
379
  }
335
380
  }
336
381
 
382
+ // The resize grip reveals whenever the cursor is over the pet's bounding box —
383
+ // the same judgement as before — kept independent of the pixel-precise
384
+ // click-through so passing clicks through transparent areas never hides it. The
385
+ // window forwards move events even while click-through is on, so this stays
386
+ // accurate over transparent (passed-through) areas too.
387
+ function refreshGripVisibility(x, y) {
388
+ const inside = Number.isFinite(x) && Number.isFinite(y) && isPointInsideRect({ x, y }, canvas.getBoundingClientRect());
389
+ petEl.classList.toggle("show-grip", inside || Boolean(resizeDrag));
390
+ }
391
+
337
392
  window.addEventListener("mousemove", (event) => {
338
393
  lastPointer = { x: event.clientX, y: event.clientY };
339
394
  refreshMouseIgnore(event.clientX, event.clientY);
395
+ refreshGripVisibility(event.clientX, event.clientY);
340
396
  });
341
397
 
342
398
  if (bridge) {
@@ -61,7 +61,10 @@ body {
61
61
  transition: opacity 0.15s ease;
62
62
  }
63
63
 
64
- #pet:hover .resize-grip,
64
+ /* .show-grip is toggled by pet-window.js based on the pet's bounding box, so the
65
+ grip's reveal is independent of the pixel-precise click-through (which would
66
+ otherwise hide it over the sprite's transparent corners). */
67
+ #pet.show-grip .resize-grip,
65
68
  .resize-grip.active {
66
69
  opacity: 1;
67
70
  }
@@ -0,0 +1,30 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { isOpaqueAlpha, isPointInsideRect } from "../src/renderer/pet-hit-test.js";
4
+
5
+ const RECT = { left: 10, top: 20, right: 110, bottom: 220 };
6
+
7
+ test("isPointInsideRect includes the top-left edge and excludes the bottom-right", () => {
8
+ assert.equal(isPointInsideRect({ x: 10, y: 20 }, RECT), true); // top-left corner is inside
9
+ assert.equal(isPointInsideRect({ x: 60, y: 120 }, RECT), true); // centre
10
+ assert.equal(isPointInsideRect({ x: 110, y: 120 }, RECT), false); // right edge is exclusive
11
+ assert.equal(isPointInsideRect({ x: 60, y: 220 }, RECT), false); // bottom edge is exclusive
12
+ });
13
+
14
+ test("isPointInsideRect rejects points outside the box", () => {
15
+ assert.equal(isPointInsideRect({ x: 9, y: 120 }, RECT), false);
16
+ assert.equal(isPointInsideRect({ x: 60, y: 19 }, RECT), false);
17
+ assert.equal(isPointInsideRect({ x: 200, y: 300 }, RECT), false);
18
+ });
19
+
20
+ test("isOpaqueAlpha compares against the threshold (default 16)", () => {
21
+ assert.equal(isOpaqueAlpha(255), true);
22
+ assert.equal(isOpaqueAlpha(16), true); // threshold is inclusive
23
+ assert.equal(isOpaqueAlpha(15), false);
24
+ assert.equal(isOpaqueAlpha(0), false);
25
+ });
26
+
27
+ test("isOpaqueAlpha honours a custom threshold", () => {
28
+ assert.equal(isOpaqueAlpha(120, 128), false);
29
+ assert.equal(isOpaqueAlpha(128, 128), true);
30
+ });
@@ -99,9 +99,16 @@ L2/PTY tradeoffs.
99
99
 
100
100
  The overlay is a transparent, always-on-top window spanning the work area, kept
101
101
  click-through except over the pet and bubble chips (via `setIgnoreMouseEvents`
102
- with mouse-move forwarding). The pet is positioned inside the window and dragged
103
- via CSS; the bubble panel is placed on whichever side of the pet has room so it
104
- stays fully on-screen. The pet currently lives on a single display's work area.
102
+ with mouse-move forwarding). Over the pet the hit test is **pixel-precise**: the
103
+ renderer samples the live canvas (`getImageData`) at the cursor and only
104
+ intercepts where the current frame has opaque pixels, so the transparent margins
105
+ of the sprite cell pass clicks through too. The press/drag is held interactive
106
+ for its whole duration (the running frames have a different silhouette), and the
107
+ resize grip keeps its own bounding-box reveal so pixel precision never hides it
108
+ (`pet-hit-test.js`, `pet-window.js`). The pet is positioned inside the window and
109
+ dragged via CSS; the bubble panel is placed on whichever side of the pet has room
110
+ so it stays fully on-screen. The pet currently lives on a single display's work
111
+ area.
105
112
 
106
113
  The bubble panel shows at most three sessions and scrolls for the rest (capped
107
114
  by the smaller of a height budget and a count budget, see
@@ -103,6 +103,32 @@ Issues found in live use, with their current status.
103
103
  `Notification` hook by type (`permission_prompt`→approval, `idle_prompt`→idle) so
104
104
  non-approval notifications no longer masquerade as approvals.
105
105
 
106
+ ## ✅ Resolved: pet stuck on "thinking" after an Esc interrupt
107
+
108
+ - **Symptom:** With hooks enabled, pressing Esc to interrupt the agent — most
109
+ visibly while it was *thinking* with no tool running — left the pet spinning on
110
+ *thinking* (or *running*) until the 30 s stale sweep, instead of showing the
111
+ turn was cut short. Affected both Claude Code and Codex.
112
+ - **Root cause:** Neither client fires a hook on an interrupt. `Stop` fires only
113
+ on a *normal* turn end, so the hook-driven status had no event to leave the
114
+ working state. A timeout was rejected (same reasoning as the denial case — it
115
+ would misreport a genuinely long turn).
116
+ - **Fix:** The existing **L3 transcript watchers** already tail each client's
117
+ session JSONL, so they now also recognise the interrupt marker each client
118
+ *does* write — ground truth, not a timer. Claude records a synthetic user
119
+ message `[Request interrupted by user]` (and `…for tool use]`); Codex records
120
+ an `event_msg` with `payload.type: "turn_aborted"`. On seeing it the wrapper
121
+ reports a dedicated **`interrupted`** state (summary "interrupted", source
122
+ `client_log`).
123
+ - **Why a new state, not `failed`:** the first attempt reported `failed`, which
124
+ *looked* right (red ✕) but is in `bubble-linger.js`'s `ENDED_STATES`, so the
125
+ linger logic treated the interrupt as a finished session and **hid the bubble
126
+ after ~2 s** — even though the session was still alive (no unregister on an
127
+ interrupt). `interrupted` maps to the same red ✕ kind and the same one-shot pet
128
+ reaction as `failed`, but is **not** terminal (not in `ENDED_STATES` or the
129
+ pet's `TERMINAL_STATES`), so the bubble stays visible until the next turn (or a
130
+ real exit). Heartbeats keep it from going stale.
131
+
106
132
  ## ✅ Resolved: pet stuck on "waiting for approval" after the user ACCEPTS
107
133
 
108
134
  - **Symptom:** The denial fix above covered "deny", but **accepting** a prompt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -17,6 +17,12 @@ const REJECTION_MARKERS = Object.freeze([
17
17
  "user chose not to"
18
18
  ]);
19
19
 
20
+ // When the user presses Esc, Claude fires no Stop hook — it writes a synthetic
21
+ // USER message carrying this marker (e.g. "[Request interrupted by user]" or
22
+ // "[Request interrupted by user for tool use]"). That's the only signal the turn
23
+ // was aborted, so the pet doesn't stay stuck on "thinking"/"running".
24
+ const INTERRUPT_MARKER = "request interrupted by user";
25
+
20
26
  // Returns a resolution event for a single transcript line, or undefined.
21
27
  // Currently we only surface denials — the approve path is already covered by the
22
28
  // PostToolUse hook, and emitting on every result would race with it.
@@ -33,6 +39,8 @@ export function parseTranscriptLine(line) {
33
39
  return undefined;
34
40
  }
35
41
 
42
+ const isUserMessage = entry?.message?.role === "user";
43
+
36
44
  for (const block of content) {
37
45
  if (block?.type === "tool_result" && block.is_error === true) {
38
46
  const text = extractText(block.content).toLowerCase();
@@ -40,6 +48,14 @@ export function parseTranscriptLine(line) {
40
48
  return { type: "tool_denied", toolUseId: block.tool_use_id };
41
49
  }
42
50
  }
51
+
52
+ // Only a user message carries the synthetic interrupt marker; guarding on the
53
+ // role avoids matching the assistant merely quoting the phrase.
54
+ if (block?.type === "text" && isUserMessage) {
55
+ if (extractText(block).toLowerCase().includes(INTERRUPT_MARKER)) {
56
+ return { type: "interrupted" };
57
+ }
58
+ }
43
59
  }
44
60
 
45
61
  return undefined;
@@ -12,12 +12,12 @@ export function parseCodexTranscriptLine(line, options = {}) {
12
12
  return undefined;
13
13
  }
14
14
 
15
- if (entry?.type !== "response_item") {
15
+ if (!entry || typeof entry !== "object") {
16
16
  return undefined;
17
17
  }
18
18
 
19
19
  // Skip records from before the current session (used when replaying a
20
- // freshly-discovered transcript so an earlier session's tool calls don't
20
+ // freshly-discovered transcript so an earlier session's events don't
21
21
  // masquerade as live activity). Records without a parseable timestamp are
22
22
  // kept — losing live events is worse than a rare stale one.
23
23
  const minTimestampMs = options.minTimestampMs ?? 0;
@@ -33,6 +33,17 @@ export function parseCodexTranscriptLine(line, options = {}) {
33
33
  return undefined;
34
34
  }
35
35
 
36
+ // The user pressing Esc aborts the turn. Codex fires no Stop hook on an abort,
37
+ // so this event_msg is the only signal the turn ended by interruption — without
38
+ // it the pet stays stuck on "thinking" until the stale sweep.
39
+ if (entry.type === "event_msg" && payload.type === "turn_aborted") {
40
+ return { type: "turn_aborted", reason: typeof payload.reason === "string" ? payload.reason : undefined };
41
+ }
42
+
43
+ if (entry.type !== "response_item") {
44
+ return undefined;
45
+ }
46
+
36
47
  if (payload.type === "function_call" || payload.type === "custom_tool_call") {
37
48
  const toolName = typeof payload.name === "string" ? payload.name : undefined;
38
49
  const toolCallId = typeof payload.call_id === "string" ? payload.call_id : undefined;
@@ -68,3 +68,41 @@ test("parseTranscriptLines collects only denial events from a batch", () => {
68
68
  { type: "tool_denied", toolUseId: "toolu_b" }
69
69
  ]);
70
70
  });
71
+
72
+ function interruptLine(text = "[Request interrupted by user]") {
73
+ return JSON.stringify({
74
+ type: "user",
75
+ message: { role: "user", content: [{ type: "text", text }] },
76
+ interruptedMessageId: "msg_1"
77
+ });
78
+ }
79
+
80
+ test("parseTranscriptLine detects a user interrupt during text generation", () => {
81
+ assert.deepEqual(parseTranscriptLine(interruptLine()), { type: "interrupted" });
82
+ });
83
+
84
+ test("parseTranscriptLine detects a user interrupt during tool use", () => {
85
+ assert.deepEqual(
86
+ parseTranscriptLine(interruptLine("[Request interrupted by user for tool use]")),
87
+ { type: "interrupted" }
88
+ );
89
+ });
90
+
91
+ test("parseTranscriptLine ignores the interrupt phrase quoted by the assistant", () => {
92
+ const line = JSON.stringify({
93
+ message: { role: "assistant", content: [{ type: "text", text: "Earlier you saw [Request interrupted by user]." }] }
94
+ });
95
+ assert.equal(parseTranscriptLine(line), undefined);
96
+ });
97
+
98
+ test("parseTranscriptLine ignores a normal user prompt", () => {
99
+ const line = JSON.stringify({ message: { role: "user", content: [{ type: "text", text: "fix this bug" }] } });
100
+ assert.equal(parseTranscriptLine(line), undefined);
101
+ });
102
+
103
+ test("parseTranscriptLines collects interrupt events alongside denials", () => {
104
+ assert.deepEqual(parseTranscriptLines([interruptLine(), rejectionLine("toolu_z")]), [
105
+ { type: "interrupted" },
106
+ { type: "tool_denied", toolUseId: "toolu_z" }
107
+ ]);
108
+ });
@@ -95,3 +95,25 @@ test("parseCodexTranscriptLines can skip records older than the session start",
95
95
  }
96
96
  ]);
97
97
  });
98
+
99
+ test("parseCodexTranscriptLine detects a user-interrupted turn", () => {
100
+ const event = parseCodexTranscriptLine(JSON.stringify({
101
+ type: "event_msg",
102
+ payload: { type: "turn_aborted", reason: "interrupted" }
103
+ }));
104
+
105
+ assert.deepEqual(event, { type: "turn_aborted", reason: "interrupted" });
106
+ });
107
+
108
+ test("parseCodexTranscriptLine skips a turn_aborted older than the session start", () => {
109
+ const old = JSON.stringify({
110
+ timestamp: "2026-06-08T10:59:59.000Z",
111
+ type: "event_msg",
112
+ payload: { type: "turn_aborted", reason: "interrupted" }
113
+ });
114
+
115
+ assert.equal(
116
+ parseCodexTranscriptLine(old, { minTimestampMs: Date.parse("2026-06-08T11:00:00.000Z") }),
117
+ undefined
118
+ );
119
+ });
@@ -34,6 +34,7 @@ export function watchClaudeTranscript(options = {}) {
34
34
  homeDir = process.env.USERPROFILE || process.env.HOME,
35
35
  startedAt = 0,
36
36
  onDenial = () => {},
37
+ onInterrupt = () => {},
37
38
  pollIntervalMs = DEFAULT_POLL_MS,
38
39
  projectsRoot,
39
40
  transcriptPath: fixedPath,
@@ -82,6 +83,8 @@ export function watchClaudeTranscript(options = {}) {
82
83
  for (const event of parseTranscriptLines(lines)) {
83
84
  if (event.type === "tool_denied") {
84
85
  onDenial(event);
86
+ } else if (event.type === "interrupted") {
87
+ onInterrupt(event);
85
88
  }
86
89
  }
87
90
  } catch {
@@ -17,6 +17,14 @@ function rejection(toolUseId) {
17
17
  })}\n`;
18
18
  }
19
19
 
20
+ function interrupt() {
21
+ return `${JSON.stringify({
22
+ type: "user",
23
+ message: { role: "user", content: [{ type: "text", text: "[Request interrupted by user]" }] },
24
+ interruptedMessageId: "msg_1"
25
+ })}\n`;
26
+ }
27
+
20
28
  test("claudeProjectDirName sanitizes drive + separators like Claude does", () => {
21
29
  assert.equal(claudeProjectDirName("D:\\Projects\\AI\\haya-pet"), "D--Projects-AI-haya-pet");
22
30
  assert.equal(claudeProjectDirName("/home/a/proj"), "-home-a-proj");
@@ -119,3 +127,23 @@ test("watchClaudeTranscript handles a line split across two appends", () => {
119
127
 
120
128
  watcher.stop();
121
129
  });
130
+
131
+ test("watchClaudeTranscript reports an interrupt appended after it starts tailing", () => {
132
+ const dir = mkdtempSync(join(tmpdir(), "transcript-"));
133
+ const path = join(dir, "session.jsonl");
134
+ writeFileSync(path, "");
135
+
136
+ const interrupts = [];
137
+ const watcher = watchClaudeTranscript({
138
+ transcriptPath: path,
139
+ onInterrupt: (event) => interrupts.push(event),
140
+ ...noopTimers
141
+ });
142
+ watcher._tick();
143
+
144
+ appendFileSync(path, interrupt());
145
+ watcher._tick();
146
+ assert.deepEqual(interrupts, [{ type: "interrupted" }]);
147
+
148
+ watcher.stop();
149
+ });
@@ -15,6 +15,14 @@ function toolStart(toolName = "shell_command", callId = "call_1", timestamp) {
15
15
  })}\n`;
16
16
  }
17
17
 
18
+ function turnAborted(timestamp) {
19
+ return `${JSON.stringify({
20
+ ...(timestamp ? { timestamp } : {}),
21
+ type: "event_msg",
22
+ payload: { type: "turn_aborted", reason: "interrupted" }
23
+ })}\n`;
24
+ }
25
+
18
26
  test("discoverCodexTranscript finds the newest session jsonl under date folders", () => {
19
27
  const root = mkdtempSync(join(tmpdir(), "codex-sessions-"));
20
28
  const oldDir = join(root, "2026", "06", "07");
@@ -106,3 +114,24 @@ test("watchCodexTranscript replays current-session records when a transcript is
106
114
 
107
115
  watcher.stop();
108
116
  });
117
+
118
+ test("watchCodexTranscript forwards a turn_aborted interrupt event", () => {
119
+ const dir = mkdtempSync(join(tmpdir(), "codex-transcript-"));
120
+ const path = join(dir, "session.jsonl");
121
+ writeFileSync(path, "");
122
+
123
+ const events = [];
124
+ const watcher = watchCodexTranscript({
125
+ transcriptPath: path,
126
+ onToolEvent: (event) => events.push(event),
127
+ ...noopTimers
128
+ });
129
+
130
+ watcher._tick();
131
+ appendFileSync(path, turnAborted());
132
+ watcher._tick();
133
+
134
+ assert.deepEqual(events, [{ type: "turn_aborted", reason: "interrupted" }]);
135
+
136
+ watcher.stop();
137
+ });
@@ -39,6 +39,7 @@ const AI_STATE_TO_PET_ACTION = Object.freeze({
39
39
  reviewing: "review",
40
40
  compacting: "review",
41
41
  failed: "failed",
42
+ interrupted: "failed",
42
43
  success: "jumping",
43
44
  stale: "waiting",
44
45
  exited: "jumping"
@@ -1,6 +1,6 @@
1
1
  import { readdir as fsReaddir, readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { pathToFileURL } from "node:url";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { parsePetManifest } from "./manifest.js";
5
5
 
6
6
  // Scans pet search paths for Codex-compatible pets. Each pet is a directory
@@ -28,6 +28,33 @@ export async function discoverPets(searchPaths = [], deps = {}) {
28
28
  return pets;
29
29
  }
30
30
 
31
+ // discoverPets only scans the user's pet folders (~/.codex/pets, ~/.haya-pet/pets),
32
+ // so a fresh install with no pets renders the dev placeholder instead of a real
33
+ // character. The package ships a ready-to-use pet; this composes it in as a
34
+ // last resort — appended after the user's pets so a real install still wins for
35
+ // the default selection, and deduped by id so a user who copied it in isn't
36
+ // shown two. The bundled directory is injectable so this stays testable.
37
+ export async function discoverPetsWithFallback(searchPaths = [], deps = {}) {
38
+ const pets = await discoverPets(searchPaths, deps);
39
+ const fallbackDir = deps.fallbackPetDir ?? getBundledFallbackPetDir();
40
+ const fallback = await loadPetFromDir(fallbackDir, deps);
41
+
42
+ if (!fallback || pets.some((pet) => pet.manifest.id === fallback.manifest.id)) {
43
+ return pets;
44
+ }
45
+
46
+ return [...pets, fallback];
47
+ }
48
+
49
+ // Absolute path to the bundled fallback pet, resolved relative to this module so
50
+ // it works the same for global installs, npm link, and source checkouts. The
51
+ // asset lives at the package root (assets/fallback-pet); this file sits three
52
+ // levels below it (packages/pet-core/src).
53
+ export function getBundledFallbackPetDir() {
54
+ const here = dirname(fileURLToPath(import.meta.url));
55
+ return join(here, "..", "..", "..", "assets", "fallback-pet");
56
+ }
57
+
31
58
  export async function loadPetFromDir(petDir, deps = {}) {
32
59
  const { readFile, stat } = resolveFs(deps);
33
60
  const manifestPath = join(petDir, "pet.json");
@@ -1,7 +1,12 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { join } from "node:path";
3
3
  import { test } from "../../../test/harness.mjs";
4
- import { discoverPets, loadPetFromDir } from "../src/discovery.js";
4
+ import {
5
+ discoverPets,
6
+ discoverPetsWithFallback,
7
+ getBundledFallbackPetDir,
8
+ loadPetFromDir
9
+ } from "../src/discovery.js";
5
10
 
6
11
  function manifest(id, name) {
7
12
  return JSON.stringify({ id, name, spritesheet: "spritesheet.webp" });
@@ -91,3 +96,60 @@ test("returns undefined for an invalid manifest", async () => {
91
96
  test("tolerates missing search directories", async () => {
92
97
  assert.deepEqual(await discoverPets([join("does-not-exist")], fakeFs()), []);
93
98
  });
99
+
100
+ test("falls back to the bundled pet when the user has no pets", async () => {
101
+ const fallbackDir = join("bundled", "fallback-pet");
102
+ const fs = fakeFs({
103
+ files: {
104
+ [join(fallbackDir, "pet.json")]: manifest("fallback-pet", "Fallback"),
105
+ [join(fallbackDir, "spritesheet.webp")]: "x"
106
+ }
107
+ });
108
+
109
+ const pets = await discoverPetsWithFallback([join("empty")], { ...fs, fallbackPetDir: fallbackDir });
110
+ assert.equal(pets.length, 1);
111
+ assert.equal(pets[0].manifest.id, "fallback-pet");
112
+ });
113
+
114
+ test("appends the bundled pet after the user's own pets", async () => {
115
+ const root = join("petsdir");
116
+ const fallbackDir = join("bundled", "fallback-pet");
117
+ const fs = fakeFs({
118
+ dirs: { [root]: ["cat"] },
119
+ files: {
120
+ [join(root, "cat", "pet.json")]: manifest("cat", "Cat"),
121
+ [join(root, "cat", "spritesheet.webp")]: "x",
122
+ [join(fallbackDir, "pet.json")]: manifest("fallback-pet", "Fallback"),
123
+ [join(fallbackDir, "spritesheet.webp")]: "x"
124
+ }
125
+ });
126
+
127
+ const pets = await discoverPetsWithFallback([root], { ...fs, fallbackPetDir: fallbackDir });
128
+ assert.deepEqual(pets.map((pet) => pet.manifest.id), ["cat", "fallback-pet"]);
129
+ });
130
+
131
+ test("does not duplicate the bundled pet when the user already installed it", async () => {
132
+ const root = join("petsdir");
133
+ const fallbackDir = join("bundled", "fallback-pet");
134
+ const fs = fakeFs({
135
+ dirs: { [root]: ["fallback-pet"] },
136
+ files: {
137
+ [join(root, "fallback-pet", "pet.json")]: manifest("fallback-pet", "User Copy"),
138
+ [join(root, "fallback-pet", "spritesheet.webp")]: "x",
139
+ [join(fallbackDir, "pet.json")]: manifest("fallback-pet", "Bundled"),
140
+ [join(fallbackDir, "spritesheet.webp")]: "x"
141
+ }
142
+ });
143
+
144
+ const pets = await discoverPetsWithFallback([root], { ...fs, fallbackPetDir: fallbackDir });
145
+ assert.equal(pets.length, 1);
146
+ assert.equal(pets[0].manifest.name, "User Copy");
147
+ });
148
+
149
+ // Guards both the package-relative path resolution and that the asset actually
150
+ // ships: if either breaks, a fresh install renders the dev placeholder again.
151
+ test("ships a bundled fallback pet that loads from disk", async () => {
152
+ const pet = await loadPetFromDir(getBundledFallbackPetDir());
153
+ assert.ok(pet, "bundled fallback pet should load from the shipped package");
154
+ assert.equal(pet.manifest.id, "fallback-pet");
155
+ });
@@ -8,6 +8,9 @@ export const AI_CLIENT_STATES = Object.freeze([
8
8
  "reviewing",
9
9
  "compacting",
10
10
  "failed",
11
+ // The user interrupted the turn (Esc). Looks like a failure (red ✕) but is NOT
12
+ // terminal — the session is still alive, so its bubble must not linger-out.
13
+ "interrupted",
11
14
  "success",
12
15
  "stale",
13
16
  "exited"
@@ -20,11 +20,14 @@ test("declares normalized AI states and state sources from the plan", () => {
20
20
  "reviewing",
21
21
  "compacting",
22
22
  "failed",
23
+ "interrupted",
23
24
  "success",
24
25
  "stale",
25
26
  "exited"
26
27
  ]);
27
28
 
29
+ assert.equal(isAiClientState("interrupted"), true);
30
+
28
31
  assert.deepEqual(STATE_SOURCES, [
29
32
  "wrapper",
30
33
  "pty_output",
@@ -16,6 +16,9 @@ const STATUS_KIND_BY_STATE = Object.freeze({
16
16
  waiting_approval: "attention",
17
17
  stale: "attention",
18
18
  failed: "failed",
19
+ // An interrupt shows the same red ✕ as a failure, but it is a live (non-ended)
20
+ // state — see bubble-linger ENDED_STATES — so the bubble stays put.
21
+ interrupted: "failed",
19
22
  idle: "done",
20
23
  success: "done",
21
24
  exited: "done"
@@ -4,14 +4,16 @@ import { mapAiStateToPetAction } from "../../pet-core/src/atlas.js";
4
4
  const TERMINAL_STATES = new Set(["exited", "success"]);
5
5
 
6
6
  // States that should NOT drive a looping stable action. success/exited are
7
- // terminal; failed is a timed reaction (a stray error must not pin the pet on
8
- // the "failed" animation forever).
9
- const NON_STABLE_STATES = new Set(["exited", "success", "failed"]);
7
+ // terminal; failed/interrupted are timed reactions (a stray error or an Esc must
8
+ // not pin the pet on the "failed" animation forever). Note interrupted is NOT in
9
+ // TERMINAL_STATES the session is still alive, it just had its turn cut short.
10
+ const NON_STABLE_STATES = new Set(["exited", "success", "failed", "interrupted"]);
10
11
 
11
12
  // States that play a one-shot reaction exactly once when first entered.
12
13
  const ONE_SHOT_BY_STATE = Object.freeze({
13
14
  success: "jumping",
14
- failed: "failed"
15
+ failed: "failed",
16
+ interrupted: "failed"
15
17
  });
16
18
 
17
19
  // Active-work states. Transitioning from one of these to idle means a turn
@@ -8,6 +8,7 @@ const STATUS_LABELS = Object.freeze({
8
8
  reviewing: "Reviewing",
9
9
  compacting: "Compacting context",
10
10
  failed: "Failed",
11
+ interrupted: "Interrupted",
11
12
  success: "Done",
12
13
  stale: "Stale",
13
14
  exited: "Exited"
@@ -12,6 +12,22 @@ test("ENDED_STATES covers the process-finished states", () => {
12
12
  assert.ok(ENDED_STATES.has("failed"));
13
13
  assert.ok(!ENDED_STATES.has("idle"));
14
14
  assert.ok(!ENDED_STATES.has("running_tool"));
15
+ // An interrupt ends the TURN, not the session — the session is still alive, so
16
+ // its bubble must NOT linger-out and vanish.
17
+ assert.ok(!ENDED_STATES.has("interrupted"));
18
+ });
19
+
20
+ test("keeps an interrupted bubble visible (the session is still alive)", () => {
21
+ // Long after any linger window would have elapsed, an interrupted bubble stays.
22
+ const result = resolveVisibleBubbles({
23
+ bubbles: [bubble("a", "interrupted")],
24
+ now: 1_000_000,
25
+ lingerState: { a: 1000 },
26
+ lingerMs: 2000
27
+ });
28
+ assert.deepEqual(result.visible.map((b) => b.sessionId), ["a"]);
29
+ assert.deepEqual(result.lingerState, {});
30
+ assert.equal(result.nextWakeMs, undefined);
15
31
  });
16
32
 
17
33
  test("keeps active (non-ended) bubbles visible with no linger tracking", () => {
@@ -85,6 +85,14 @@ test("resolves failure to the 'failed' status kind (red cross)", () => {
85
85
  assert.equal(resolveBubbleStatusKind("failed"), "failed");
86
86
  });
87
87
 
88
+ test("resolves an interrupt to the 'failed' status kind (red cross) but keeps it a live state", () => {
89
+ assert.equal(resolveBubbleStatusKind("interrupted"), "failed");
90
+ const view = buildBubbleView({ ...baseSession, state: "interrupted", summary: "interrupted" }, 6_000);
91
+ assert.equal(view.statusKind, "failed");
92
+ assert.equal(view.statusLabel, "Interrupted");
93
+ assert.equal(view.petAction, "failed");
94
+ });
95
+
88
96
  test("resolves idle/finished states to the 'done' status kind (check mark)", () => {
89
97
  for (const state of ["idle", "success", "exited"]) {
90
98
  assert.equal(resolveBubbleStatusKind(state), "done", state);
@@ -82,6 +82,29 @@ test("a failed session still yields to active work for the stable action", () =>
82
82
  assert.equal(result.stableAction, "running");
83
83
  });
84
84
 
85
+ test("an interrupt is a one-shot reaction, not a sticky stable action (like failed)", () => {
86
+ const result = resolveCompanionPetState({
87
+ bubbles: [bubble("a", "interrupted")],
88
+ prioritySessionId: "a",
89
+ previousStates: { a: "thinking" }
90
+ });
91
+ // plays the failed reaction once on the interrupt...
92
+ assert.deepEqual(result.oneShots, ["failed"]);
93
+ // ...but does not pin the pet on a looping action, and the session stays alive
94
+ // (still in the active bubble list, unlike exited/success).
95
+ assert.equal(result.stableAction, "idle");
96
+ assert.deepEqual(result.activeBubbles.map((b) => b.sessionId), ["a"]);
97
+ });
98
+
99
+ test("does not repeat the interrupt reaction while it stays interrupted", () => {
100
+ const result = resolveCompanionPetState({
101
+ bubbles: [bubble("a", "interrupted")],
102
+ prioritySessionId: "a",
103
+ previousStates: { a: "interrupted" }
104
+ });
105
+ assert.deepEqual(result.oneShots, []);
106
+ });
107
+
85
108
  test("celebrates with a one-shot jump when a working turn finishes (working -> idle)", () => {
86
109
  const result = resolveCompanionPetState({
87
110
  bubbles: [bubble("a", "idle")],