diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 946658cbb6..b1e4a2c758 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -1,2 +1,3 @@ # Trusted external contributors, one GitHub username per line without @. # Maintainers, bots, and collaborators with write access are allowed automatically. +sirouk diff --git a/.pylon/features.yaml b/.pylon/features.yaml index 3d1a21fc36..b89401bcd8 100644 --- a/.pylon/features.yaml +++ b/.pylon/features.yaml @@ -6,8 +6,8 @@ fork: mirror_branch: main product_branch: pylon tracking_issue: https://github.com/pylon-code/prime-agent/issues/1 - reviewed_upstream_commit: d60fab8a76d9c169f945341f0ee3bde21903bb55 - reviewed_at: 2026-08-29 + reviewed_upstream_commit: a903d4b6768f484bd6d459b7b0aa7dee38e461e2 + reviewed_at: 2026-08-30 decisions: managed-plan-daemon: @@ -58,8 +58,13 @@ decisions: pylon_refs: - https://github.com/pylon-code/pylon/issues/114 - https://github.com/pylon-code/pylon/issues/126 + upstream_refs: + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1700 + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1845 + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1847 + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1859 fork_change: possible-capability - upstream_support: Prime exposes subagent lifecycle separately from root plan state. + upstream_support: Prime exposes separate subagent lifecycle with unified legacy recovery, one child projection, activity-driven quiescence, and single-send remote-message failure containment, but not Pylon's provider-neutral root progress model. revisit_when: - Root progress can summarize delegated phases without creating one plan row per worker. - Prime exposes a stable aggregate delegated-work status where Pylon lacks one. @@ -71,28 +76,35 @@ decisions: decision: hybridize pylon_refs: - https://github.com/pylon-code/pylon/issues/114 + upstream_refs: + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1858 + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1882 fork_change: candidate - upstream_support: Prime has native background execution, but Pylon lacks complete public lifecycle and ownership mapping. + upstream_support: Prime has native background execution, per-command abort ownership, and daemon-close bash draining, but Pylon lacks complete public lifecycle and ownership mapping. revisit_when: - Prime publishes list, correlation, cancellation, and terminal lifecycle events. correlated-prompt-lifecycle: area: prompt-ownership - state: candidate + state: shipped owner: shared decision: hybridize pylon_refs: - https://github.com/pylon-code/prime-agent/issues/3 - https://github.com/pylon-code/prime-agent/pull/9 + - https://github.com/pylon-code/prime-agent/pull/12 - https://github.com/pylon-code/pylon/issues/114 upstream_refs: - https://github.com/PrimeIntellect-ai/prime-agent/pull/800 - https://github.com/PrimeIntellect-ai/prime-agent/pull/1239 + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1700 + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1756 - https://github.com/PrimeIntellect-ai/prime-agent/pull/1859 - https://github.com/PrimeIntellect-ai/prime-agent/pull/1861 + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1864 fork_change: negotiated-correlated-prompt-lifecycle-v1 - upstream_support: Prime has queue, background, cancellation, reconnect, and event primitives, but no optional contract proving per-prompt ownership, delivery, provenance, terminal usage, and durable generation-scoped recovery together. + upstream_support: Prime through a903d4b6768f has queue, background, cancellation, reconnect, single-send remote messages, recovery-aware reuse, activity-fenced continuations, owner-checked opens, and event primitives, but no optional contract proving per-prompt ownership, delivery, provenance, terminal usage, and durable generation-scoped recovery together. revisit_when: - Prime upstream ships an equivalent negotiated lifecycle with exact delivery, provenance, scoped cancellation, recovery, and per-prompt usage guarantees. - Pylon can remove the fork contract without weakening stock-Prime fallback behavior. @@ -104,8 +116,10 @@ decisions: decision: hybridize pylon_refs: - https://github.com/pylon-code/pylon/issues/114 + upstream_refs: + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1857 fork_change: none-yet - upstream_support: Prime exposes heartbeat list, management, and change APIs. + upstream_support: Prime exposes heartbeat list, management, and change APIs and now derives scoped heartbeat state at render time. blocker: Pylon needs canonical autonomous occurrence, turn, checkpoint, and active-run ownership. revisit_when: - The provider-neutral autonomous-work ownership model is implemented. @@ -157,7 +171,7 @@ decisions: kernel-protocol-test-fixtures: area: runtime-reliability - state: adopted-ahead-of-upstream + state: shipped owner: upstream decision: adopt pylon_refs: @@ -165,41 +179,50 @@ decisions: - https://github.com/pylon-code/prime-agent/pull/5 upstream_refs: - https://github.com/PrimeIntellect-ai/prime-agent/pull/1839 + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1882 - https://github.com/PrimeIntellect-ai/prime-agent/pull/1886 - fork_change: exact-upstream-pr-1886 - upstream_support: "Prime PR #1886 repairs protocol fixtures regressed by the #1839 merge commit, but is not yet on main." + fork_change: none + upstream_support: "Prime PR #1882 landed the same protocol-3 fixture repair that Pylon had adopted ahead from PR #1886; there is no remaining behavioral fork." revisit_when: - - "Prime merges PR #1886 and the exact fix reaches the mirrored main branch." + - "Prime changes the kernel protocol or its real-process fixture contract again." snapshot-recovery-integrity: area: runtime-reliability - state: candidate + state: shipped owner: shared decision: hybridize pylon_refs: - https://github.com/pylon-code/prime-agent/issues/11 + - https://github.com/pylon-code/prime-agent/pull/14 upstream_refs: - https://github.com/PrimeIntellect-ai/prime-agent/issues/1229 - https://github.com/PrimeIntellect-ai/prime-agent/pull/1845 + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1847 - https://github.com/PrimeIntellect-ai/prime-agent/pull/1756 - https://github.com/PrimeIntellect-ai/prime-agent/pull/1864 fork_change: immutable-snapshot-transfer-and-isolated-retry - upstream_support: Prime now provides one RLM child projection, recovery-aware session reuse, and ownership checks for in-flight opens, but still reuses the event cursor as snapshot transfer identity, serializes selected message objects lazily, and closes a healthy worker on an isolated duplicate-transfer mismatch. + upstream_support: Prime now provides one RLM child projection, tolerant legacy topology recovery, recovery-aware session reuse, and ownership checks for in-flight opens, but still reuses the event cursor as snapshot transfer identity, serializes selected message objects lazily, and closes a healthy worker on an isolated duplicate-transfer mismatch. revisit_when: - Prime gives each transfer immutable bytes and a unique identity while preserving the event cursor as ordering metadata. - Prime retries an isolated bad snapshot generation without closing the resident worker control channel. authoritative-owned-session-cleanup: area: runtime-reliability - state: candidate + state: shipped owner: shared decision: hybridize pylon_refs: - https://github.com/pylon-code/prime-agent/issues/11 + - https://github.com/pylon-code/prime-agent/pull/14 - https://github.com/pylon-code/pylon/issues/114 - https://github.com/rynfar/comet/issues/5 + upstream_refs: + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1756 + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1858 + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1864 + - https://github.com/PrimeIntellect-ai/prime-agent/pull/1882 fork_change: negotiated-authoritative-owned-session-cleanup-v1 - upstream_support: Prime v0.8.1 supports client-owned sessions and best-effort owner cleanup, but another host process cannot prove exact worker-generation and durable-descriptor absence after the owner connection disappears. + upstream_support: Prime through a903d4b6768f has recovery-aware reuse, owner-checked opens, bounded daemon-close bash draining, per-command abort ownership, client-owned sessions, and best-effort owner cleanup, but another host process cannot prove exact worker-generation and durable-descriptor absence after the owner connection disappears. revisit_when: - Prime upstream exposes an equivalent privacy-safe, capability-negotiated cleanup proof with durable descriptor-last retries. - Comet and Pylon can remove the fork capability without weakening crash cleanup or stock-Prime fallback behavior. diff --git a/.pylon/upstream-review.md b/.pylon/upstream-review.md index c061af6330..576a81ac58 100644 --- a/.pylon/upstream-review.md +++ b/.pylon/upstream-review.md @@ -66,3 +66,18 @@ This ledger records Prime upstream evidence and the decision taken for each over - Compatibility classification: **backward-compatible, additive, capability-gated**. Private session workers continue to advertise the shared default server capabilities and do not falsely offer the supervisor-only cleanup query. Current clients reject the query locally against stock `v0.8.1` or schema 26 without sending an unknown command. The proof applies only to registrations created or durably adopted by a capable supervisor; it cannot retroactively certify a descriptorless historical orphan. - Validation: 695 focused protocol/client/connection/session/snapshot/supervisor tests passed across 13 files; 40 correlated-lifecycle/queue/continuation tests passed; 13 real-process supervisor tests passed with 8 fixture-gated skips, including direct completion, owner loss before and after worker registration, visible `stopping`, replacement-client `settled` proof, supervisor replacement, exact worker exit, and zero descriptors. Final exact-head review also forced recovery-join, published-replacement rollback retention, reentrant shutdown single-flight, concurrent-completion, and generation-keyed-finalizer repairs before renewed exact-head review. Both stock/current `v0.8.1` adoption directions passed with local cleanup-query rejection on the stock supervisor. `npm run check` passed Biome over 941 files, TypeScript, installer rendering, and browser smoke. The root build passed; the live generated model catalog was restored afterward. `git diff --check` passed. - Revisit when Prime upstream provides equivalent immutable snapshot, attach admission, worker-channel authority, and descriptor-last crash cleanup contracts without weakening the stock fallback or exposing host-private process/session details. + + +## 2026-08-30 — upstream integration through `a903d4b` + +- Upstream range: `PrimeIntellect-ai/prime-agent@d60fab8a76d9c169f945341f0ee3bde21903bb55..a903d4b6768f484bd6d459b7b0aa7dee38e461e2`; latest audited compatibility release remains stock `v0.8.1`. The range is linear and contains Prime PRs #1700, #1860, #1756, #1842, #1845, #1847, #1848, #1855, #1857, #1859, #1882, #1850, #1851, #1858, #1864, and #1899. Every production change has an upstream change fragment; no new release tag is reachable. +- Regenerated merge: `7ae231dbce67cde34d2638a37181ff37cc11de08`, with exact parents post-#11 `pylon@fd5cadc600f867a0a5a989064cce22934e9dad94` and Prime `a903d4b6768f484bd6d459b7b0aa7dee38e461e2`, and tree `f6cff7f22608329b3e9fd4e99c4bd8c66ad14ccd`. The complete tree is byte-for-byte identical to independently approved synthetic tree `f6cff7f22608329b3e9fd4e99c4bd8c66ad14ccd`. The four manual conflict resolutions retain Pylon's durable restored-child terminal `error`, validate current worker active-session authority before publishing refreshed summaries, and retain both deterministic regressions. +- `correlated-prompt-lifecycle`: **hybridize**. Adopt #1700 single-send remote-message containment, #1842 queue single-sourcing, #1859 activity-driven quiescence and fenced continuation serialization, and the #1756/#1864 recovery/open-ownership primitives. Retain Pylon's negotiated per-prompt identity, delivery, provenance, scoped cancellation, event-persistence failure, durable generation recovery, and terminal usage contract from merged PR #12. +- `snapshot-recovery-integrity`: **hybridize**. Adopt #1845's single RLM child projection, #1847's tolerant legacy topology parser, and #1756/#1864 recovery-aware reuse and owner checks. Retain Pylon's immutable transfer bytes and IDs, shared cursor coverage, exact attach-registration cut, isolated retry, terminal error truth, and current-channel/current-roster authority from merged PR #14. +- `authoritative-owned-session-cleanup` and `background-process-lifecycle`: **hybridize**. Adopt #1858's bounded daemon-close bash drain and #1882's per-command abort ownership, while retaining exact process-generation proof, journal/archive proof, descriptor-last removal, cross-client `active | stopping | settled`, recovery joining, generation-keyed finalizers, and single-flight shutdown. Upstream still does not provide complete public background-run ownership or authoritative crash-cleanup proof. +- `heartbeat-projection`: **hybridize** and remain blocked. Adopt #1857's render-time scope and refresh-deadline fixes, but these do not define Pylon's canonical occurrence, turn, checkpoint, or active-run ownership. +- `delegated-work-progress`: **redesign** remains. Adopt #1700/#1845/#1847/#1859 delivery, projection, legacy recovery, and activity primitives; they improve the separate Agents surface but do not supply provider-neutral root phase progress. +- `kernel-protocol-test-fixtures`: **adopt** and mark shipped. PR #1882 contains the same protocol-3 fixture repair Pylon previously adopted ahead from PR #1886, so no behavioral fork remains. Adopt the remaining AI, telemetry, interactive selector/queue, model-resolver, daemon-lookup, and contributor-vouch refactors (#1860, #1848, #1855, #1850, #1851, #1899); they do not supersede another Pylon decision. +- Fork boundaries remain intact: no inherited Linear gate or release workflow is restored; optional protocol behavior remains capability-negotiated; the `main` mirror and `pylon` product branch roles are unchanged. +- Validation: exact merge `npm run check`, installer render, browser smoke, root build, and `git diff --check` passed; the live generated model catalog was restored. A clean-env focused boundary set passed 269 executions (18 continuation, 12 correlated lifecycle, 18 lazy-subagent, 109 supervisor-monitor, and 112 recursion). Native-install full shard 1 passed 1,466 tests with 24 skipped; shard 3 passed 1,241 with 22 skipped. Shard 2 recorded 1,865 passes with 9 skips while three load-sensitive cases tripped wall-clock/process thresholds; all 28 affected cases passed immediately in isolation. The real-process supervisor suite passed 13 tests with 8 fixture-gated skips. Both stock/current `v0.8.1` adoption directions and the 36 MiB compiled transfer bound passed. Trusted exact-head CI remains mandatory before merge. +- Independent exact-commit security/regression review found no P0/P1, verified both-parent ancestry and all 1,245 tree entries, and approved the retained conflict semantics. Revisit each hybrid only when Prime supplies an explicitly negotiated equivalent without weakening Pylon privacy, lifecycle, recovery, cleanup, or stock-Prime fallback guarantees. diff --git a/packages/ai/.changes/remove-overflow-pattern.md b/packages/ai/.changes/remove-overflow-pattern.md new file mode 100644 index 0000000000..7f2dc8f19f --- /dev/null +++ b/packages/ai/.changes/remove-overflow-pattern.md @@ -0,0 +1 @@ +- Removed the `getOverflowPatterns()` test helper export from `utils/overflow.ts`; use `isContextOverflow()` directly. diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index 648f2343ed..bf59405d82 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -142,10 +142,3 @@ export function isContextOverflow(message: AssistantMessage, contextWindow?: num return false; } - -/** - * Get the overflow patterns for testing purposes. - */ -export function getOverflowPatterns(): RegExp[] { - return [...OVERFLOW_PATTERNS]; -} diff --git a/packages/coding-agent/.changes/await-daemon-bash-end.md b/packages/coding-agent/.changes/await-daemon-bash-end.md new file mode 100644 index 0000000000..e74b823eb4 --- /dev/null +++ b/packages/coding-agent/.changes/await-daemon-bash-end.md @@ -0,0 +1 @@ +- Made daemon shutdown wait for Bash completion without polling. diff --git a/packages/coding-agent/.changes/derive-scoped-heartbeats.md b/packages/coding-agent/.changes/derive-scoped-heartbeats.md new file mode 100644 index 0000000000..d917a1484e --- /dev/null +++ b/packages/coding-agent/.changes/derive-scoped-heartbeats.md @@ -0,0 +1 @@ +- Kept heartbeat lists current when session or subagent scope changes. diff --git a/packages/coding-agent/.changes/eng-5533-worker-recovery.md b/packages/coding-agent/.changes/eng-5533-worker-recovery.md new file mode 100644 index 0000000000..7e607bb4a4 --- /dev/null +++ b/packages/coding-agent/.changes/eng-5533-worker-recovery.md @@ -0,0 +1 @@ +- Fixed saved-session resume when its resident worker is still recovering after a daemon restart. diff --git a/packages/coding-agent/.changes/execute-bash-abort-controller.md b/packages/coding-agent/.changes/execute-bash-abort-controller.md new file mode 100644 index 0000000000..c997cafa59 --- /dev/null +++ b/packages/coding-agent/.changes/execute-bash-abort-controller.md @@ -0,0 +1 @@ +- Fixed concurrent `execute_bash_and_wait` commands sharing one bash abort controller: each `executeBash` invocation now gets its own controller, so a finishing command no longer clears a still-running command's abort state and `abortBash` cancels every in-flight command. diff --git a/packages/coding-agent/.changes/legacy-rlm-parser.md b/packages/coding-agent/.changes/legacy-rlm-parser.md new file mode 100644 index 0000000000..15cf1b793a --- /dev/null +++ b/packages/coding-agent/.changes/legacy-rlm-parser.md @@ -0,0 +1 @@ +- Fixed passive RLM child metadata recovery from legacy registries without a session directory. diff --git a/packages/coding-agent/.changes/pending-open-ownership.md b/packages/coding-agent/.changes/pending-open-ownership.md new file mode 100644 index 0000000000..0750e13c65 --- /dev/null +++ b/packages/coding-agent/.changes/pending-open-ownership.md @@ -0,0 +1 @@ +- Fixed a race where a concurrent open of a session already being opened by another client bypassed the session ownership check instead of failing with session-already-active. diff --git a/packages/coding-agent/.changes/post-compaction-idle.md b/packages/coding-agent/.changes/post-compaction-idle.md new file mode 100644 index 0000000000..c059f46e0b --- /dev/null +++ b/packages/coding-agent/.changes/post-compaction-idle.md @@ -0,0 +1 @@ +- Removed the delay before continuing sessions after compaction. diff --git a/packages/coding-agent/.changes/remote-message-single-send.md b/packages/coding-agent/.changes/remote-message-single-send.md new file mode 100644 index 0000000000..6ebe037872 --- /dev/null +++ b/packages/coding-agent/.changes/remote-message-single-send.md @@ -0,0 +1 @@ +- Fixed remote agent messages being delivered twice when the daemon request timed out or the response was lost: the message is now sent exactly once per call, and post-send failures surface as errors instead of triggering a resend. diff --git a/packages/coding-agent/.changes/remove-daemon-lookup-fake.md b/packages/coding-agent/.changes/remove-daemon-lookup-fake.md new file mode 100644 index 0000000000..789c472947 --- /dev/null +++ b/packages/coding-agent/.changes/remove-daemon-lookup-fake.md @@ -0,0 +1 @@ +- Removed the test-only daemon active-session lookup override. diff --git a/packages/coding-agent/.changes/remove-empty-selector-timers.md b/packages/coding-agent/.changes/remove-empty-selector-timers.md new file mode 100644 index 0000000000..96a3643832 --- /dev/null +++ b/packages/coding-agent/.changes/remove-empty-selector-timers.md @@ -0,0 +1 @@ +- Removed delayed cancellation callbacks from empty interactive selectors. diff --git a/packages/coding-agent/.changes/remove-test-telemetry-branch.md b/packages/coding-agent/.changes/remove-test-telemetry-branch.md new file mode 100644 index 0000000000..3504399521 --- /dev/null +++ b/packages/coding-agent/.changes/remove-test-telemetry-branch.md @@ -0,0 +1 @@ +- Stopped treating `NODE_ENV=test` as an implicit telemetry opt-out. diff --git a/packages/coding-agent/.changes/rlm-activity-change-waiter.md b/packages/coding-agent/.changes/rlm-activity-change-waiter.md new file mode 100644 index 0000000000..754d5df11a --- /dev/null +++ b/packages/coding-agent/.changes/rlm-activity-change-waiter.md @@ -0,0 +1 @@ +- Wait for RLM session activity changes without zero-delay polling. diff --git a/packages/coding-agent/.changes/rlm-child-snapshot.md b/packages/coding-agent/.changes/rlm-child-snapshot.md new file mode 100644 index 0000000000..ce9aa258aa --- /dev/null +++ b/packages/coding-agent/.changes/rlm-child-snapshot.md @@ -0,0 +1 @@ +- Fixed reattached sessions omitting queued child agents or showing the wrong child activity. diff --git a/packages/coding-agent/.changes/snimu-tui-queue-single-source.md b/packages/coding-agent/.changes/snimu-tui-queue-single-source.md new file mode 100644 index 0000000000..09067e72b8 --- /dev/null +++ b/packages/coding-agent/.changes/snimu-tui-queue-single-source.md @@ -0,0 +1 @@ +- Fixed queued-message editing so duplicate prompts always target the selected queue entry. diff --git a/packages/coding-agent/.changes/test-only-parser-hints.md b/packages/coding-agent/.changes/test-only-parser-hints.md new file mode 100644 index 0000000000..65a6e65a82 --- /dev/null +++ b/packages/coding-agent/.changes/test-only-parser-hints.md @@ -0,0 +1 @@ +- Simplified model resolution and feature hint shuffling internals. diff --git a/packages/coding-agent/.changes/vouch-sirouk.md b/packages/coding-agent/.changes/vouch-sirouk.md new file mode 100644 index 0000000000..0021808907 --- /dev/null +++ b/packages/coding-agent/.changes/vouch-sirouk.md @@ -0,0 +1 @@ +- Accept contributions from sirouk as a vouched external contributor. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 441325541a..a2de9a3d54 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -850,11 +850,12 @@ function createAgentMessageDeferred(): AgentMessageDeferred { /** One-shot settlement for a scheduled post-compaction continuation; a settled failure is never re-exposed to later waiters. */ interface PostCompactionContinuationSettlement extends AgentMessageDeferred { + continueAfterSessionInput: boolean; settled: boolean; } function createPostCompactionContinuationSettlement(): PostCompactionContinuationSettlement { - return { ...createAgentMessageDeferred(), settled: false }; + return { ...createAgentMessageDeferred(), continueAfterSessionInput: false, settled: false }; } export interface ModelCycleResult { @@ -1107,7 +1108,7 @@ export class AgentSession { private _pendingSessionActionFenceWaiters = 0; private readonly _sessionActionCommitContext = new AsyncLocalStorage(); private readonly _sessionActionCommitDisposeAbortController = new AbortController(); - // Checkpoint and handoff waiters share lifecycle-edge notifications to avoid polling. + // Checkpoint, handoff, and activity waiters share lifecycle-edge notifications to avoid polling. private readonly _sessionInputCheckpointWaiters = new Set<() => void>(); private _pendingNextTurnMessages: CustomMessage[] = []; @@ -1143,7 +1144,7 @@ export class AgentSession { /** Outcome disclosures whose session-file append failed; retained for context rebuilds. */ private readonly _unpersistedOutcomes: CustomMessage[] = []; - private _bashAbortController: AbortController | undefined = undefined; + private _bashAbortControllers = new Set(); private _userBashRunning = false; private _userBashAbortRequested = false; private _pendingBashMessages: BashExecutionMessage[] = []; @@ -1241,7 +1242,6 @@ export class AgentSession { private _compactAutoRefinePending = false; private _turnIntervalAutoRefinePending = false; private _postCompactionContinuationScheduled = false; - private _postCompactionContinuationTimer: ReturnType | undefined; private _postCompactionContinuationSettlement: PostCompactionContinuationSettlement | undefined; private _postCompactionContinuationMessages: AgentMessage[] = []; private _scheduledPostCompactionContinuationMessages: AgentMessage[] = []; @@ -2677,6 +2677,7 @@ export class AgentSession { if (this._refineInFlight === applySettled) { this._refineInFlight = undefined; } + this._notifySessionInputCheckpointChange(); this._scheduleSessionInputPump(); } } @@ -2885,6 +2886,7 @@ export class AgentSession { if (this._refineInFlight === applySettled) { this._refineInFlight = undefined; } + this._notifySessionInputCheckpointChange(); this._scheduleSessionInputPump(); } } @@ -3915,6 +3917,7 @@ export class AgentSession { this._retryResolve(); this._retryResolve = undefined; this._retryPromise = undefined; + this._notifySessionInputCheckpointChange(); this._scheduleSessionInputPump(); } } @@ -7114,6 +7117,19 @@ export class AgentSession { for (const resolve of waiters) resolve(); } + private _waitForSessionActivityChange(signal: AbortSignal): Promise { + return new Promise((resolve) => { + const finish = () => { + this._sessionInputCheckpointWaiters.delete(finish); + signal.removeEventListener("abort", finish); + resolve(); + }; + this._sessionInputCheckpointWaiters.add(finish); + signal.addEventListener("abort", finish, { once: true }); + if (signal.aborted) finish(); + }); + } + private _observeSessionActionDeferral(action: QueuedSessionAction): { deferred: Promise; stop(): void; @@ -7325,10 +7341,29 @@ export class AgentSession { } async waitForIdle(): Promise { - while (true) { + await this._waitForIdleOrSettlement(); + } + + /** + * {@link waitForIdle} loop; with a settlement, returns once that settlement is + * superseded so a cancelled post-compaction runner cannot keep a checkpoint + * waiter registered (a leaked waiter holds hasPendingAdmissionWaiters true and + * blocks daemon passivation). + */ + private async _waitForIdleOrSettlement(settlement?: PostCompactionContinuationSettlement): Promise { + while (settlement === undefined || this._postCompactionContinuationSettlement === settlement) { if (this._actionStore.queuedActions().length > 0) { if (this._sessionInputPumpSuspended || this._queuedWorkPauses.size > 0) { - await new Promise((resolve) => this._sessionInputCheckpointWaiters.add(resolve)); + let wake = () => {}; + const changed = new Promise((resolve) => { + wake = resolve; + this._sessionInputCheckpointWaiters.add(resolve); + }); + try { + await (settlement ? Promise.race([changed, settlement.promise]) : changed); + } finally { + this._sessionInputCheckpointWaiters.delete(wake); + } continue; } this._scheduleSessionInputPump(); @@ -7850,6 +7885,7 @@ export class AgentSession { throw new Error("Cannot compact without aborting while the agent is running."); } const hadPostCompactionContinue = this._postCompactionContinuationScheduled; + const continueAfterSessionInput = this._postCompactionContinuationSettlement?.continueAfterSessionInput ?? false; this._disconnectFromAgent(); if (!options.skipAbort) await this.abort(); let didCompact = false; @@ -7914,11 +7950,12 @@ export class AgentSession { this._compactionOperation = undefined; } resolveCompactionOperation(); + this._notifySessionInputCheckpointChange(); this._scheduleSessionInputPump(); if (didCompact) { this._discardPendingAutoRefine({ cancelPostCompactionContinue: true }); if (hadPostCompactionContinue) { - this._schedulePostCompactionContinue(); + this._schedulePostCompactionContinue(continueAfterSessionInput); } // Queued agent or session-owned inputs resume the loop; defer refine // behind them instead of interleaving it before their turns. @@ -8037,20 +8074,17 @@ export class AgentSession { } private _settlePostCompactionContinue(error?: Error): void { - if (!error && (this._postCompactionContinuationScheduled || this._postCompactionContinuationTimer)) return; + if (!error && this._postCompactionContinuationScheduled) return; const settlement = this._postCompactionContinuationSettlement; if (!settlement || settlement.settled) return; settlement.settled = true; this._postCompactionContinuationSettlement = undefined; if (error) settlement.reject(error); else settlement.resolve(); + this._notifySessionInputCheckpointChange(); } private _cancelPostCompactionContinue(): void { - if (this._postCompactionContinuationTimer) { - clearTimeout(this._postCompactionContinuationTimer); - this._postCompactionContinuationTimer = undefined; - } this._postCompactionContinuationScheduled = false; this._scheduledPostCompactionContinuationMessages = []; this._settlePostCompactionContinue(); @@ -8147,75 +8181,126 @@ export class AgentSession { this._scheduleAutoRefine("compact"); } - private _schedulePostCompactionContinue(): void { - if (this._postCompactionContinuationScheduled) { - return; - } + private _schedulePostCompactionContinue(continueAfterSessionInput = false): void { if (!this._postCompactionContinuationSettlement || this._postCompactionContinuationSettlement.settled) { this._postCompactionContinuationSettlement = createPostCompactionContinuationSettlement(); } + const settlement = this._postCompactionContinuationSettlement; + settlement.continueAfterSessionInput ||= continueAfterSessionInput; + if (this._postCompactionContinuationScheduled) { + return; + } this._postCompactionContinuationScheduled = true; this._scheduledPostCompactionContinuationMessages = [...this._postCompactionContinuationMessages]; - this._postCompactionContinuationTimer = setTimeout(() => { - this._postCompactionContinuationTimer = undefined; - void this._runScheduledPostCompactionContinue() - .catch(() => undefined) - .finally(() => this._settlePostCompactionContinue()); - }, 100); + void this._runScheduledPostCompactionContinue(settlement) + .catch(() => undefined) + .finally(() => { + if (this._postCompactionContinuationSettlement === settlement) { + this._settlePostCompactionContinue(); + } + }); } private _sessionOwnsScheduledContinuations(continuationMessages: AgentMessage[]): boolean { return continuationMessages.some((message) => this._postCompactionContinuationMessages.includes(message)); } - private async _runScheduledPostCompactionContinue(): Promise { - await this._waitForRefineIdle(); - if (!this._postCompactionContinuationScheduled) { - return; - } - if (this.isStreaming || this.isCompacting || this.isRetrying || this._queuedWorkPauses.size > 0) { - this._postCompactionContinuationScheduled = false; - this._schedulePostCompactionContinue(); - return; + private async _waitForQueuedWorkResume(settlement: PostCompactionContinuationSettlement): Promise { + while (this._queuedWorkPauses.size > 0 && this._postCompactionContinuationSettlement === settlement) { + let resume = () => {}; + const resumed = new Promise((resolve) => { + resume = resolve; + this._sessionInputCheckpointWaiters.add(resolve); + }); + try { + await Promise.race([resumed, settlement.promise]); + } finally { + this._sessionInputCheckpointWaiters.delete(resume); + } } + } - const continuationMessages = [...this._scheduledPostCompactionContinuationMessages]; - if (continuationMessages.length > 0 && !this._sessionOwnsScheduledContinuations(continuationMessages)) { - this._cancelPostCompactionContinue(); - this._scheduleAutoRefineAfterAgentEnd(); - return; - } - // An empty queue is not idle while the scheduler still owns active work. - if (this.unfinishedActionCount > 0 || this._sessionInputPumpRequested) { - this._scheduleSessionInputPump(); - await this._sessionInputPump; - if (this._postCompactionContinuationScheduled) { - this._postCompactionContinuationScheduled = false; - const shouldReschedule = - continuationMessages.length === 0 - ? this.unfinishedActionCount > 0 - : this._sessionOwnsScheduledContinuations(continuationMessages); - if (shouldReschedule) { - this._schedulePostCompactionContinue(); - } else { - this._scheduledPostCompactionContinuationMessages = []; + private async _runScheduledPostCompactionContinue(settlement: PostCompactionContinuationSettlement): Promise { + while (this._postCompactionContinuationScheduled && this._postCompactionContinuationSettlement === settlement) { + await this.agent.waitForIdle(); + await this.waitForRetry(); + await this._waitForRefineIdle(); + await this._waitForQueuedWorkResume(settlement); + const compactionOperation = this._compactionOperation; + if (compactionOperation) { + await Promise.race([compactionOperation, settlement.promise]); + continue; + } + + const commitFence = await this._acquireSessionActionCommitFence(); + let continuation: Promise | undefined; + let continuationMessages: AgentMessage[] = []; + let waitForSessionInput = false; + try { + await this.agent.waitForIdle(); + if ( + !this._postCompactionContinuationScheduled || + this._postCompactionContinuationSettlement !== settlement + ) { + return; + } + + if (this._queuedWorkPauses.size > 0 || this._compactionOperation || this._refineInFlight) { + continue; + } + + continuationMessages = [...this._scheduledPostCompactionContinuationMessages]; + if (continuationMessages.length > 0 && !this._sessionOwnsScheduledContinuations(continuationMessages)) { + this._cancelPostCompactionContinue(); this._scheduleAutoRefineAfterAgentEnd(); + return; + } + if (this.unfinishedActionCount > 0 || this._sessionInputPumpRequested) { + this._scheduleSessionInputPump(); + waitForSessionInput = true; + } else { + this._postCompactionContinuationScheduled = false; + continuation = this.agent.continue(); } + } finally { + commitFence.release(); } - return; - } - this._postCompactionContinuationScheduled = false; - try { - await this.agent.continue(); - this._forgetConsumedPostCompactionContinuations(continuationMessages); - } catch (error) { - const code = error instanceof AgentContinueError ? error.code : undefined; - if (code === "busy") { - this._schedulePostCompactionContinue(); - } else if (code !== "nothing-to-continue") { - // "nothing-to-continue" means the turn already completed; anything else must reject headless idle waiters. - this._settlePostCompactionContinue(this._asError(error)); + if (waitForSessionInput) { + await this._waitForIdleOrSettlement(settlement); + if (this._postCompactionContinuationSettlement !== settlement) return; + const shouldContinue = + (settlement.continueAfterSessionInput && continuationMessages.length === 0) || + this._sessionOwnsScheduledContinuations(continuationMessages); + if (shouldContinue) { + this._scheduledPostCompactionContinuationMessages = [...this._postCompactionContinuationMessages]; + continue; + } + this._postCompactionContinuationScheduled = false; + this._scheduledPostCompactionContinuationMessages = []; + this._scheduleAutoRefineAfterAgentEnd(); + return; + } + + try { + await continuation; + if (this._postCompactionContinuationSettlement === settlement) { + this._forgetConsumedPostCompactionContinuations(continuationMessages); + } + return; + } catch (error) { + const code = error instanceof AgentContinueError ? error.code : undefined; + if (code === "busy") { + if (this._postCompactionContinuationSettlement === settlement) { + this._postCompactionContinuationScheduled = true; + this._scheduledPostCompactionContinuationMessages = [...this._postCompactionContinuationMessages]; + } + continue; + } + if (code !== "nothing-to-continue" && this._postCompactionContinuationSettlement === settlement) { + this._settlePostCompactionContinue(this._asError(error)); + } + return; } } } @@ -8559,6 +8644,7 @@ export class AgentSession { if (this._refineInFlight === applySettled) { this._refineInFlight = undefined; } + this._notifySessionInputCheckpointChange(); this._scheduleSessionInputPump(); } } @@ -9029,12 +9115,17 @@ export class AgentSession { (reason === "requested" || reason === "threshold") && (shouldContinueAfterCompaction || this.agent.hasQueuedMessages() || this.hasPendingSessionWork) ) { - this._schedulePostCompactionContinue(); + this._schedulePostCompactionContinue(shouldContinueAfterCompaction); } }; this._emit({ type: "compaction_start", reason, customInstructions }); this._autoCompactionAbortController = new AbortController(); + let resolveCompactionOperation: () => void = () => {}; + const compactionOperation = new Promise((resolve) => { + resolveCompactionOperation = resolve; + }); + this._compactionOperation = compactionOperation; try { const authResult = this.model ? await this._modelRegistry.getApiKeyAndHeaders(this.model) : undefined; @@ -9081,13 +9172,13 @@ export class AgentSession { this.agent.state.messages = messages.slice(0, -1); } - this._schedulePostCompactionContinue(); + this._schedulePostCompactionContinue(true); this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); return true; } else if (shouldContinueAfterCompaction || hasQueuedMessages) { // Compaction can intentionally stop a tool loop between turns. // Queued follow-up/steering/custom messages can also be waiting. - this._schedulePostCompactionContinue(); + this._schedulePostCompactionContinue(shouldContinueAfterCompaction); this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); } else { this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); @@ -9137,6 +9228,11 @@ export class AgentSession { return false; } finally { this._autoCompactionAbortController = undefined; + if (this._compactionOperation === compactionOperation) { + this._compactionOperation = undefined; + } + resolveCompactionOperation(); + this._notifySessionInputCheckpointChange(); this._scheduleSessionInputPump(); } } @@ -10647,12 +10743,9 @@ export class AgentSession { try { while (true) { await wait(this.waitForHeadlessIdle()); - // Strong RLM quiescence also owns session-level work (bash, refine, - // branch mutation, and manual compaction) that interactive waitForIdle - // intentionally ignores. Yield a macrotask while such work is active so - // recursive parent/child barriers cannot form a microtask busy-loop. + // Strong RLM quiescence also owns work that interactive waitForIdle ignores. if (this.isSessionActive || this._hasDeferredRlmTerminalNotices()) { - await wait(new Promise((resolve) => setTimeout(resolve, 0))); + await wait(this._waitForSessionActivityChange(cancellation.signal)); continue; } const unsettledRuns = [...this._unsettledRlmChildRuns].filter((run) => !run.settled); @@ -11505,7 +11598,9 @@ export class AgentSession { transient?: boolean; }, ): Promise { - this._bashAbortController = new AbortController(); + // Each invocation owns its controller so abortBash reaches every in-flight command. + const abortController = new AbortController(); + this._bashAbortControllers.add(abortController); const prefix = this.settingsManager.getShellCommandPrefix(); const shellPath = this.settingsManager.getShellPath(); @@ -11518,7 +11613,7 @@ export class AgentSession { options?.operations ?? createLocalBashOperations({ shellPath }), { onChunk, - signal: this._bashAbortController.signal, + signal: abortController.signal, }, ); @@ -11527,7 +11622,8 @@ export class AgentSession { } return result; } finally { - this._bashAbortController = undefined; + this._bashAbortControllers.delete(abortController); + this._notifySessionInputCheckpointChange(); } } @@ -11572,6 +11668,7 @@ export class AgentSession { ); } finally { this._userBashRunning = false; + this._notifySessionInputCheckpointChange(); } // Emitted after the slot is released so clients never observe a bash_end // while the session still rejects new commands as already running. @@ -11696,14 +11793,17 @@ export class AgentSession { abortBash(): void { // A user bash command may not have spawned yet (extension dispatch in // progress); flag the request so runUserBash cancels before executing. - if (this._userBashRunning && this._bashAbortController === undefined) { + // runUserBash clears the flag at each start, so a stale flag is harmless. + if (this._userBashRunning) { this._userBashAbortRequested = true; } - this._bashAbortController?.abort(); + for (const controller of this._bashAbortControllers) { + controller.abort(); + } } get isBashRunning(): boolean { - return this._bashAbortController !== undefined || this._userBashRunning; + return this._bashAbortControllers.size > 0 || this._userBashRunning; } /** Whether there are pending bash messages waiting to be flushed */ @@ -12031,6 +12131,7 @@ export class AgentSession { this._branchSummaryOperation = undefined; } resolveBranchSummaryOperation(); + this._notifySessionInputCheckpointChange(); } } diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 00187f0708..950020e703 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -147,7 +147,7 @@ function tryMatchModel(modelPattern: string, availableModels: Model[]): Mod } } -export interface ParsedModelResult { +interface ParsedModelResult { model: Model | undefined; /** Thinking level if explicitly specified in pattern, undefined otherwise */ thinkingLevel?: ThinkingLevel; @@ -199,10 +199,8 @@ function findPreferredDefaultModel(availableModels: Model[]): Model | * 3. If not found and has colons, split on last colon: * - If suffix is valid thinking level, use it and recurse on prefix * - If suffix is invalid, warn and recurse on prefix with "off" - * - * @internal Exported for testing */ -export function parseModelPattern( +function parseModelPattern( pattern: string, availableModels: Model[], options?: { allowInvalidThinkingLevelFallback?: boolean }, diff --git a/packages/coding-agent/src/core/telemetry.ts b/packages/coding-agent/src/core/telemetry.ts index de8eb860fe..bb32bce5fd 100644 --- a/packages/coding-agent/src/core/telemetry.ts +++ b/packages/coding-agent/src/core/telemetry.ts @@ -212,9 +212,6 @@ export function isTelemetryEnabled(settingsManager: SettingsManager): boolean { if (override !== undefined) { return override; } - if (process.env.NODE_ENV === "test") { - return false; - } return settingsManager.getTelemetryEnabled(); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 385fde6831..8740776ec5 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -302,20 +302,17 @@ export function shouldEnsureDaemonBeforeActiveSessionLookup(options: DaemonActiv ); } -type ActiveDaemonSessionSummaryLookup = (socketPath: string, selector: string) => Promise; - interface ActiveDaemonSessionSummaryLookupOptions { fallbackOnError?: boolean; - lookup?: ActiveDaemonSessionSummaryLookup; } -export async function findActiveDaemonSessionSummaryForInteractiveStartup( +async function findActiveDaemonSessionSummaryForInteractiveStartup( socketPath: string, selector: string, options: ActiveDaemonSessionSummaryLookupOptions = {}, ): Promise { try { - return await (options.lookup ?? findActiveDaemonSessionSummary)(socketPath, selector); + return await findActiveDaemonSessionSummary(socketPath, selector); } catch (error) { if (options.fallbackOnError === false) { throw error; diff --git a/packages/coding-agent/src/modes/daemon/active-session-state.ts b/packages/coding-agent/src/modes/daemon/active-session-state.ts index 360fb6347b..dd53195239 100644 --- a/packages/coding-agent/src/modes/daemon/active-session-state.ts +++ b/packages/coding-agent/src/modes/daemon/active-session-state.ts @@ -40,6 +40,7 @@ export interface ActiveSessionState { extensionUiRequests: Map; eventGeneration: string; lastEventSequence: DaemonEventSequence; + inFlightBash?: Promise; unsubscribe?: () => void; /** Latest background status summary, surfaced in the agents view. */ summaryState?: AgentStatus; diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index fe4c9ed0fb..d79f7ef6ee 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -9,7 +9,7 @@ import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { readFile, stat } from "node:fs/promises"; +import { stat } from "node:fs/promises"; import { createConnection, createServer, type Server, type Socket } from "node:net"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { isDeepStrictEqual } from "node:util"; @@ -197,9 +197,11 @@ import { import { MutationDrainLatch } from "./mutation-drain-latch.js"; import { createRlmLedgerRegistrySeedSource, + type LegacyRlmSubagentRegistryEntry, type RlmLedgerDeleteReason, type RlmLedgerEdge, RlmSpawnLedger, + readLegacyRlmSubagentRegistry as readLegacyRlmSubagentRegistryFile, } from "./rlm-ledger.js"; import { readRlmSubagentDisplayEntry, @@ -432,17 +434,6 @@ interface PassiveRlmSubagentEntry { createdAt: number; } -/** - * Legacy per-parent `rlm-subagents.jsonl` entry shape, exactly as the daemon - * wrote it before the spawn ledger became topology authority. Read-only: - * registries are consumed only as the ledger seed source and as fallback - * hydration metadata for pre-ledger children without a display file. - */ -interface LegacyRlmSubagentRegistryEntry extends PassiveRlmSubagentEntry { - type: "rlm_subagent"; - updatedAt: string; -} - /** Spread-ready optional metadata fields shared by display files and legacy registry entries. */ function rlmSubagentMetadataFields(source: { rlmMaxDepth?: number; @@ -1009,50 +1000,14 @@ export class AgentDaemon { return join(getSessionArtifactPathForFile(parentSessionFile, parentSessionId), RLM_SUBAGENT_REGISTRY_FILE); } - private async readLegacyRlmSubagentRegistry( + private readLegacyRlmSubagentRegistry( path: string, throwOnReadError = false, ): Promise { - let lines: string[]; - try { - lines = (await readFile(path, "utf8")).split(/\r?\n/); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - this.log(`failed to read RLM subagent registry: ${error instanceof Error ? error.message : String(error)}`); - if (throwOnReadError) { - throw error; - } - } - return []; - } - const latest = new Map(); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - try { - const entry = JSON.parse(trimmed) as Partial; - if ( - entry.type !== "rlm_subagent" || - typeof entry.childId !== "string" || - typeof entry.sessionName !== "string" || - typeof entry.sessionDir !== "string" || - typeof entry.sessionFile !== "string" || - (entry.status !== "running" && entry.status !== "completed" && entry.status !== "deleted") || - (entry.rlmDepth !== undefined && (!Number.isSafeInteger(entry.rlmDepth) || entry.rlmDepth < 0)) || - (entry.rlmMaxDepth !== undefined && (!Number.isSafeInteger(entry.rlmMaxDepth) || entry.rlmMaxDepth < 0)) - ) { - continue; - } - latest.set(entry.childId, entry as LegacyRlmSubagentRegistryEntry); - } catch (error) { - this.log( - `ignored malformed RLM subagent registry entry: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - return [...latest.values()]; + return readLegacyRlmSubagentRegistryFile(path, { + throwOnReadError, + log: (message) => this.log(message), + }); } /** @@ -4486,28 +4441,23 @@ export class AgentDaemon { } // Respond before completion (bash can outlive the client request // timeout); output and completion stream via bash_* session events. - void state.runtime.session - .runUserBash(command.command, { - excludeFromContext: command.excludeFromContext, - transient: command.transient, - runId: command.runId, - }) - .catch((error) => { - this.broadcastToSession( - state, - failure(undefined, "execute_bash", error, serializeDaemonError(error)), - ); - }); + const bash = state.runtime.session.runUserBash(command.command, { + excludeFromContext: command.excludeFromContext, + transient: command.transient, + runId: command.runId, + }); + state.inFlightBash = Promise.allSettled([state.inFlightBash, bash]).then(() => undefined); + void bash.catch((error) => { + this.broadcastToSession(state, failure(undefined, "execute_bash", error, serializeDaemonError(error))); + }); return success(command.id, "execute_bash"); } case "execute_bash_and_wait": { const state = this.getSessionState(command.activeSessionId); - return success( - command.id, - "execute_bash_and_wait", - await state.runtime.session.executeBash(command.command), - ); + const bash = state.runtime.session.executeBash(command.command); + state.inFlightBash = Promise.allSettled([state.inFlightBash, bash]).then(() => undefined); + return success(command.id, "execute_bash_and_wait", await bash); } case "abort_bash": { @@ -6183,42 +6133,45 @@ export class AgentDaemon { throw new Error(`Unknown active session: ${targetSelector}`); } const deadline = Date.now() + 30_000; + let client: DaemonClient | undefined; let lastError: unknown; while (Date.now() < deadline && !this.shuttingDown) { - const client = new DaemonClient(supervisorSocketPath); - let receivedResponse = false; + const candidate = new DaemonClient(supervisorSocketPath); try { - await client.connect(1000); - await client.waitForHello(1000); - const response = await client.request( - { - type: "send_message", - targetActiveSessionId: targetSelector, - message, - fromActiveSessionId: fromState.activeSessionId, - agentOrigin: true, - }, - 30_000, - ); - receivedResponse = true; - if (!response.success) { - throw deserializeDaemonError(response); - } - if (!response.data || typeof response.data !== "object") { - throw new Error("Supervisor returned an invalid agent-message receipt"); - } - return response.data as AgentSessionMessageReceipt; + await candidate.connect(1000); + await candidate.waitForHello(1000); + client = candidate; + break; } catch (error) { lastError = error; - if (receivedResponse) { - throw error; - } - } finally { - client.close(); + candidate.close(); } await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)); } - throw lastError instanceof Error ? lastError : new Error(`Unknown active session: ${targetSelector}`); + if (!client) { + throw lastError instanceof Error ? lastError : new Error(`Unknown active session: ${targetSelector}`); + } + try { + const response = await client.request( + { + type: "send_message", + targetActiveSessionId: targetSelector, + message, + fromActiveSessionId: fromState.activeSessionId, + agentOrigin: true, + }, + 30_000, + ); + if (!response.success) { + throw deserializeDaemonError(response); + } + if (!response.data || typeof response.data !== "object") { + throw new Error("Supervisor returned an invalid agent-message receipt"); + } + return response.data as AgentSessionMessageReceipt; + } finally { + client.close(); + } } private async acceptAgentSessionMessage( @@ -6718,14 +6671,12 @@ export class AgentDaemon { } private async abortBashForClose(state: ActiveSessionState): Promise { - if (!state.runtime.session.isBashRunning) { + const session = state.runtime.session; + if (!session.isBashRunning) { return; } - state.runtime.session.abortBash(); - const deadline = Date.now() + UPDATE_RESTART_ABORT_BASH_TIMEOUT_MS; - while (state.runtime.session.isBashRunning && Date.now() < deadline) { - await delay(50); - } + session.abortBash(); + await Promise.race([state.inFlightBash ?? Promise.resolve(), delay(UPDATE_RESTART_ABORT_BASH_TIMEOUT_MS)]); } private async closeSessionOnce( diff --git a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts index 0ab9ee9e28..d257678475 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts @@ -103,22 +103,76 @@ export interface RlmLedgerSeedRegistryEntry { status: "running" | "completed" | "deleted"; } +export interface LegacyRlmSubagentRegistryEntry extends RlmLedgerSeedRegistryEntry { + type: "rlm_subagent"; + sessionDir: string; + parentSessionId: string; + parentSessionFile?: string; + rlmMaxDepth?: number; + rlmParentNodeId?: string; + prompt?: string; + spawnCode?: string; + model?: { provider: string; modelId: string }; + createdAt: number; + updatedAt: string; +} + export interface RlmLedgerSeedSource { - /** - * Tolerant last-writer-wins registry read for a parent session file, using - * the daemon's existing registry conventions. Must never throw for a - * missing registry; other failures may throw (seeding degrades to empty). - */ readRegistryForSessionFile(sessionFile: string): Promise; } -/** - * Default seed source: derive the per-parent registry path from the session - * file's header id (a bounded first-line read, no full transcript parse) and - * read it with the same tolerant last-writer-wins semantics the daemon's - * passive-hydration reader uses (malformed lines ignored, unknown fields - * accepted, absent depths allowed). - */ +export async function readLegacyRlmSubagentRegistry( + path: string, + options: { throwOnReadError?: boolean; log?: (message: string) => void } = {}, +): Promise { + let contents: string; + try { + contents = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + options.log?.( + `failed to read RLM subagent registry: ${error instanceof Error ? error.message : String(error)}`, + ); + if (options.throwOnReadError) throw error; + } + return []; + } + const latest = new Map(); + for (const line of contents.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = JSON.parse(trimmed) as Partial; + if ( + entry.type !== "rlm_subagent" || + typeof entry.childId !== "string" || + typeof entry.sessionName !== "string" || + typeof entry.sessionFile !== "string" || + (entry.status !== "running" && entry.status !== "completed" && entry.status !== "deleted") || + (entry.rlmDepth !== undefined && (!Number.isSafeInteger(entry.rlmDepth) || entry.rlmDepth < 0)) + ) { + continue; + } + latest.set(entry.childId, { + ...entry, + sessionDir: typeof entry.sessionDir === "string" ? entry.sessionDir : dirname(entry.sessionFile), + // rlmMaxDepth is optional hydration metadata the ledger seeder never + // reads; a damaged value must not discard the child's topology edge, + // so it is dropped instead of rejecting the whole entry. + rlmMaxDepth: + entry.rlmMaxDepth !== undefined && Number.isSafeInteger(entry.rlmMaxDepth) && entry.rlmMaxDepth >= 0 + ? entry.rlmMaxDepth + : undefined, + } as LegacyRlmSubagentRegistryEntry); + } catch (error) { + options.log?.( + `ignored malformed RLM subagent registry entry: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return [...latest.values()]; +} + export function createRlmLedgerRegistrySeedSource(): RlmLedgerSeedSource { return { readRegistryForSessionFile: async (sessionFile) => { @@ -133,49 +187,9 @@ export function createRlmLedgerRegistrySeedSource(): RlmLedgerSeedSource { return []; } if (!headerId) return []; - const registryPath = join(getSessionArtifactPathForFile(sessionFile, headerId), "rlm-subagents.jsonl"); - let contents: string; - try { - contents = await readFile(registryPath, "utf8"); - } catch { - return []; - } - const latest = new Map(); - for (const line of contents.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const entry = JSON.parse(trimmed) as { - type?: unknown; - childId?: unknown; - sessionName?: unknown; - sessionFile?: unknown; - rlmDepth?: unknown; - status?: unknown; - }; - if ( - entry.type !== "rlm_subagent" || - typeof entry.childId !== "string" || - typeof entry.sessionName !== "string" || - typeof entry.sessionFile !== "string" || - (entry.status !== "running" && entry.status !== "completed" && entry.status !== "deleted") || - (entry.rlmDepth !== undefined && - (!Number.isSafeInteger(entry.rlmDepth) || (entry.rlmDepth as number) < 0)) - ) { - continue; - } - latest.set(entry.childId, { - childId: entry.childId, - sessionName: entry.sessionName, - sessionFile: entry.sessionFile, - ...(typeof entry.rlmDepth === "number" ? { rlmDepth: entry.rlmDepth } : {}), - status: entry.status, - }); - } catch { - // Malformed registry history is ignored, matching the daemon reader. - } - } - return [...latest.values()]; + return readLegacyRlmSubagentRegistry( + join(getSessionArtifactPathForFile(sessionFile, headerId), "rlm-subagents.jsonl"), + ); }, }; } diff --git a/packages/coding-agent/src/modes/interactive/components/heartbeat-manager.ts b/packages/coding-agent/src/modes/interactive/components/heartbeat-manager.ts index 0c6b8efea9..28e9344ec7 100644 --- a/packages/coding-agent/src/modes/interactive/components/heartbeat-manager.ts +++ b/packages/coding-agent/src/modes/interactive/components/heartbeat-manager.ts @@ -14,6 +14,7 @@ const HEARTBEAT_SCROLL_INDICATOR_ROWS = 1; type HeartbeatManagerMode = { type: "list" } | { type: "actions"; heartbeatId: string; selectedIndex: number }; export interface HeartbeatManagerOptions { + getHeartbeats: () => readonly AgentConnectionHeartbeat[]; getRows: () => number; onAction: (heartbeat: AgentConnectionHeartbeat, action: AgentHeartbeatManagementAction) => Promise; onClose: () => void; @@ -21,19 +22,13 @@ export interface HeartbeatManagerOptions { } export class HeartbeatManagerComponent implements Component, Focusable { - private heartbeats: AgentConnectionHeartbeat[] = []; - private selectedIndex = 0; + private selectedHeartbeatId: string | undefined; private mode: HeartbeatManagerMode = { type: "list" }; private busy = false; private error: string | undefined; private _focused = false; - constructor( - heartbeats: readonly AgentConnectionHeartbeat[], - private readonly options: HeartbeatManagerOptions, - ) { - this.setHeartbeats(heartbeats); - } + constructor(private readonly options: HeartbeatManagerOptions) {} get focused(): boolean { return this._focused; @@ -45,22 +40,13 @@ export class HeartbeatManagerComponent implements Component, Focusable { invalidate(): void {} - setHeartbeats(heartbeats: readonly AgentConnectionHeartbeat[]): void { - const selectedId = this.heartbeats[this.selectedIndex]?.job.id; - this.heartbeats = [...heartbeats].sort((left, right) => { + private get heartbeats(): AgentConnectionHeartbeat[] { + return [...this.options.getHeartbeats()].sort((left, right) => { const sessionOrder = this.sessionLabel(left).localeCompare(this.sessionLabel(right)); if (sessionOrder !== 0) return sessionOrder; if (left.job.source !== right.job.source) return left.job.source === "heartbeat" ? -1 : 1; return left.job.createdAt.localeCompare(right.job.createdAt); }); - const nextIndex = selectedId - ? this.heartbeats.findIndex((heartbeat) => heartbeat.job.id === selectedId) - : this.selectedIndex; - this.selectedIndex = Math.max(0, Math.min(nextIndex < 0 ? 0 : nextIndex, this.heartbeats.length - 1)); - if (this.mode.type !== "list" && !this.findHeartbeat(this.mode.heartbeatId)) { - this.mode = { type: "list" }; - } - this.options.requestRender(); } handleInput(data: string): void { @@ -98,6 +84,14 @@ export class HeartbeatManagerComponent implements Component, Focusable { } render(width: number): string[] { + const heartbeats = this.heartbeats; + if (!heartbeats.some((heartbeat) => heartbeat.job.id === this.selectedHeartbeatId)) { + this.selectedHeartbeatId = heartbeats[0]?.job.id; + } + if (this.mode.type !== "list") { + const heartbeatId = this.mode.heartbeatId; + if (!heartbeats.some((heartbeat) => heartbeat.job.id === heartbeatId)) this.mode = { type: "list" }; + } const panel = this.mode.type === "list" ? this.createHeartbeatListPanel() : this.createActionPanel(this.mode); const safeWidth = Math.max(1, width); const panelWidth = Math.min(safeWidth, HEARTBEAT_PANEL_MAX_WIDTH); @@ -127,19 +121,21 @@ export class HeartbeatManagerComponent implements Component, Focusable { } private populateHeartbeatList(list: MenuList): void { - if (this.heartbeats.length === 0) { + const heartbeats = this.heartbeats; + if (heartbeats.length === 0) { list.addChild(new TruncatedText(theme.fg("muted", "No running or paused heartbeats"), 1, 0)); return; } + const selectedIndex = this.getSelectedIndex(heartbeats); const visibleItems = this.getListLayout().visibleItems; const startIndex = Math.max( 0, - Math.min(this.selectedIndex - Math.floor(visibleItems / 2), this.heartbeats.length - visibleItems), + Math.min(selectedIndex - Math.floor(visibleItems / 2), heartbeats.length - visibleItems), ); - const endIndex = Math.min(startIndex + visibleItems, this.heartbeats.length); + const endIndex = Math.min(startIndex + visibleItems, heartbeats.length); for (let index = startIndex; index < endIndex; index++) { - const heartbeat = this.heartbeats[index]; + const heartbeat = heartbeats[index]; if (!heartbeat) continue; const source = this.sourceLabel(heartbeat); const label = heartbeat.job.label?.trim(); @@ -152,15 +148,13 @@ export class HeartbeatManagerComponent implements Component, Focusable { primary: label || this.singleLine(heartbeat.job.prompt) || this.defaultHeartbeatName(heartbeat), secondary: details, meta: this.formatStatus(heartbeat), - selected: index === this.selectedIndex, + selected: index === selectedIndex, }), ); } - if (startIndex > 0 || endIndex < this.heartbeats.length) { - list.addChild( - new TruncatedText(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.heartbeats.length})`), 1, 0), - ); + if (startIndex > 0 || endIndex < heartbeats.length) { + list.addChild(new TruncatedText(theme.fg("muted", ` (${selectedIndex + 1}/${heartbeats.length})`), 1, 0)); } } @@ -198,8 +192,11 @@ export class HeartbeatManagerComponent implements Component, Focusable { private moveSelection(delta: number): void { if (this.mode.type === "list") { - if (this.heartbeats.length === 0) return; - this.selectedIndex = Math.max(0, Math.min(this.selectedIndex + delta, this.heartbeats.length - 1)); + const heartbeats = this.heartbeats; + if (heartbeats.length === 0) return; + const selectedIndex = this.getSelectedIndex(heartbeats); + const nextIndex = Math.max(0, Math.min(selectedIndex + delta, heartbeats.length - 1)); + this.selectedHeartbeatId = heartbeats[nextIndex]?.job.id; } else { const count = this.availableActions(this.findHeartbeat(this.mode.heartbeatId)).length; this.mode = { ...this.mode, selectedIndex: Math.max(0, Math.min(this.mode.selectedIndex + delta, count - 1)) }; @@ -209,7 +206,8 @@ export class HeartbeatManagerComponent implements Component, Focusable { private async confirmSelection(): Promise { if (this.mode.type === "list") { - const heartbeat = this.heartbeats[this.selectedIndex]; + const heartbeats = this.heartbeats; + const heartbeat = heartbeats[this.getSelectedIndex(heartbeats)]; if (heartbeat) { this.mode = { type: "actions", heartbeatId: heartbeat.job.id, selectedIndex: 0 }; this.options.requestRender(); @@ -254,6 +252,11 @@ export class HeartbeatManagerComponent implements Component, Focusable { ]; } + private getSelectedIndex(heartbeats: readonly AgentConnectionHeartbeat[]): number { + const index = heartbeats.findIndex((heartbeat) => heartbeat.job.id === this.selectedHeartbeatId); + return index < 0 ? 0 : index; + } + private findHeartbeat(id: string): AgentConnectionHeartbeat | undefined { return this.heartbeats.find((heartbeat) => heartbeat.job.id === id); } diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index a4a196fa58..e01bdfb79d 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -1198,10 +1198,6 @@ export class TreeSelectorComponent extends Container implements Focusable { this.addChild(this.labelInputContainer); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); - - if (tree.length === 0) { - setTimeout(() => onCancel(), 100); - } } private showLabelInput(entryId: string, currentLabel: string | undefined): void { diff --git a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts index 7dae13e12f..999d830344 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts @@ -117,10 +117,6 @@ export class UserMessageSelectorComponent extends Container { this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); - - if (messages.length === 0) { - setTimeout(() => onCancel(), 100); - } } getMessageList(): UserMessageList { diff --git a/packages/coding-agent/src/modes/interactive/feature-hints.ts b/packages/coding-agent/src/modes/interactive/feature-hints.ts index c4ffbad4b3..8b3d8134ef 100644 --- a/packages/coding-agent/src/modes/interactive/feature-hints.ts +++ b/packages/coding-agent/src/modes/interactive/feature-hints.ts @@ -109,8 +109,6 @@ export class FeatureHintDeck { private remaining: FeatureHint[] = []; private previousId: string | undefined; - constructor(private readonly random: () => number = Math.random) {} - next(context: FeatureHintContext): FeatureHint | undefined { if (this.remaining.length === 0) { this.refill(context); @@ -128,8 +126,7 @@ export class FeatureHintDeck { return text ? [{ id: hint.id, text }] : []; }); for (let index = hints.length - 1; index > 0; index--) { - const random = Math.min(0.999999999, Math.max(0, this.random())); - const target = Math.floor(random * (index + 1)); + const target = Math.floor(Math.random() * (index + 1)); [hints[index], hints[target]] = [hints[target]!, hints[index]!]; } if (hints.length > 1 && hints[hints.length - 1]?.id === this.previousId) { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c888fb2ab8..b5d59e2ef1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -252,7 +252,7 @@ import { shouldRunPrimeCliOnboardingSplash, } from "./onboarding.js"; import type { ClientPromptStashStore, PromptStash, PromptStashState } from "./prompt-stash-state.js"; -import { QueueSelection } from "./queue-selection.js"; +import { QueueSelection, type QueueSelectionItem } from "./queue-selection.js"; import { formatResumeHint } from "./resume-hint.js"; import { getAvailableThemes, @@ -990,12 +990,12 @@ export class InteractiveMode { private connectionState: AgentConnectionState | undefined; private connectionResourceSnapshot: AgentConnectionResourceSnapshot | undefined; private heartbeatCatalog: AgentConnectionHeartbeat[] = []; - private heartbeats: AgentConnectionHeartbeat[] = []; private heartbeatRefreshPromise: Promise | undefined; private heartbeatRefreshRequested = false; private heartbeatManager: HeartbeatManagerComponent | undefined; private heartbeatManagerHandle: OverlayHandle | undefined; private heartbeatManagerRefreshTimer: ReturnType | undefined; + private heartbeatManagerRefreshAt: number | undefined; // Registry of images pasted this session, keyed by the `[image #N]` marker // shown to the user. Insertion-ordered; the bytes persist (bounded by @@ -1015,12 +1015,11 @@ export class InteractiveMode { private retryCountdown: CountdownTimer | undefined = undefined; private traceUploadAllAbortController: AbortController | undefined = undefined; - // Session-owned queued messages mirrored from connection events. - private connectionQueue: AgentConnectionQueueState = { steering: [], followUp: [] }; private readonly queueSelection = new QueueSelection(); private isApplyingQueueSelectionText = false; private queueMutationChain: Promise = Promise.resolve(); private pendingQueueEdit: symbol | undefined; + private pendingQueueMove = false; private shutdownRequested = false; @@ -2508,25 +2507,11 @@ export class InteractiveMode { } } - private async refreshConnectionQueue(): Promise { - this.replaceConnectionQueue(await this.agentConnection.getQueue()); - } - - private replaceConnectionQueue(queue: AgentConnectionQueueState): void { - this.connectionQueue = { - steering: [...queue.steering], - followUp: [...queue.followUp], + private getConnectionQueue(): AgentConnectionQueueState { + return { + steering: [...(this.connectionState?.sessionActions.steering ?? [])], + followUp: [...(this.connectionState?.sessionActions.followUps ?? [])], }; - const dropped = this.queueSelection.sync(this.connectionQueue); - if (dropped !== undefined) { - const editorText = this.editor.getText(); - if (editorText === dropped) { - this.setEditorTextFromQueueSelection(this.queueSelection.reset()); - } else if (!this.pendingQueueEdit) { - this.queueSelection.replaceDraft(editorText); - } - } - this.updatePendingMessagesDisplay(); } private async refreshConnectionCatalog(): Promise { @@ -2568,32 +2553,19 @@ export class InteractiveMode { private applyHeartbeatCatalog(heartbeats: AgentConnectionHeartbeat[]): void { this.heartbeatCatalog = heartbeats; - this.updateScopedHeartbeats(); - } - - private updateScopedHeartbeats(): void { - const heartbeats = scopeHeartbeatsToSession( - this.heartbeatCatalog, - this.connectionState, - this.subagentSnapshots.values(), - ); - if ( - heartbeats.length === this.heartbeats.length && - heartbeats.every((heartbeat, index) => heartbeat === this.heartbeats[index]) - ) { - return; - } - this.heartbeats = heartbeats; - this.heartbeatManager?.setHeartbeats(heartbeats); this.scheduleHeartbeatManagerRefresh(); this.updateSubagentSummaryLine(); this.ui.requestRender(); } + private getScopedHeartbeats(): AgentConnectionHeartbeat[] { + return scopeHeartbeatsToSession(this.heartbeatCatalog, this.connectionState, this.subagentSnapshots.values()); + } + private applyConnectionStateSnapshot(state: AgentConnectionState): void { this.bindPromptStashSession(state.sessionId); this.connectionState = state; - this.updateScopedHeartbeats(); + this.scheduleHeartbeatManagerRefresh(); // Don't touch contextUsageTokenBaseline: a mid-stream snapshot reflects only completed // turns (the in-flight message isn't persisted yet), so the in-flight delta must keep // accumulating. The baseline is managed at turn end (refreshConnectionContextUsage) and @@ -2650,6 +2622,13 @@ export class InteractiveMode { this.patchConnectionState({ contextUsage: stats.contextUsage }); } + private refreshQueueSelectionFromState(): void { + const selected = this.queueSelection.selected; + if (selected && !this.pendingQueueEdit && !this.pendingQueueMove) { + this.refreshQueueSelectionAt(this.getConnectionQueue(), selected, selected.index); + } + } + private updateConnectionStateFromEvent(event: AgentConnectionSessionEvent): void { if (!this.connectionState) { return; @@ -2678,6 +2657,7 @@ export class InteractiveMode { break; case "session_action_update": this.patchConnectionState({ sessionActions: event.actions }); + this.refreshQueueSelectionFromState(); break; case "compaction_start": this.patchConnectionState({ isCompacting: true }); @@ -2819,7 +2799,11 @@ export class InteractiveMode { this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true }); } this.subscribeToAgent(); - await Promise.all([this.refreshConnectionQueue(), this.refreshHeartbeatCatalog().catch(() => undefined)]); + // A session_action_update in the unsubscribed gap above is lost; re-sync the queue post-subscription. + this.patchConnectionState({ sessionActions: (await this.agentConnection.getState()).sessionActions }); + this.refreshQueueSelectionFromState(); + this.updatePendingMessagesDisplay(); + await this.refreshHeartbeatCatalog().catch(() => undefined); await this.updateAvailableProviderCount(); this.updateEditorBorderColor(); this.updateTerminalTitle(); @@ -2842,8 +2826,8 @@ export class InteractiveMode { this.shortcutGuideContainer.clear(); this.pendingMessagesContainer.clear(); this.queuedMessagesContainer.clear(); - this.connectionQueue = { steering: [], followUp: [] }; this.pendingQueueEdit = undefined; + this.pendingQueueMove = false; // The selection and its stashed draft belong to the previous session; // every editor draft is cleared below, so discard rather than restore. this.queueSelection.reset(); @@ -2901,9 +2885,7 @@ export class InteractiveMode { await this.sessionEventQueue; this.resetCurrentSessionRenderState(); await this.renderInitialMessages(); - // The session transition and transcript are already authoritative here; - // a transient queue read must not turn a successful switch into a fatal error. - await this.refreshConnectionQueue().catch(() => undefined); + this.updatePendingMessagesDisplay(); this.syncWorkingLoader(); } @@ -2919,6 +2901,7 @@ export class InteractiveMode { private async renderResyncedSession(snapshot: AgentConnectionSnapshot): Promise { const bashFinished = this.isBashRunning() && !snapshot.state.isBashRunning; this.applyConnectionStateSnapshot(snapshot.state); + this.refreshQueueSelectionFromState(); this.restoreTurnStartFromMessages(this.getSessionContextFromConnectionSnapshot(snapshot).messages); this.streamingComponent = undefined; this.streamingMessage = undefined; @@ -2929,7 +2912,7 @@ export class InteractiveMode { updateFooter: true, }); await this.restoreStreamingMessageFromSnapshot(snapshot.streamingMessage); - await this.refreshConnectionQueue(); + this.updatePendingMessagesDisplay(); if (bashFinished) { if (this.activeBashComponent) { this.activeBashComponent.setComplete(undefined, false); @@ -4433,7 +4416,8 @@ export class InteractiveMode { for (const entry of this.editor.getHistory?.() ?? []) { add(entry); } - for (const msg of [...this.connectionQueue.steering, ...this.connectionQueue.followUp]) { + const queue = this.getConnectionQueue(); + for (const msg of [...queue.steering, ...queue.followUp]) { add(msg); } return ids; @@ -5375,10 +5359,7 @@ export class InteractiveMode { break; case "session_action_update": { - this.replaceConnectionQueue({ - steering: [...event.actions.steering], - followUp: [...event.actions.followUps], - }); + this.updatePendingMessagesDisplay(); this.ui.requestRender(); break; } @@ -5950,7 +5931,7 @@ export class InteractiveMode { } private refreshSubagentSummary(): void { - this.updateScopedHeartbeats(); + this.scheduleHeartbeatManagerRefresh(); this.updateSubagentSummaryLine(); this.updateWorkingPulse(); this.syncWorkingLoader(); @@ -5981,7 +5962,7 @@ export class InteractiveMode { this.subagentSnapshots.clear(); this.rlmNodeId = undefined; this.updateSubagentSummaryLine(); - this.updateScopedHeartbeats(); + this.scheduleHeartbeatManagerRefresh(); // Clearing snapshots can drop the last running subagent; reconcile the // pulse and loader so neither lingers when nothing is in flight. this.updateWorkingPulse(); @@ -6102,11 +6083,12 @@ export class InteractiveMode { } private getTrayHeartbeatLabel(): string | undefined { - if (this.heartbeats.length === 0) { + const heartbeats = this.getScopedHeartbeats(); + if (heartbeats.length === 0) { return undefined; } - const paused = this.heartbeats.filter((heartbeat) => heartbeat.job.status === "paused").length; - const count = `${this.heartbeats.length} heartbeat${this.heartbeats.length === 1 ? "" : "s"}`; + const paused = heartbeats.filter((heartbeat) => heartbeat.job.status === "paused").length; + const count = `${heartbeats.length} heartbeat${heartbeats.length === 1 ? "" : "s"}`; const pausedLabel = paused ? ` · ${paused} paused` : ""; const shortcut = keyText("app.heartbeats.open"); return `${count}${pausedLabel}${shortcut ? ` (${shortcut})` : ""}`; @@ -6992,9 +6974,20 @@ export class InteractiveMode { } } + private refreshQueueSelectionAt( + queue: AgentConnectionQueueState, + selected: QueueSelectionItem, + index: number, + ): void { + const dropped = this.queueSelection.refreshAt(queue, selected.lane, index, selected.text); + if (dropped !== undefined && this.editor.getText() === selected.text) { + this.setEditorTextFromQueueSelection(dropped); + } + } + private browseQueueSelection(direction: -1 | 1): void { - if (this.pendingQueueEdit) return; - const text = this.queueSelection.move(this.connectionQueue, this.editor.getText(), direction); + if (this.pendingQueueEdit || this.pendingQueueMove) return; + const text = this.queueSelection.move(this.getConnectionQueue(), this.editor.getText(), direction); if (text === undefined) return; this.setEditorTextFromQueueSelection(text); this.ui.requestRender(); @@ -7011,50 +7004,54 @@ export class InteractiveMode { } private moveQueueSelection(direction: -1 | 1): void { - if (this.pendingQueueEdit) return; - const submittedSelection = this.queueSelection.selected; - if (!submittedSelection) return; + if (this.pendingQueueEdit || !this.queueSelection.selected) return; const sessionGeneration = this.sessionEventGeneration; void this.enqueueQueueMutation(async () => { if (sessionGeneration !== this.sessionEventGeneration) return; - const lane = this.connectionQueue[submittedSelection.lane]; - const resolvedIndex = - lane[submittedSelection.index] === submittedSelection.text - ? submittedSelection.index - : lane.indexOf(submittedSelection.text); - if (resolvedIndex < 0) { - this.showStatus("Queue changed; reorder not applied"); - return; - } - const selected = { ...submittedSelection, index: resolvedIndex }; - const queueBefore = this.connectionQueue; - const status = await this.agentConnection.mutateQueuedMessage(selected.lane, selected.index, selected.text, { - type: "move", - direction, - }); - if (sessionGeneration !== this.sessionEventGeneration) return; - if (status === "applied") { - // The queue event for this mutation can land before or after the - // response. Patch the mirror only when no event has replaced it - // meanwhile (events always assign a fresh object); patching an - // already-updated mirror would apply the mutation twice. - const lane = this.connectionQueue[selected.lane]; - const target = selected.index + direction; - if ( - this.connectionQueue === queueBefore && - lane[selected.index] === selected.text && - target >= 0 && - target < lane.length - ) { - [lane[selected.index], lane[target]] = [lane[target] as string, selected.text]; - this.queueSelection.sync(this.connectionQueue); - this.updatePendingMessagesDisplay(); - this.ui.requestRender(); + const selected = this.queueSelection.selected; + if (!selected) return; + this.pendingQueueMove = true; + const actionsBefore = this.connectionState?.sessionActions; + try { + const status = await this.agentConnection.mutateQueuedMessage( + selected.lane, + selected.index, + selected.text, + { + type: "move", + direction, + }, + ); + if (sessionGeneration !== this.sessionEventGeneration) return; + await this.sessionEventQueue; + if (sessionGeneration !== this.sessionEventGeneration) return; + // The move's event can land after the response; mirror it locally (events assign a fresh sessionActions). + if (status === "applied" && actionsBefore && this.connectionState?.sessionActions === actionsBefore) { + const queue = this.getConnectionQueue(); + const lane = queue[selected.lane]; + const target = selected.index + direction; + if (lane[selected.index] === selected.text && target >= 0 && target < lane.length) { + [lane[selected.index], lane[target]] = [lane[target] as string, selected.text]; + this.patchConnectionState({ + sessionActions: { ...actionsBefore, steering: queue.steering, followUps: queue.followUp }, + }); + this.updatePendingMessagesDisplay(); + } } - } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); - else this.showStatus("Queue changed; reorder not applied"); + this.refreshQueueSelectionAt( + this.getConnectionQueue(), + selected, + status === "applied" ? selected.index + direction : selected.index, + ); + if (status === "applied") this.ui.requestRender(); + else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); + else this.showStatus("Queue changed; reorder not applied"); + } finally { + this.pendingQueueMove = false; + } }).catch((error) => { if (sessionGeneration === this.sessionEventGeneration) { + this.refreshQueueSelectionFromState(); this.showError(error instanceof Error ? error.message : String(error)); } }); @@ -7066,9 +7063,7 @@ export class InteractiveMode { * Empty text deletes; otherwise replaces, moving the item to `targetLane`. */ private applyQueueSelection(text: string, targetLane: "steering" | "followUp"): Promise { - if (this.pendingQueueEdit) return Promise.resolve(false); - const submittedSelection = this.queueSelection.selected; - if (!submittedSelection) return Promise.resolve(false); + if (this.pendingQueueEdit || !this.queueSelection.selected) return Promise.resolve(false); const pendingQueueEdit = Symbol("pending-queue-edit"); this.pendingQueueEdit = pendingQueueEdit; const sessionGeneration = this.sessionEventGeneration; @@ -7098,68 +7093,40 @@ export class InteractiveMode { }; return this.enqueueQueueMutation(async () => { if (discardStaleSelection()) return true; - // Earlier serialized moves may have changed the selected item's index. - const lane = this.connectionQueue[submittedSelection.lane]; - const resolvedIndex = - lane[submittedSelection.index] === submittedSelection.text - ? submittedSelection.index - : lane.indexOf(submittedSelection.text); - if (resolvedIndex < 0) { - this.queueSelection.sync(this.connectionQueue); - const editorUntouched = - submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; - if (editorUntouched) { - this.setEditorTextFromQueueSelection(text); - } - this.queueSelection.replaceDraft(editorUntouched ? text : this.editor.getText()); - this.showStatus("Queue changed; edit kept in the editor"); - this.updatePendingMessagesDisplay(); - this.ui.requestRender(); - return true; - } - const selected = { ...submittedSelection, index: resolvedIndex }; - const queueBefore = this.connectionQueue; + const selected = this.queueSelection.selected; let status: AgentConnectionQueuedMessageMutationStatus; - try { - status = await this.agentConnection.mutateQueuedMessage( - selected.lane, - selected.index, - selected.text, - mutation, - ); - } catch (error) { - if (discardStaleSelection()) return true; - // The editor was already cleared by Enter; restore the edit before surfacing the error. - const editorUntouched = - submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; - if (editorUntouched) { - this.setEditorTextFromQueueSelection(text); - } - if (!this.queueSelection.isBrowsing) { - this.queueSelection.replaceDraft(editorUntouched ? text : this.editor.getText()); + if (selected) { + try { + status = await this.agentConnection.mutateQueuedMessage( + selected.lane, + selected.index, + selected.text, + mutation, + ); + } catch (error) { + if (discardStaleSelection()) return true; + // The editor was already cleared by Enter; restore the edit before surfacing the error. + const editorUntouched = + submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; + if (editorUntouched) { + this.setEditorTextFromQueueSelection(text); + } + if (!this.queueSelection.isBrowsing) { + this.queueSelection.replaceDraft(editorUntouched ? text : this.editor.getText()); + } + throw error; } - throw error; + } else { + status = "rejected"; } if (discardStaleSelection()) return true; const editorUntouched = submissionGeneration === this.inputSubmissionGeneration && this.editor.getText() === editorTextBefore; if (status === "applied") { - // Same optimistic patch as moveQueueSelection, and the same guard: - // skip when a queue event already replaced the mirror. - const lane = this.connectionQueue[selected.lane]; - if (this.connectionQueue === queueBefore && lane[selected.index] === selected.text) { - if (!trimmed) lane.splice(selected.index, 1); - else if (targetLane === selected.lane) lane[selected.index] = trimmed; - else { - lane.splice(selected.index, 1); - this.connectionQueue[targetLane].push(trimmed); - } - } if (trimmed) this.editor.addToHistory?.(trimmed); const draft = this.queueSelection.reset(); if (editorUntouched) this.setEditorTextFromQueueSelection(draft); } else { - this.queueSelection.sync(this.connectionQueue); // Enter submissions clear the editor before onSubmit runs; restore the // edit so a failed mutation never swallows it. if (editorUntouched) this.setEditorTextFromQueueSelection(text); @@ -7175,7 +7142,11 @@ export class InteractiveMode { this.ui.requestRender(); return true; }).finally(() => { - if (this.pendingQueueEdit === pendingQueueEdit) this.pendingQueueEdit = undefined; + if (this.pendingQueueEdit === pendingQueueEdit) { + this.pendingQueueEdit = undefined; + // Queue events were not reconciled while the edit was pending; drop a now-stale selection. + this.refreshQueueSelectionFromState(); + } }); } @@ -7424,10 +7395,7 @@ export class InteractiveMode { } private getAllQueuedMessages(): { steering: string[]; followUp: string[] } { - return { - steering: [...this.connectionQueue.steering], - followUp: [...this.connectionQueue.followUp], - }; + return this.getConnectionQueue(); } private updatePendingMessagesDisplay(): void { @@ -9589,7 +9557,8 @@ export class InteractiveMode { this.showError(error instanceof Error ? error.message : String(error)); return; } - const manager = new HeartbeatManagerComponent(this.heartbeats, { + const manager = new HeartbeatManagerComponent({ + getHeartbeats: () => this.getScopedHeartbeats(), getRows: () => this.ui.terminal.rows, onAction: (heartbeat, action) => this.manageHeartbeat(heartbeat, action), onClose: () => this.closeHeartbeatManager(), @@ -9604,10 +9573,7 @@ export class InteractiveMode { } private closeHeartbeatManager(): void { - if (this.heartbeatManagerRefreshTimer) { - clearTimeout(this.heartbeatManagerRefreshTimer); - this.heartbeatManagerRefreshTimer = undefined; - } + this.clearHeartbeatManagerRefreshTimer(); this.heartbeatManagerHandle?.hide(); this.heartbeatManagerHandle = undefined; this.heartbeatManager = undefined; @@ -9615,25 +9581,37 @@ export class InteractiveMode { } private scheduleHeartbeatManagerRefresh(): void { - if (this.heartbeatManagerRefreshTimer) { - clearTimeout(this.heartbeatManagerRefreshTimer); - this.heartbeatManagerRefreshTimer = undefined; - } if (!this.heartbeatManager) { + this.clearHeartbeatManagerRefreshTimer(); return; } - const nextRunAt = this.heartbeats + const nextRunAt = this.getScopedHeartbeats() .filter((heartbeat) => heartbeat.job.status === "active" && heartbeat.job.nextRunAt) .map((heartbeat) => Date.parse(heartbeat.job.nextRunAt!)) .filter(Number.isFinite) .sort((left, right) => left - right)[0]; if (nextRunAt === undefined) { + this.clearHeartbeatManagerRefreshTimer(); return; } const untilNextRun = nextRunAt - Date.now(); const delay = untilNextRun > 0 ? Math.min(60_000, untilNextRun + 250) : 5_000; + const refreshAt = Date.now() + delay; + // Subagent snapshots re-derive this schedule constantly; keep an earlier + // pending refresh instead of re-arming, or an overdue heartbeat's 5s + // fallback would be postponed for as long as children stay busy. + if ( + this.heartbeatManagerRefreshTimer && + this.heartbeatManagerRefreshAt !== undefined && + this.heartbeatManagerRefreshAt <= refreshAt + ) { + return; + } + this.clearHeartbeatManagerRefreshTimer(); + this.heartbeatManagerRefreshAt = refreshAt; this.heartbeatManagerRefreshTimer = setTimeout(() => { this.heartbeatManagerRefreshTimer = undefined; + this.heartbeatManagerRefreshAt = undefined; if (!this.heartbeatManager) { return; } @@ -9642,6 +9620,14 @@ export class InteractiveMode { this.heartbeatManagerRefreshTimer.unref?.(); } + private clearHeartbeatManagerRefreshTimer(): void { + if (this.heartbeatManagerRefreshTimer) { + clearTimeout(this.heartbeatManagerRefreshTimer); + this.heartbeatManagerRefreshTimer = undefined; + } + this.heartbeatManagerRefreshAt = undefined; + } + private async manageHeartbeat( heartbeat: AgentConnectionHeartbeat, action: AgentHeartbeatManagementAction, diff --git a/packages/coding-agent/src/modes/interactive/queue-selection.ts b/packages/coding-agent/src/modes/interactive/queue-selection.ts index 3954775449..e0f3444531 100644 --- a/packages/coding-agent/src/modes/interactive/queue-selection.ts +++ b/packages/coding-agent/src/modes/interactive/queue-selection.ts @@ -44,8 +44,6 @@ export class QueueSelection { if (direction > 0) return undefined; this.items = flatten(queue); if (this.items.length === 0) return undefined; - // A drop (sync) keeps the previous draft stashed; do not overwrite it - // with the dropped item's text still sitting in the editor. if (!this.hasStashedDraft) { this.draft = draft; this.hasStashedDraft = true; @@ -62,23 +60,17 @@ export class QueueSelection { return this.items[next]?.text; } - /** - * Track queue changes while browsing: keep the selection when its text is - * still present. Returns the dropped item's text when the selection could - * not be kept, so the caller can restore the stashed draft. - */ - sync(queue: AgentConnectionQueueState): string | undefined { - const selected = this.selected; + refreshAt( + queue: AgentConnectionQueueState, + lane: QueueLane, + index: number, + expectedText: string, + ): string | undefined { this.items = flatten(queue); - if (!selected) return undefined; - const exact = this.items[selected.lane === "steering" ? selected.index : queue.steering.length + selected.index]; - if (exact?.lane === selected.lane && exact.text === selected.text) { - this.cursor = this.items.indexOf(exact); - return undefined; - } - const retargeted = this.items.find((item) => item.lane === selected.lane && item.text === selected.text); - this.cursor = retargeted ? this.items.indexOf(retargeted) : -1; - return retargeted ? undefined : selected.text; + const cursor = lane === "steering" ? index : queue.steering.length + index; + const selected = this.items[cursor]; + if (selected?.lane !== lane || selected.index !== index || selected.text !== expectedText) return this.reset(); + this.cursor = cursor; } /** Called after a mutation or submit resolved the selection. Returns the stashed draft. */ diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index 1cad16ee85..f68daa6d00 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -1503,7 +1503,7 @@ describe("AgentSession rlm recursion", () => { }); }); - it("strong quiescence yields to a real gated child bash while interactive idle remains resolved", async () => { + it("strong quiescence waits for a gated child bash activity change", async () => { const child = createSession({ rlmSessionDir: join(tempDir, "bash-active-child") }); const bashStarted = deferred(); const bashCompletion = deferred(); @@ -1523,7 +1523,7 @@ describe("AgentSession rlm recursion", () => { const originalHeadlessIdle = child.waitForHeadlessIdle.bind(child); let headlessIdleCalls = 0; vi.spyOn(child, "waitForHeadlessIdle").mockImplementation(async () => { - if (++headlessIdleCalls > 100) throw new Error("RLM quiescence spun without yielding a macrotask"); + headlessIdleCalls++; await originalHeadlessIdle(); }); @@ -1536,11 +1536,12 @@ describe("AgentSession rlm recursion", () => { sleep(20).then(() => "timer" as const), ]); expect(firstBoundary).toBe("timer"); + expect(headlessIdleCalls).toBe(1); bashCompletion.resolve(); await bash; await expect(quiescence).resolves.toBeUndefined(); - expect(headlessIdleCalls).toBeLessThan(100); + expect(headlessIdleCalls).toBe(2); }); it("rechecks parent self-activity after a child quiescence boundary", async () => { diff --git a/packages/coding-agent/test/agent-session-services.test.ts b/packages/coding-agent/test/agent-session-services.test.ts index 81a2414a34..7c1eec6c7c 100644 --- a/packages/coding-agent/test/agent-session-services.test.ts +++ b/packages/coding-agent/test/agent-session-services.test.ts @@ -29,6 +29,7 @@ describe("createAgentSessionFromServices", () => { }); it("shows the telemetry disclosure independently of the Herdr reporter", async () => { + vi.stubEnv("DO_NOT_TRACK", "0"); vi.stubEnv("PRIME_AGENT_TELEMETRY", "1"); const tempDir = join(tmpdir(), `pi-session-telemetry-notice-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); @@ -50,6 +51,7 @@ describe("createAgentSessionFromServices", () => { }); it("honors an explicit daemon-carried telemetry opt-out", async () => { + vi.stubEnv("DO_NOT_TRACK", "0"); vi.stubEnv("PRIME_AGENT_TELEMETRY", "1"); const tempDir = join(tmpdir(), `pi-session-daemon-telemetry-opt-out-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); @@ -81,6 +83,7 @@ describe("createAgentSessionFromServices", () => { }); it("does not install top-level telemetry for a resumed child session", async () => { + vi.stubEnv("DO_NOT_TRACK", "0"); vi.stubEnv("PRIME_AGENT_TELEMETRY", "1"); const tempDir = join(tmpdir(), `pi-session-child-telemetry-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 2c4d713109..fd50ed3344 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -70,6 +70,103 @@ describe("daemon mode helpers", () => { expect(client.id).toBe("public-client"); }); + it("waits for overlapping Bash commands with a bounded close deadline", async () => { + vi.useFakeTimers(); + try { + const daemon = new AgentDaemon("/tmp/unused-daemon.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + abortBashForClose(state: ActiveSessionState): Promise; + }; + + let resolveFirstBash!: () => void; + let resolveSecondBash!: () => void; + const firstBash = new Promise((resolve) => { + resolveFirstBash = resolve; + }); + const secondBash = new Promise((resolve) => { + resolveSecondBash = resolve; + }); + let isBashRunning = false; + const abortBash = vi.fn(); + const state = makeState("active"); + state.runtime = { + ...state.runtime, + session: { + get isBashRunning() { + return isBashRunning; + }, + runUserBash: vi.fn(() => { + isBashRunning = true; + return firstBash; + }), + executeBash: vi.fn(() => secondBash), + abortBash, + }, + } as never; + internals.sessions.set(state.activeSessionId, state); + const client = makeClient("client", state.activeSessionId); + + await internals.handleCommand(client, { + id: "first", + type: "execute_bash", + activeSessionId: state.activeSessionId, + command: "first", + }); + const secondResponse = internals.handleCommand(client, { + id: "second", + type: "execute_bash_and_wait", + activeSessionId: state.activeSessionId, + command: "second", + }); + resolveSecondBash(); + await secondResponse; + + const closing = internals.abortBashForClose(state); + expect(await Promise.race([closing.then(() => "done"), Promise.resolve("pending")])).toBe("pending"); + resolveFirstBash(); + await expect(closing).resolves.toBeUndefined(); + expect(abortBash).toHaveBeenCalledOnce(); + + let isStalledBashRunning = false; + const abortStalledBash = vi.fn(); + const stalledState = makeState("stalled"); + stalledState.runtime = { + ...stalledState.runtime, + session: { + get isBashRunning() { + return isStalledBashRunning; + }, + runUserBash: vi.fn(() => { + isStalledBashRunning = true; + return new Promise(() => {}); + }), + abortBash: abortStalledBash, + }, + } as never; + internals.sessions.set(stalledState.activeSessionId, stalledState); + await internals.handleCommand(makeClient("stalled-client", stalledState.activeSessionId), { + id: "stalled", + type: "execute_bash", + activeSessionId: stalledState.activeSessionId, + command: "stalled", + }); + + const stalledClose = internals.abortBashForClose(stalledState); + await vi.advanceTimersByTimeAsync(4999); + expect(await Promise.race([stalledClose.then(() => "done"), Promise.resolve("pending")])).toBe("pending"); + await vi.advanceTimersByTimeAsync(1); + await expect(stalledClose).resolves.toBeUndefined(); + expect(abortStalledBash).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + it("normalizes daemon session names before validation and persistence", async () => { const daemon = new AgentDaemon("/tmp/unused-daemon.sock", { defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, @@ -908,18 +1005,19 @@ describe("daemon mode helpers", () => { const registryPath = join(fixture.parentArtifactDir, "rlm-subagents.jsonl"); const entry = JSON.parse(readFileSync(registryPath, "utf8")) as Record; entry.status = "running"; + delete entry.sessionDir; writeFileSync(registryPath, `${JSON.stringify(entry)}\n`); const internals = fixture.daemon as unknown as { createRuntime(command: Extract): Promise; - listPassiveRlmSubagents(): Promise>; + listPassiveRlmSubagents(): Promise>; createAgentMessageController( getCurrentState: () => ActiveSessionState | undefined, ): AgentSessionMessageController; }; const parentState = await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); - expect((await internals.listPassiveRlmSubagents()).map(({ entry }) => entry.childId)).toContain( - fixture.childId, + expect((await internals.listPassiveRlmSubagents()).map(({ entry }) => entry)).toContainEqual( + expect.objectContaining({ childId: fixture.childId, status: "running" }), ); await expect(internals.createAgentMessageController(() => parentState).roster?.()).resolves.toMatchObject({ entries: [expect.objectContaining({ relationship: "child", name: "renamed-worker" })], @@ -1800,6 +1898,85 @@ describe("daemon mode helpers", () => { } }); + it("does not retry after the supervisor receives an agent message", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "pa-msg-disconnect-")); + const socketPath = join(tempDir, "d.sock"); + let requestCount = 0; + const server: Server = createServer((socket) => { + socket.on("error", () => undefined); + socket.write( + `${JSON.stringify({ + type: "daemon_hello", + socketPath, + protocol: DAEMON_PROTOCOL_INFO, + schemaId: DAEMON_SCHEMA_ID, + clientId: "supervisor", + serverCapabilities: [], + })}\n`, + ); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + const wire = JSON.parse(buffer.slice(0, newline)) as { + id: string; + command?: { type: string }; + type: string; + }; + const command = wire.command ?? wire; + requestCount++; + if (requestCount === 1) { + socket.destroy(); + return; + } + socket.write( + `${JSON.stringify({ + type: "response", + id: wire.id, + command: command.type, + success: true, + data: {}, + })}\n`, + ); + }); + }); + const previousSupervisorSocket = process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV]; + try { + await new Promise((resolve) => server.listen(socketPath, resolve)); + process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV] = socketPath; + const daemon = new AgentDaemon("/tmp/prime-agent-worker-test.sock", { + defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + worker: { authenticationToken: "worker-token" }, + }); + const sendRemoteAgentSessionMessage = ( + daemon as unknown as { + sendRemoteAgentSessionMessage( + fromState: ActiveSessionState, + targetSelector: string, + message: string, + ): Promise; + } + ).sendRemoteAgentSessionMessage.bind(daemon); + + await expect(sendRemoteAgentSessionMessage(makeState("source"), "remote", "continue")).rejects.toThrow( + "Connection to the Prime Agent daemon closed", + ); + expect(requestCount).toBe(1); + } finally { + if (previousSupervisorSocket === undefined) { + delete process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV]; + } else { + process.env[DAEMON_WORKER_SUPERVISOR_SOCKET_ENV] = previousSupervisorSocket; + } + await new Promise((resolve) => server.close(() => resolve())); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("routes worker-local session renames through the supervisor", async () => { const tempDir = mkdtempSync(join(tmpdir(), "pa-worker-rename-")); const socketPath = join(tempDir, "s"); diff --git a/packages/coding-agent/test/heartbeat-manager.test.ts b/packages/coding-agent/test/heartbeat-manager.test.ts index bc8ae2e711..58901c6c91 100644 --- a/packages/coding-agent/test/heartbeat-manager.test.ts +++ b/packages/coding-agent/test/heartbeat-manager.test.ts @@ -48,8 +48,8 @@ describe("HeartbeatManagerComponent", () => { }); it("groups user and agent heartbeats and stays within terminal width", () => { - const component = new HeartbeatManagerComponent( - [ + const component = new HeartbeatManagerComponent({ + getHeartbeats: () => [ heartbeat("user", { source: "heartbeat" }), heartbeat("agent", { source: "rlm_heartbeat", @@ -59,8 +59,11 @@ describe("HeartbeatManagerComponent", () => { lastError: "the previous delivery failed", }), ], - { getRows: () => 20, onAction: async () => {}, onClose: () => {}, requestRender: () => {} }, - ); + getRows: () => 20, + onAction: async () => {}, + onClose: () => {}, + requestRender: () => {}, + }); for (const width of [32, 48, 80]) { const lines = component.render(width); expect(lines.every((line) => visibleWidth(line) === width)).toBe(true); @@ -81,9 +84,25 @@ describe("HeartbeatManagerComponent", () => { expect(rendered.find((line) => line.includes("Esc close"))?.indexOf("Esc close")).toBe(titleColumn); }); + it("reads the current heartbeat list when rendered", () => { + let heartbeats = [heartbeat("user", { source: "heartbeat" })]; + const component = new HeartbeatManagerComponent({ + getHeartbeats: () => heartbeats, + getRows: () => 20, + onAction: async () => {}, + onClose: () => {}, + requestRender: () => {}, + }); + + expect(stripAnsi(component.render(80).join("\n"))).toContain("1 heartbeat."); + heartbeats = [heartbeat("user", { source: "heartbeat" }), heartbeat("agent", { source: "rlm_heartbeat" })]; + expect(stripAnsi(component.render(80).join("\n"))).toContain("2 heartbeats."); + }); + it("uses arrows to open and go back, and closes with escape or the toggle shortcut", () => { let closeCount = 0; - const component = new HeartbeatManagerComponent([heartbeat("user", { source: "heartbeat" })], { + const component = new HeartbeatManagerComponent({ + getHeartbeats: () => [heartbeat("user", { source: "heartbeat" })], getRows: () => 20, onAction: async () => {}, onClose: () => closeCount++, @@ -114,7 +133,8 @@ describe("HeartbeatManagerComponent", () => { it("pauses and stops individual heartbeats immediately", async () => { const actions: Array<{ id: string; action: AgentHeartbeatManagementAction }> = []; - const component = new HeartbeatManagerComponent([heartbeat("user", { source: "heartbeat" })], { + const component = new HeartbeatManagerComponent({ + getHeartbeats: () => [heartbeat("user", { source: "heartbeat" })], getRows: () => 20, onAction: async (entry, action) => { actions.push({ id: entry.job.id, action }); diff --git a/packages/coding-agent/test/interactive-heartbeat-management.test.ts b/packages/coding-agent/test/interactive-heartbeat-management.test.ts index c7923fd8db..664779e00e 100644 --- a/packages/coding-agent/test/interactive-heartbeat-management.test.ts +++ b/packages/coding-agent/test/interactive-heartbeat-management.test.ts @@ -8,7 +8,6 @@ import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; interface HeartbeatManagementHarness { heartbeatCatalog: AgentConnectionHeartbeat[]; - heartbeats: AgentConnectionHeartbeat[]; agentConnection: { manageHeartbeat( activeSessionId: string, @@ -25,24 +24,30 @@ interface HeartbeatManagementHarness { interface HeartbeatScopeHarness { heartbeatCatalog: AgentConnectionHeartbeat[]; - heartbeats: AgentConnectionHeartbeat[]; connectionState: { activeSessionId: string; sessionId: string }; subagentSnapshots: Map; - heartbeatManager: { setHeartbeats(heartbeats: AgentConnectionHeartbeat[]): void } | undefined; ui: { requestRender(): void }; scheduleHeartbeatManagerRefresh(): void; updateSubagentSummaryLine(): void; applyHeartbeatCatalog(heartbeats: AgentConnectionHeartbeat[]): void; + getScopedHeartbeats(): AgentConnectionHeartbeat[]; } interface ChildIdentityUpdateHarness { subagentSnapshots: Map; - refreshSubagentSummary(): void; + ui: { requestRender(): void }; updateSubagentSummary(child: AgentConnectionRlmChildAgentSnapshot): void; + scheduleHeartbeatManagerRefresh(): void; + updateSubagentSummaryLine(): void; + updateWorkingPulse(): void; + syncWorkingLoader(): void; + updateWorkingLoaderMessage(): void; } interface HeartbeatRefreshHarness { - heartbeats: AgentConnectionHeartbeat[]; + heartbeatCatalog: AgentConnectionHeartbeat[]; + connectionState: { activeSessionId: string; sessionId: string }; + subagentSnapshots: Map; heartbeatManager: object | undefined; heartbeatManagerRefreshTimer: ReturnType | undefined; refreshHeartbeatCatalog(): Promise; @@ -74,8 +79,7 @@ describe("interactive heartbeat management", () => { const stopped = { ...current, status: "cancelled" as const, nextRunAt: undefined }; const patches: Array<{ heartbeat: AgentCronJob | null }> = []; const harness = Object.create(InteractiveMode.prototype) as HeartbeatManagementHarness; - harness.heartbeats = [{ job: current }]; - harness.heartbeatCatalog = harness.heartbeats; + harness.heartbeatCatalog = [{ job: current }]; harness.connectionState = { activeSessionId: current.activeSessionId }; harness.agentConnection = { manageHeartbeat: vi.fn(async () => stopped), @@ -95,8 +99,7 @@ describe("interactive heartbeat management", () => { const current = heartbeat(); const paused = { ...current, status: "paused" as const, nextRunAt: undefined }; const harness = Object.create(InteractiveMode.prototype) as HeartbeatManagementHarness; - harness.heartbeats = [{ job: current, sessionName: "Primary session" }]; - harness.heartbeatCatalog = harness.heartbeats; + harness.heartbeatCatalog = [{ job: current, sessionName: "Primary session" }]; harness.connectionState = { activeSessionId: current.activeSessionId }; harness.agentConnection = { manageHeartbeat: vi.fn(async () => paused) }; harness.patchConnectionState = vi.fn(); @@ -123,7 +126,6 @@ describe("interactive heartbeat management", () => { }; const harness = Object.create(InteractiveMode.prototype) as HeartbeatScopeHarness; harness.heartbeatCatalog = []; - harness.heartbeats = []; harness.connectionState = { activeSessionId: "active-1", sessionId: "session-1" }; harness.subagentSnapshots = new Map([ [ @@ -137,7 +139,6 @@ describe("interactive heartbeat management", () => { }, ], ]); - harness.heartbeatManager = { setHeartbeats: vi.fn() }; harness.ui = { requestRender: vi.fn() }; harness.scheduleHeartbeatManagerRefresh = vi.fn(); harness.updateSubagentSummaryLine = vi.fn(); @@ -145,8 +146,7 @@ describe("interactive heartbeat management", () => { harness.applyHeartbeatCatalog([own, child, unrelated]); expect(harness.heartbeatCatalog).toEqual([own, child, unrelated]); - expect(harness.heartbeats).toEqual([own, child]); - expect(harness.heartbeatManager.setHeartbeats).toHaveBeenCalledWith([own, child]); + expect(harness.getScopedHeartbeats()).toEqual([own, child]); expect(harness.updateSubagentSummaryLine).toHaveBeenCalledOnce(); }); @@ -159,12 +159,17 @@ describe("interactive heartbeat management", () => { }; const harness = Object.create(InteractiveMode.prototype) as ChildIdentityUpdateHarness; harness.subagentSnapshots = new Map([[existing.id, existing]]); - harness.refreshSubagentSummary = vi.fn(); + harness.ui = { requestRender: vi.fn() }; + harness.scheduleHeartbeatManagerRefresh = vi.fn(); + harness.updateSubagentSummaryLine = vi.fn(); + harness.updateWorkingPulse = vi.fn(); + harness.syncWorkingLoader = vi.fn(); + harness.updateWorkingLoaderMessage = vi.fn(); harness.updateSubagentSummary({ ...existing, activeSessionId: "active-2" }); expect(harness.subagentSnapshots.get(existing.id)?.activeSessionId).toBe("active-2"); - expect(harness.refreshSubagentSummary).toHaveBeenCalledOnce(); + expect(harness.scheduleHeartbeatManagerRefresh).toHaveBeenCalledOnce(); }); it("refreshes an open manager after the next scheduled run", async () => { @@ -172,7 +177,9 @@ describe("interactive heartbeat management", () => { try { vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const harness = Object.create(InteractiveMode.prototype) as HeartbeatRefreshHarness; - harness.heartbeats = [{ job: { ...heartbeat(), nextRunAt: "2026-01-01T00:00:01.000Z" } }]; + harness.heartbeatCatalog = [{ job: { ...heartbeat(), nextRunAt: "2026-01-01T00:00:01.000Z" } }]; + harness.connectionState = { activeSessionId: "active-1", sessionId: "session-1" }; + harness.subagentSnapshots = new Map(); harness.heartbeatManager = {}; harness.heartbeatManagerRefreshTimer = undefined; harness.refreshHeartbeatCatalog = vi.fn(async () => {}); @@ -185,4 +192,57 @@ describe("interactive heartbeat management", () => { vi.useRealTimers(); } }); + + it("keeps the overdue refresh deadline when subagent updates re-derive the schedule", async () => { + vi.useFakeTimers(); + try { + // nextRunAt (00:05) is already in the past, so the 5s overdue fallback applies. + vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z")); + const harness = Object.create(InteractiveMode.prototype) as HeartbeatRefreshHarness; + harness.heartbeatCatalog = [{ job: heartbeat() }]; + harness.connectionState = { activeSessionId: "active-1", sessionId: "session-1" }; + harness.subagentSnapshots = new Map(); + harness.heartbeatManager = {}; + harness.heartbeatManagerRefreshTimer = undefined; + harness.refreshHeartbeatCatalog = vi.fn(async () => {}); + + harness.scheduleHeartbeatManagerRefresh(); + // Subagent snapshot updates re-derive the schedule more often than + // every 5s; they must not postpone the pending overdue refresh. + for (let i = 0; i < 5; i++) { + await vi.advanceTimersByTimeAsync(1_000); + harness.scheduleHeartbeatManagerRefresh(); + } + + expect(harness.refreshHeartbeatCatalog).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("re-arms to an earlier deadline when a sooner heartbeat appears", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const harness = Object.create(InteractiveMode.prototype) as HeartbeatRefreshHarness; + // Two minutes out, so the first schedule arms the capped 60s poll. + harness.heartbeatCatalog = [{ job: { ...heartbeat(), nextRunAt: "2026-01-01T00:02:00.000Z" } }]; + harness.connectionState = { activeSessionId: "active-1", sessionId: "session-1" }; + harness.subagentSnapshots = new Map(); + harness.heartbeatManager = {}; + harness.heartbeatManagerRefreshTimer = undefined; + harness.refreshHeartbeatCatalog = vi.fn(async () => {}); + + harness.scheduleHeartbeatManagerRefresh(); + // A sooner heartbeat must pull the pending refresh forward, not sit + // behind the already-armed 60s poll. + harness.heartbeatCatalog = [{ job: { ...heartbeat(), nextRunAt: "2026-01-01T00:00:02.000Z" } }]; + harness.scheduleHeartbeatManagerRefresh(); + await vi.advanceTimersByTimeAsync(3_000); + + expect(harness.refreshHeartbeatCatalog).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/packages/coding-agent/test/interactive-mode-ctrl-c.test.ts b/packages/coding-agent/test/interactive-mode-ctrl-c.test.ts index 21fa99890e..caa8a1a8e8 100644 --- a/packages/coding-agent/test/interactive-mode-ctrl-c.test.ts +++ b/packages/coding-agent/test/interactive-mode-ctrl-c.test.ts @@ -26,7 +26,6 @@ type FakeInteractiveMode = { retryAttempt: number; sessionActions: { queuedCount: number; steering: readonly string[]; followUps: readonly string[] }; }; - connectionQueue: { steering: string[]; followUp: string[] }; agentConnection: { abort: Mock; clearQueue: Mock; @@ -98,7 +97,6 @@ function createInteractiveFake(options: { retryAttempt: options.retryAttempt ?? 0, sessionActions: { queuedCount: 0, steering: [], followUps: [] }, }, - connectionQueue: { steering: [], followUp: [] }, agentConnection: { abort: vi.fn().mockResolvedValue(undefined), clearQueue: vi.fn().mockResolvedValue({ steering: [], followUp: [] }), @@ -172,7 +170,7 @@ describe("InteractiveMode interrupt shortcuts", () => { it("preserves the queue and the draft when interrupting streaming", () => { const mode = createInteractiveFake({ editorText: "draft", streaming: true }); - mode.connectionQueue = { steering: ["steer"], followUp: ["follow"] }; + mode.connectionState.sessionActions = { queuedCount: 2, steering: ["steer"], followUps: ["follow"] }; Reflect.get(InteractiveMode.prototype, "handleCtrlC").call(mode); @@ -180,7 +178,11 @@ describe("InteractiveMode interrupt shortcuts", () => { expect(mode.agentConnection.abortAndClearQueue).not.toHaveBeenCalled(); expect(mode.agentConnection.clearQueue).not.toHaveBeenCalled(); expect(mode.editor.getText()).toBe("draft"); - expect(mode.connectionQueue).toEqual({ steering: ["steer"], followUp: ["follow"] }); + expect(mode.connectionState.sessionActions).toEqual({ + queuedCount: 2, + steering: ["steer"], + followUps: ["follow"], + }); }); it("exits on the second Ctrl+C while the hint is visible", () => { diff --git a/packages/coding-agent/test/interactive-mode-feature-hints.test.ts b/packages/coding-agent/test/interactive-mode-feature-hints.test.ts index 3fa36d9fc3..5be90902e9 100644 --- a/packages/coding-agent/test/interactive-mode-feature-hints.test.ts +++ b/packages/coding-agent/test/interactive-mode-feature-hints.test.ts @@ -33,7 +33,10 @@ function createMode() { featureHintContainer, loadingAnimation: loader, workingVisible: true, - connectionState: { isStreaming: true }, + connectionState: { + isStreaming: true, + sessionActions: { queuedCount: 0, steering: [], followUps: [] }, + }, workingTimer: undefined, workingStartedAt: 0, featureHintDeck, @@ -43,7 +46,6 @@ function createMode() { featureHintAnimationTimer: undefined, featureHintComponent: undefined, featureHintRunPending: false, - connectionQueue: { steering: [], followUp: [] }, compactionQueuedMessages: [], options: { returnToAgentsView: true }, ui: { requestRender }, @@ -54,7 +56,7 @@ function createMode() { describe("feature hint deck", () => { it("shows every available hint before repeating and avoids a boundary repeat", () => { - const deck = new FeatureHintDeck(() => 0); + const deck = new FeatureHintDeck(); const context = { getKeybinding: (action: string) => `Custom ${action}`, isResidentSession: true }; const firstCycle = FEATURE_HINTS.map(() => deck.next(context)); @@ -64,7 +66,6 @@ describe("feature hint deck", () => { }); it("uses configured shortcuts in keybinding-based hints", () => { - const deck = new FeatureHintDeck(() => 0); const context = { getKeybinding: (action: string) => { if (action === "app.prompt.stash") return "Meta+S"; @@ -73,39 +74,11 @@ describe("feature hint deck", () => { }, isResidentSession: true, }; - const hints = FEATURE_HINTS.map(() => deck.next(context)); - - expect(hints.find((hint) => hint?.id === "prompt-stash")?.text).toContain("Meta+S"); - expect(hints.find((hint) => hint?.id === "follow-up")?.text).toContain("Meta+Enter"); - expect(hints.find((hint) => hint?.id === "agents-view")?.text).toContain("Meta+Left"); - }); - - it("covers Prime Agent workflows with capability-focused copy", () => { - const deck = new FeatureHintDeck(() => 0); - const hints = FEATURE_HINTS.map(() => deck.next({ getKeybinding: () => "Meta+A", isResidentSession: true })); - const textById = new Map(hints.map((hint) => [hint?.id, hint?.text])); - - expect(textById.get("subagents")).toBe("Prime Agent can delegate tasks to subagents and run them in parallel."); - expect(textById.get("agents-view")).toContain("Session View"); - expect(textById.get("session-rewind")).toContain("/tree"); - expect(textById.get("steering")).toContain("steer"); - expect(textById.get("agent-messaging")).toContain("message each other"); - expect(textById.get("goal")).toContain("/goal"); - expect(textById.get("refine")).toContain("/refine"); - expect(textById.get("persistent-ipython")).toContain("kernel variables"); - expect(textById.get("context-usage")).toContain("/context"); - expect(textById.get("session-fork")).toContain("/fork"); - expect(textById.get("compaction")).toContain("/compact"); - expect(textById.get("auto-compaction")).toContain("automatically compacts"); - expect(textById.get("auto-refine")).toContain("self-improves"); - expect(textById.get("background-running")).toContain("close the terminal"); - }); - - it("keeps every hint concise", () => { - const deck = new FeatureHintDeck(() => 0); - const hints = FEATURE_HINTS.map(() => deck.next({ getKeybinding: () => "Ctrl+Key", isResidentSession: true })); + const textById = new Map(FEATURE_HINTS.map((hint) => [hint.id, hint.getText(context)])); - expect(hints.every((hint) => hint !== undefined && hint.text.length <= 80)).toBe(true); + expect(textById.get("prompt-stash")).toContain("Meta+S"); + expect(textById.get("follow-up")).toContain("Meta+Enter"); + expect(textById.get("agents-view")).toContain("Meta+Left"); }); it("hides resident-only hints in ephemeral sessions", () => { diff --git a/packages/coding-agent/test/interactive-mode-prompt-stash.test.ts b/packages/coding-agent/test/interactive-mode-prompt-stash.test.ts index 9c033e8c26..eb7b746497 100644 --- a/packages/coding-agent/test/interactive-mode-prompt-stash.test.ts +++ b/packages/coding-agent/test/interactive-mode-prompt-stash.test.ts @@ -44,7 +44,9 @@ type PromptStashHarness = { }; type PromptStashLiveMarkerHarness = PromptStashHarness & { - connectionQueue: { steering: string[]; followUp: string[] }; + connectionState: { + sessionActions: { queuedCount: number; steering: readonly string[]; followUps: readonly string[] }; + }; }; type SharedPromptStashHarness = PromptStashHarness & { @@ -628,13 +630,13 @@ describe("InteractiveMode prompt stash", () => { expect(mode.editor.getText()).toBe("half-written draft"); }); - it("drops queued image references from old sessions while keeping stashed images", () => { + it("drops old-session images while keeping stashed images", () => { const base = createPromptStashHarness({ stash: "keep [image #1]" }); const mode: ResetHarness = { ...base, defaultEditor: base.editor, queueSelection: new QueueSelection(), - connectionQueue: { steering: ["old [image #2]"], followUp: [] }, + connectionState: { sessionActions: { queuedCount: 0, steering: [], followUps: [] } }, chatContainer: { clear: vi.fn() }, shortcutGuideContainer: { clear: vi.fn() }, pendingMessagesContainer: { clear: vi.fn() }, @@ -661,7 +663,6 @@ describe("InteractiveMode prompt stash", () => { interactiveModeMethods.resetCurrentSessionRenderState.call(mode); - expect(mode.connectionQueue).toEqual({ steering: [], followUp: [] }); expect(mode.promptStash?.text).toBe("keep [image #1]"); expect(mode.pastedImages.has(1)).toBe(true); expect(mode.pastedImages.has(2)).toBe(false); @@ -673,7 +674,7 @@ describe("InteractiveMode prompt stash", () => { ...base, defaultEditor: base.editor, queueSelection: new QueueSelection(), - connectionQueue: { steering: [], followUp: [] }, + connectionState: { sessionActions: { queuedCount: 0, steering: [], followUps: [] } }, chatContainer: { clear: vi.fn() }, shortcutGuideContainer: { clear: vi.fn() }, pendingMessagesContainer: { clear: vi.fn() }, @@ -818,7 +819,7 @@ describe("InteractiveMode prompt stash", () => { it("keeps image markers in a stashed prompt live", () => { const mode: PromptStashLiveMarkerHarness = { ...createPromptStashHarness({ stash: "look at [image #7]" }), - connectionQueue: { steering: [], followUp: [] }, + connectionState: { sessionActions: { queuedCount: 0, steering: [], followUps: [] } }, }; Object.setPrototypeOf(mode, InteractiveMode.prototype); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 4c06344564..b23232f6a6 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1264,7 +1264,7 @@ describe("InteractiveMode pending bash components", () => { const bashComponent = () => ({ render: () => [], invalidate: () => {} }); - test("keeps pending bash components visible across queue refreshes", () => { + test("keeps pending bash components visible across queue display updates", () => { const pendingMessagesContainer = new Container(); const component = bashComponent(); const fakeThis = { @@ -1434,74 +1434,60 @@ describe("InteractiveMode pending bash components", () => { }); describe("InteractiveMode connection events", () => { - test("rendering a switched session tolerates a transient queue refresh failure", async () => { + test("rendering a switched session updates the pending display from its snapshot", async () => { const harness = { resetCurrentSessionRenderState: vi.fn(), renderInitialMessages: vi.fn(async () => {}), - refreshConnectionQueue: vi.fn(async () => { - throw new Error("queue unavailable"); - }), + updatePendingMessagesDisplay: vi.fn(), syncWorkingLoader: vi.fn(), }; - await expect( - ( - InteractiveMode.prototype as unknown as { - renderCurrentSessionState(this: typeof harness): Promise; - } - ).renderCurrentSessionState.call(harness), - ).resolves.toBeUndefined(); + await ( + InteractiveMode.prototype as unknown as { + renderCurrentSessionState(this: typeof harness): Promise; + } + ).renderCurrentSessionState.call(harness); + expect(harness.updatePendingMessagesDisplay).toHaveBeenCalledOnce(); expect(harness.syncWorkingLoader).toHaveBeenCalledOnce(); }); - test("degrades heartbeat refresh failures without hiding queue refresh failures during rebind", async () => { + test("degrades heartbeat refresh failures while updating the pending display during rebind", async () => { const rebindCurrentSession = ( InteractiveMode.prototype as unknown as { rebindCurrentSession(this: InteractiveMode): Promise } ).rebindCurrentSession; - const createHarness = ( - refreshConnectionQueue: () => Promise, - refreshHeartbeatCatalog: () => Promise, - ) => - ({ - unsubscribe: undefined, - localSessionHost: undefined, - toolDefinitionCache: { clear: vi.fn() }, - applyRuntimeSettings: vi.fn(), - bindLocalSessionExtensions: true, - bindCurrentSessionExtensions: vi.fn(async () => {}), - subscribeToAgent: vi.fn(), - refreshConnectionQueue, - refreshHeartbeatCatalog, - updateAvailableProviderCount: vi.fn(async () => {}), - updateEditorBorderColor: vi.fn(), - updateTerminalTitle: vi.fn(), - setGoalAnnouncementBaseline: vi.fn(), - syncGoalTray: vi.fn(), - syncWorkingLoader: vi.fn(), - getGoalState: () => emptyGoalState(), - }) as unknown as InteractiveMode; - - await expect( - rebindCurrentSession.call( - createHarness( - vi.fn(async () => {}), - vi.fn(async () => { - throw new Error("heartbeat unavailable"); - }), - ), - ), - ).resolves.toBeUndefined(); + const updatePendingMessagesDisplay = vi.fn(); + const subscribeToAgent = vi.fn(); + const getState = vi.fn(async () => createConnectionState()); + const harness = { + unsubscribe: undefined, + localSessionHost: undefined, + toolDefinitionCache: { clear: vi.fn() }, + applyRuntimeSettings: vi.fn(), + bindLocalSessionExtensions: true, + bindCurrentSessionExtensions: vi.fn(async () => {}), + subscribeToAgent, + agentConnection: { getState }, + patchConnectionState: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), + updatePendingMessagesDisplay, + refreshHeartbeatCatalog: vi.fn(async () => { + throw new Error("heartbeat unavailable"); + }), + updateAvailableProviderCount: vi.fn(async () => {}), + updateEditorBorderColor: vi.fn(), + updateTerminalTitle: vi.fn(), + setGoalAnnouncementBaseline: vi.fn(), + syncGoalTray: vi.fn(), + syncWorkingLoader: vi.fn(), + getGoalState: () => emptyGoalState(), + } as unknown as InteractiveMode; - await expect( - rebindCurrentSession.call( - createHarness( - vi.fn(async () => { - throw new Error("queue unavailable"); - }), - vi.fn(async () => {}), - ), - ), - ).rejects.toThrow("queue unavailable"); + await expect(rebindCurrentSession.call(harness)).resolves.toBeUndefined(); + expect(updatePendingMessagesDisplay).toHaveBeenCalledOnce(); + // The queue re-sync must run after subscribing, or updates in the gap are lost. + expect(getState.mock.invocationCallOrder[0]).toBeGreaterThan( + subscribeToAgent.mock.invocationCallOrder[0] as number, + ); }); test("restores in-flight assistant state on every session render", async () => { @@ -1694,6 +1680,64 @@ describe("InteractiveMode connection events", () => { expect(fakeThis.renderInitialMessages).toHaveBeenCalledOnce(); }); + test("exits stale queue browsing when a resync replaces the queue snapshot", async () => { + const queueSelection = new QueueSelection(); + let editorText = "draft"; + queueSelection.move({ steering: [], followUp: ["queued"] }, editorText, -1); + editorText = "queued"; + const snapshot: AgentConnectionSnapshot = { + state: createConnectionState({ + sessionActions: { queuedCount: 0, steering: [], followUps: [] }, + }), + messages: [], + }; + const fakeThis = { + connectionState: createConnectionState({ + sessionActions: { queuedCount: 1, steering: [], followUps: ["queued"] }, + }), + queueSelection, + pendingQueueEdit: undefined, + pendingQueueMove: false, + isApplyingQueueSelectionText: false, + editor: { + getText: () => editorText, + setText: (text: string) => { + editorText = text; + }, + }, + isBashRunning: () => false, + applyConnectionStateSnapshot: vi.fn(), + restoreTurnStartFromMessages: vi.fn(), + replaceSubagentSummary: vi.fn(), + getSessionContextFromConnectionSnapshot: vi.fn(() => ({ + messages: [], + thinkingLevel: "medium", + model: null, + })), + renderSessionContext: vi.fn(async () => {}), + restoreStreamingMessageFromSnapshot: vi.fn(), + updatePendingMessagesDisplay: vi.fn(), + updateTerminalTitle: vi.fn(), + setGoalAnnouncementBaseline: vi.fn(), + syncGoalTray: vi.fn(), + syncWorkingLoader: vi.fn(), + getGoalState: () => emptyGoalState(), + }; + fakeThis.applyConnectionStateSnapshot.mockImplementation((state: AgentConnectionState) => { + fakeThis.connectionState = state; + }); + Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); + + await ( + InteractiveMode.prototype as unknown as { + renderResyncedSession(this: unknown, value: AgentConnectionSnapshot): Promise; + } + ).renderResyncedSession.call(fakeThis, snapshot); + + expect(queueSelection.isBrowsing).toBe(false); + expect(editorText).toBe("draft"); + }); + test("preserves client-local work while rendering a resynchronized snapshot", async () => { const sideQuestion = { id: "side-1", status: "running" }; const extensionRequests = new Map([["request-1", { cancelLocal: vi.fn() }]]); @@ -1727,6 +1771,7 @@ describe("InteractiveMode connection events", () => { streamingComponent: {}, streamingMessage: {}, applyConnectionStateSnapshot: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), updateWorkingLoaderMessage: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ @@ -1736,7 +1781,7 @@ describe("InteractiveMode connection events", () => { })), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot, - refreshConnectionQueue: vi.fn(async () => {}), + updatePendingMessagesDisplay: vi.fn(), flushPendingBashComponents: vi.fn(), updateTerminalTitle: vi.fn(), setGoalAnnouncementBaseline: vi.fn(), @@ -1783,6 +1828,7 @@ describe("InteractiveMode connection events", () => { isAgentCompacting: () => true, isBashRunning: () => true, applyConnectionStateSnapshot: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), restoreTurnStartFromMessages: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ @@ -1792,7 +1838,7 @@ describe("InteractiveMode connection events", () => { })), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot: vi.fn(), - refreshConnectionQueue: vi.fn(async () => {}), + updatePendingMessagesDisplay: vi.fn(), flushPendingBashComponents, updateTerminalTitle: vi.fn(), setGoalAnnouncementBaseline: vi.fn(), @@ -1830,7 +1876,7 @@ describe("InteractiveMode connection events", () => { }), resetCurrentSessionRenderState: () => calls.push("reset"), renderInitialMessages: async () => calls.push("messages"), - refreshConnectionQueue: async () => calls.push("queue"), + updatePendingMessagesDisplay: () => calls.push("display"), syncWorkingLoader: () => calls.push("loader"), }; @@ -1840,7 +1886,7 @@ describe("InteractiveMode connection events", () => { } ).renderCurrentSessionState.call(fakeThis); - expect(calls).toEqual(["replacement", "reset", "messages", "queue", "loader"]); + expect(calls).toEqual(["replacement", "reset", "messages", "display", "loader"]); }); test("drops a queued source event after the session is replaced", async () => { @@ -3119,7 +3165,7 @@ class EventEmittingReplacementRuntime { describe("InteractiveMode session switch command catalog", () => { test.each(["switchSession", "newSession", "fork"] as const)( - "refreshes an event-emitting in-process %s replacement exactly once before replay", + "refreshes the command catalog for an event-emitting in-process %s replacement exactly once before replay", async (operation) => { const sourceSession = createFakeConnectionSession("source-command"); const targetSession = createFakeConnectionSession("target-command"); @@ -3150,12 +3196,13 @@ describe("InteractiveMode session switch command catalog", () => { resetExtensionUI: vi.fn(), applyConnectionStateSnapshot: vi.fn(), resetCurrentSessionRenderState: vi.fn(() => calls.push("reset")), + queueSelection: { selected: undefined }, setupAutocompleteProvider: vi.fn(() => calls.push("catalog")), renderInitialMessages: vi.fn(async () => { calls.push("render"); expect(fakeThis.connectionCommands.map((command) => command.name)).toEqual(["target-command"]); }), - refreshConnectionQueue: vi.fn(async () => {}), + updatePendingMessagesDisplay: vi.fn(), syncWorkingLoader: vi.fn(), ui: { requestRender: vi.fn() }, handleEvent: vi.fn(), @@ -4206,8 +4253,11 @@ describe("InteractiveMode goal status announcements", () => { describe("InteractiveMode tray goal label", () => { type TrayUsage = { contextWindow: number; tokens: number | null; percent: number | null }; type TrayLabelHarness = { - heartbeats: AgentConnectionHeartbeat[]; + heartbeatCatalog: AgentConnectionHeartbeat[]; + subagentSnapshots: Map; connectionState: { + activeSessionId: string; + sessionId: string; goal: GoalState; heartbeat?: AgentCronJob | null; contextUsage: TrayUsage | undefined; @@ -4237,8 +4287,11 @@ describe("InteractiveMode tray goal label", () => { test("shows active goals in the lower tray without an objective", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; - fakeThis.heartbeats = []; + fakeThis.heartbeatCatalog = []; + fakeThis.subagentSnapshots = new Map(); fakeThis.connectionState = { + activeSessionId: "active-1", + sessionId: "session-1", goal: { active: true, status: "active", @@ -4256,8 +4309,11 @@ describe("InteractiveMode tray goal label", () => { test("combines active goals with token/context usage in one lower-tray label", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; - fakeThis.heartbeats = []; + fakeThis.heartbeatCatalog = []; + fakeThis.subagentSnapshots = new Map(); fakeThis.connectionState = { + activeSessionId: "active-1", + sessionId: "session-1", goal: { active: true, status: "active", @@ -4275,8 +4331,11 @@ describe("InteractiveMode tray goal label", () => { test("combines active goals, active heartbeats, and context usage in one lower-tray label", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; - fakeThis.heartbeats = [{ job: createHeartbeat("active") }]; + fakeThis.heartbeatCatalog = [{ job: createHeartbeat("active") }]; + fakeThis.subagentSnapshots = new Map(); fakeThis.connectionState = { + activeSessionId: "active-1", + sessionId: "session-1", goal: { active: true, status: "active", @@ -4295,8 +4354,11 @@ describe("InteractiveMode tray goal label", () => { test("omits the usage segment when token count is unknown", () => { const fakeThis = Object.create(InteractiveMode.prototype) as TrayLabelHarness; - fakeThis.heartbeats = []; + fakeThis.heartbeatCatalog = []; + fakeThis.subagentSnapshots = new Map(); fakeThis.connectionState = { + activeSessionId: "active-1", + sessionId: "session-1", goal: { active: true, status: "active", diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index b53b21c975..009be4650b 100644 --- a/packages/coding-agent/test/interactive-queue-edit.test.ts +++ b/packages/coding-agent/test/interactive-queue-edit.test.ts @@ -1,10 +1,20 @@ import { describe, expect, it, vi } from "vitest"; +import type { QueuedMessageMutation } from "../src/core/session-action-store.js"; +import type { AgentConnectionSessionEvent } from "../src/modes/agent-connection/index.js"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; import { QueueSelection } from "../src/modes/interactive/queue-selection.js"; +type QueueState = { steering: string[]; followUp: string[] }; + type Harness = { queueSelection: QueueSelection; - connectionQueue: { steering: string[]; followUp: string[] }; + connectionState: { + sessionActions: { + queuedCount: number; + steering: readonly string[]; + followUps: readonly string[]; + }; + }; editor: { getText: () => string; setText: (text: string) => void; addToHistory?: (text: string) => void }; isApplyingQueueSelectionText: boolean; pastedImages: Map; @@ -14,19 +24,27 @@ type Harness = { ui: { requestRender: () => void }; agentConnection: { mutateQueuedMessage: ReturnType; - getQueue: ReturnType; abort?: ReturnType; }; sessionEventGeneration: number; + sessionEventQueue: Promise; inputSubmissionGeneration: number; pendingQueueEdit: symbol | undefined; + pendingQueueMove: boolean; queueMutationChain: Promise; enqueueQueueMutation: (run: () => Promise) => Promise; applyQueueSelection: (text: string, targetLane: "steering" | "followUp") => Promise; browseQueueSelection: (direction: -1 | 1) => void; moveQueueSelection: (direction: -1 | 1) => void; - refreshConnectionQueue: () => Promise; - replaceConnectionQueue: (queue: { steering: string[]; followUp: string[] }) => void; + getConnectionQueue: () => QueueState; + refreshQueueSelectionAt: ( + queue: QueueState, + selected: { lane: "steering" | "followUp"; index: number; text: string }, + index: number, + ) => void; + refreshQueueSelectionFromState: () => void; + updateConnectionStateFromEvent: (event: AgentConnectionSessionEvent) => void; + patchConnectionState: (patch: Partial) => void; setEditorTextFromQueueSelection: (text: string) => void; collectQueueReplaceImages: (text: string) => unknown; }; @@ -37,7 +55,13 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate let editorText = ""; const harness = { queueSelection: new QueueSelection(), - connectionQueue: queue, + connectionState: { + sessionActions: { + queuedCount: queue.steering.length + queue.followUp.length, + steering: queue.steering, + followUps: queue.followUp, + }, + }, editor: { getText: () => editorText, setText: (text: string) => { @@ -53,25 +77,52 @@ function createHarness(queue: { steering: string[]; followUp: string[] }, mutate ui: { requestRender: vi.fn() }, agentConnection: { mutateQueuedMessage: vi.fn(async () => mutateResult), - getQueue: vi.fn(async () => ({ steering: [], followUp: [] })), abort: vi.fn(async () => {}), }, sessionEventGeneration: 0, + sessionEventQueue: Promise.resolve(), inputSubmissionGeneration: 0, pendingQueueEdit: undefined, + pendingQueueMove: false, queueMutationChain: Promise.resolve(), enqueueQueueMutation: proto.enqueueQueueMutation, applyQueueSelection: proto.applyQueueSelection, browseQueueSelection: proto.browseQueueSelection, moveQueueSelection: proto.moveQueueSelection, - refreshConnectionQueue: proto.refreshConnectionQueue, - replaceConnectionQueue: proto.replaceConnectionQueue, + getConnectionQueue: proto.getConnectionQueue, + refreshQueueSelectionAt: proto.refreshQueueSelectionAt, + refreshQueueSelectionFromState: proto.refreshQueueSelectionFromState, + updateConnectionStateFromEvent: proto.updateConnectionStateFromEvent, + patchConnectionState: () => {}, setEditorTextFromQueueSelection: proto.setEditorTextFromQueueSelection, collectQueueReplaceImages: proto.collectQueueReplaceImages, } as unknown as Harness; + harness.patchConnectionState = (patch) => { + harness.connectionState = { ...harness.connectionState, ...patch }; + }; return harness; } +function setQueue(harness: Harness, queue: QueueState): void { + harness.connectionState.sessionActions = { + ...harness.connectionState.sessionActions, + queuedCount: queue.steering.length + queue.followUp.length, + steering: queue.steering, + followUps: queue.followUp, + }; +} + +function emitQueueUpdate(harness: Harness, queue: QueueState): void { + harness.updateConnectionStateFromEvent({ + type: "session_action_update", + actions: { + queuedCount: queue.steering.length + queue.followUp.length, + steering: queue.steering, + followUps: queue.followUp, + }, + }); +} + describe("interactive queued-message editing", () => { it("browses into the queue and applies an enter edit as steering", async () => { const harness = createHarness({ steering: ["s1"], followUp: ["f1"] }); @@ -102,7 +153,7 @@ describe("interactive queued-message editing", () => { lane: "followUp", }); - harness.connectionQueue = { steering: ["s1"], followUp: [] }; + setQueue(harness, { steering: ["s1"], followUp: [] }); harness.browseQueueSelection(-1); await harness.applyQueueSelection(" ", "steering"); expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenLastCalledWith("steering", 0, "s1", { @@ -183,7 +234,7 @@ describe("interactive queued-message editing", () => { const pending = harness.applyQueueSelection(text, "steering"); await vi.waitFor(() => expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce()); - harness.replaceConnectionQueue({ + setQueue(harness, { steering: text.trim() ? [text.trim()] : [], followUp: [], }); @@ -244,30 +295,6 @@ describe("interactive queued-message editing", () => { expect(harness.editor.getText()).toBe("edited"); }); - it("restores the submitted edit when its queue item vanishes before the mutation starts", async () => { - let releaseMutationChain: () => void = () => {}; - const harness = createHarness({ steering: ["queued"], followUp: [] }); - harness.queueMutationChain = new Promise((resolve) => { - releaseMutationChain = resolve; - }); - harness.editor.setText("draft"); - harness.browseQueueSelection(-1); - harness.editor.setText(""); - const pending = harness.applyQueueSelection("edited", "steering"); - harness.replaceConnectionQueue({ steering: ["remaining"], followUp: [] }); - releaseMutationChain(); - await pending; - expect(harness.agentConnection.mutateQueuedMessage).not.toHaveBeenCalled(); - expect(harness.editor.getText()).toBe("edited"); - expect(harness.queueSelection.hasDraft).toBe(true); - expect(harness.showStatus).toHaveBeenCalledWith("Queue changed; edit kept in the editor"); - - harness.browseQueueSelection(-1); - expect(harness.editor.getText()).toBe("remaining"); - harness.browseQueueSelection(1); - expect(harness.editor.getText()).toBe("edited"); - }); - it("does not reset queue browsing in a replacement session when an old mutation completes", async () => { let resolveMutation: (status: string) => void = () => {}; const harness = createHarness({ steering: ["old queued"], followUp: [] }); @@ -288,7 +315,7 @@ describe("interactive queued-message editing", () => { harness.sessionEventGeneration++; harness.pendingQueueEdit = undefined; harness.queueSelection.reset(); - harness.connectionQueue = { steering: ["new queued"], followUp: [] }; + setQueue(harness, { steering: ["new queued"], followUp: [] }); harness.editor.setText("new draft"); harness.browseQueueSelection(-1); @@ -323,101 +350,238 @@ describe("interactive queued-message editing", () => { expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); }); - it("serializes rapid moves and addresses the second with the post-move index before any queue event", async () => { - // The daemon's session_action_update can arrive after the mutation response, - // so the local mirror must be updated optimistically between chained moves. - const harness = createHarness({ steering: ["s1", "s2", "s3"], followUp: [] }); - harness.browseQueueSelection(-1); // s3 at index 2 - harness.moveQueueSelection(-1); + it("exits browsing when an external event removes the selected item", async () => { + const harness = createHarness({ steering: [], followUp: ["queued"] }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + + emitQueueUpdate(harness, { steering: [], followUp: [] }); + + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("draft"); + await expect(harness.applyQueueSelection("draft", "steering")).resolves.toBe(false); + expect(harness.agentConnection.mutateQueuedMessage).not.toHaveBeenCalled(); + }); + + it("refreshes browse navigation from external queue events", () => { + const harness = createHarness({ steering: ["s1"], followUp: ["f1", "f2"] }); + harness.browseQueueSelection(-1); + + emitQueueUpdate(harness, { steering: ["s1"], followUp: ["f0", "f2", "f3"] }); + harness.browseQueueSelection(-1); + + expect(harness.editor.getText()).toBe("f0"); + }); + + it("refreshes selection from event-driven queue state after a move", async () => { + const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { + emitQueueUpdate(harness, { steering: ["s2", "s1"], followUp: [] }); + return "applied"; + }); + harness.browseQueueSelection(-1); harness.moveQueueSelection(-1); await harness.queueMutationChain; - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(1, "steering", 2, "s3", { - type: "move", - direction: -1, + + expect(harness.getConnectionQueue()).toEqual({ steering: ["s2", "s1"], followUp: [] }); + expect(harness.queueSelection.selected).toEqual({ lane: "steering", index: 0, text: "s2" }); + }); + + it("leaves browse mode when the moved tuple is absent from the event snapshot", async () => { + const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); + return "applied"; }); - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(2, "steering", 1, "s3", { - type: "move", - direction: -1, + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + await harness.queueMutationChain; + + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("draft"); + }); + + it("refreshes selection after a failed move suppresses an external event", async () => { + const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }, "rejected"); + harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); + return "rejected"; }); - expect(harness.connectionQueue.steering).toEqual(["s3", "s1", "s2"]); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + await harness.queueMutationChain; + + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("draft"); }); - it("preserves a queued reorder when an edit immediately exits browse mode", async () => { + it("keeps a chained edit when the preceding move loses its selection", async () => { const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); + return "applied"; + }); + harness.editor.setText("draft"); harness.browseQueueSelection(-1); harness.moveQueueSelection(-1); - const edited = harness.applyQueueSelection("s2 edited", "steering"); - await harness.queueMutationChain; + harness.editor.setText(""); + await harness.applyQueueSelection("s2 edited", "steering"); + + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); + expect(harness.editor.getText()).toBe("s2 edited"); + expect(harness.showStatus).toHaveBeenCalledWith("Queue changed; edit kept in the editor"); + }); + + it("uses canonical post-move positions for consecutive moves and an edit", async () => { + const queue = ["s1", "s2", "s3"]; + const harness = createHarness({ steering: queue, followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation( + async ( + _lane: "steering" | "followUp", + index: number, + expectedText: string, + mutation: QueuedMessageMutation, + ) => { + const item = queue[index]; + if (item !== expectedText) return "rejected"; + if (mutation.type === "move") { + const target = index + mutation.direction; + const neighbor = queue[target]; + if (neighbor === undefined) return "rejected"; + queue[index] = neighbor; + queue[target] = item; + } else if (mutation.type === "replace") { + queue[index] = mutation.text; + } + emitQueueUpdate(harness, { steering: [...queue], followUp: [] }); + return "applied"; + }, + ); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + harness.moveQueueSelection(-1); + const edited = harness.applyQueueSelection("s3 edited", "steering"); await edited; - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(1, "steering", 1, "s2", { + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(1, "steering", 2, "s3", { type: "move", direction: -1, }); - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(2, "steering", 0, "s2", { + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(2, "steering", 1, "s3", { + type: "move", + direction: -1, + }); + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(3, "steering", 0, "s3", { type: "replace", - text: "s2 edited", + text: "s3 edited", images: [], lane: "steering", }); + expect(harness.getConnectionQueue()).toEqual({ steering: ["s3 edited", "s1", "s2"], followUp: [] }); }); - it("optimistically updates the local queue mirror on replace and delete", async () => { - const harness = createHarness({ steering: ["s1", "s2"], followUp: ["f1"] }); - harness.browseQueueSelection(-1); // f1 - await harness.applyQueueSelection("f1 edited", "followUp"); - // An immediate browse must see the new text before the queue event arrives. - expect(harness.connectionQueue).toEqual({ steering: ["s1", "s2"], followUp: ["f1 edited"] }); + it("keeps the selected index when duplicate text shifts before an edit", async () => { + let releaseMutationChain: () => void = () => {}; + const harness = createHarness({ steering: [], followUp: ["dup", "dup"] }, "rejected"); + harness.queueMutationChain = new Promise((resolve) => { + releaseMutationChain = resolve; + }); + harness.browseQueueSelection(-1); + const pending = harness.applyQueueSelection("edited", "followUp"); + setQueue(harness, { steering: [], followUp: ["dup"] }); + releaseMutationChain(); + await pending; - harness.browseQueueSelection(-1); // f1 edited - await harness.applyQueueSelection(" ", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: ["s1", "s2"], followUp: [] }); + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledWith("followUp", 1, "dup", { + type: "replace", + text: "edited", + images: [], + lane: "followUp", + }); }); - it("does not double-apply a delete when the queue event lands before the response", async () => { - const harness = createHarness({ steering: [], followUp: ["dup", "dup"] }); - harness.agentConnection.mutateQueuedMessage.mockImplementation(async () => { - // The server's session_action_update arrives before the response - // resolves: the mirror is replaced and the selection retargets to - // the remaining same-text item. - harness.connectionQueue = { steering: [], followUp: ["dup"] }; - harness.queueSelection.sync(harness.connectionQueue); - return "applied"; - }); - harness.browseQueueSelection(-1); // dup at followUp index 1 - await harness.applyQueueSelection(" ", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: [], followUp: ["dup"] }); + it("ignores browse keys while a queue move is pending", async () => { + let resolveMutation: (status: string) => void = () => {}; + const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation( + () => + new Promise((resolve) => { + resolveMutation = resolve; + }), + ); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + await vi.waitFor(() => expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce()); + + harness.browseQueueSelection(-1); + expect(harness.editor.getText()).toBe("s2"); + + resolveMutation("applied"); + await harness.queueMutationChain; + expect(harness.queueSelection.selected).toEqual({ lane: "steering", index: 0, text: "s2" }); }); - it("moves the item across lanes in the local mirror on a lane-changing replace", async () => { - const harness = createHarness({ steering: ["s1"], followUp: [] }); - harness.browseQueueSelection(-1); // s1 - await harness.applyQueueSelection("now follow-up", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: [], followUp: ["now follow-up"] }); + it("keeps the moved selection when the queue event lands after the response", async () => { + const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + await harness.queueMutationChain; + + expect(harness.getConnectionQueue()).toEqual({ steering: ["s2", "s1"], followUp: [] }); + expect(harness.queueSelection.selected).toEqual({ lane: "steering", index: 0, text: "s2" }); + + emitQueueUpdate(harness, { steering: ["s2", "s1"], followUp: [] }); + expect(harness.queueSelection.selected).toEqual({ lane: "steering", index: 0, text: "s2" }); + expect(harness.editor.getText()).toBe("s2"); }); - it("restores the stashed draft when the browsed item is consumed externally", () => { - const harness = createHarness({ steering: [], followUp: ["f1"] }); + it("drops a stale selection after a rejected edit so enter returns to normal submission", async () => { + let resolveMutation: (status: string) => void = () => {}; + const harness = createHarness({ steering: ["queued"], followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation( + () => + new Promise((resolve) => { + resolveMutation = resolve; + }), + ); harness.editor.setText("draft"); harness.browseQueueSelection(-1); - expect(harness.editor.getText()).toBe("f1"); - // The item is delivered: the queue update drops the selection. - harness.connectionQueue = { steering: [], followUp: [] }; - const dropped = harness.queueSelection.sync(harness.connectionQueue); - expect(dropped).toBe("f1"); - if (dropped !== undefined && harness.editor.getText() === dropped) { - harness.setEditorTextFromQueueSelection(harness.queueSelection.reset()); - } - expect(harness.editor.getText()).toBe("draft"); + harness.editor.setText(""); + const pending = harness.applyQueueSelection("edited", "steering"); + await vi.waitFor(() => expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce()); + + // The item is consumed while the edit is pending; event reconciliation is suppressed. + emitQueueUpdate(harness, { steering: [], followUp: [] }); + resolveMutation("rejected"); + await pending; + + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("edited"); + await expect(harness.applyQueueSelection("edited", "steering")).resolves.toBe(false); + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); }); - it("synchronizes queue browsing when a reconnect refresh replaces the queue", async () => { - const harness = createHarness({ steering: [], followUp: ["queued"] }); + it("drops a stale selection when a move request fails after the item was consumed", async () => { + let rejectMutation: (error: Error) => void = () => {}; + const harness = createHarness({ steering: ["s1", "s2"], followUp: [] }); + harness.agentConnection.mutateQueuedMessage.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectMutation = reject; + }), + ); harness.editor.setText("draft"); harness.browseQueueSelection(-1); - harness.agentConnection.getQueue.mockResolvedValue({ steering: [], followUp: [] }); + harness.moveQueueSelection(-1); + await vi.waitFor(() => expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce()); - await harness.refreshConnectionQueue(); + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); + rejectMutation(new Error("connection lost")); + await vi.waitFor(() => expect(harness.showError).toHaveBeenCalledWith("connection lost")); expect(harness.queueSelection.isBrowsing).toBe(false); expect(harness.editor.getText()).toBe("draft"); diff --git a/packages/coding-agent/test/main-interactive-routing.test.ts b/packages/coding-agent/test/main-interactive-routing.test.ts index 727662662f..6c1e4bf3e4 100644 --- a/packages/coding-agent/test/main-interactive-routing.test.ts +++ b/packages/coding-agent/test/main-interactive-routing.test.ts @@ -8,7 +8,6 @@ import { type AppMode, type DaemonInteractiveSessionManagerDecision, daemonServerDefaultSessionConfig, - findActiveDaemonSessionSummaryForInteractiveStartup, findActiveDaemonSessionSummaryForSessionFile, type InteractiveDaemonStartupDecision, isClientOwnedDaemonSession, @@ -232,48 +231,6 @@ describe("daemon-backed interactive session manager routing", () => { ).toBe(false); }); - test("falls back to local session lookup when daemon active-session probing fails", async () => { - await expect( - findActiveDaemonSessionSummaryForInteractiveStartup("/tmp/prime.sock", "saved-session-id", { - lookup: async () => { - throw new Error("Daemon returned an invalid active session summary"); - }, - }), - ).resolves.toBeUndefined(); - }); - - test("propagates active-session lookup failures for explicit attach", async () => { - await expect( - findActiveDaemonSessionSummaryForInteractiveStartup("/tmp/prime.sock", "active-1", { - fallbackOnError: false, - lookup: async () => { - throw new Error("protocol mismatch"); - }, - }), - ).rejects.toThrow("protocol mismatch"); - }); - - test("uses daemon active-session summary when probing succeeds", async () => { - await expect( - findActiveDaemonSessionSummaryForInteractiveStartup("/tmp/prime.sock", "active-1", { - lookup: async () => ({ - id: "active-1", - activeSessionId: "active-1", - lifecycle: "draft", - activity: "idle", - isSessionActive: false, - sessionId: "session-1", - cwd: "/tmp/project", - isStreaming: false, - isCompacting: false, - attachedClients: 0, - messageCount: 0, - sessionActions: { queuedCount: 0, steering: [], followUps: [] }, - }), - }), - ).resolves.toMatchObject({ activeSessionId: "active-1" }); - }); - test("uses an ephemeral local session manager for fresh daemon-owned sessions", () => { expect(shouldUseEphemeralSessionManagerForDaemonInteractive({})).toBe(true); }); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index bb56a730bd..4988f7ad48 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -1,9 +1,8 @@ import type { Model } from "@earendil-works/pi-ai"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { defaultModelPerProvider, findInitialModel, - parseModelPattern, resolveCliModel, resolveModelScopeFromModels, } from "../src/core/model-resolver.js"; @@ -90,163 +89,46 @@ describe("resolveModelScopeFromModels", () => { expect(result[1]?.model.provider).toBe("openai"); expect(result[1]?.model.id).toBe("gpt-4o"); }); -}); - -describe("parseModelPattern", () => { - describe("simple patterns without colons", () => { - test("exact match returns model with undefined thinking level", () => { - const result = parseModelPattern("claude-sonnet-4-5", allModels); - expect(result.model?.id).toBe("claude-sonnet-4-5"); - expect(result.thinkingLevel).toBeUndefined(); - expect(result.warning).toBeUndefined(); - }); - test("partial match returns best model with undefined thinking level", () => { - const result = parseModelPattern("sonnet", allModels); - expect(result.model?.id).toBe("claude-sonnet-4-5"); - expect(result.thinkingLevel).toBeUndefined(); - expect(result.warning).toBeUndefined(); - }); - - test("preserves provider-qualified selections when model names overlap", () => { - const primeInferenceModel: Model<"anthropic-messages"> = { - ...mockModels[0], - id: "z-ai/glm-5.2", - name: "GLM 5.2", - provider: "prime-inference", - baseUrl: "https://api.pinference.ai/api/v1", - }; - const huggingFaceModel: Model<"anthropic-messages"> = { - ...primeInferenceModel, - id: "zai-org/GLM-5.2", - provider: "huggingface", - baseUrl: "https://router.huggingface.co/v1", - }; - - const result = parseModelPattern("huggingface/zai-org/GLM-5.2", [primeInferenceModel, huggingFaceModel]); - - expect(result.model).toBe(huggingFaceModel); - }); + test("resolves a thinking level after a colon-bearing model id", () => { + const result = resolveModelScopeFromModels(["openrouter/qwen/qwen3-coder:exacto:high"], allModels); - test("no match returns undefined model and thinking level", () => { - const result = parseModelPattern("nonexistent", allModels); - expect(result.model).toBeUndefined(); - expect(result.thinkingLevel).toBeUndefined(); - expect(result.warning).toBeUndefined(); - }); + expect(result).toEqual([{ model: mockOpenRouterModels[0], thinkingLevel: "high" }]); }); - describe("patterns with valid thinking levels", () => { - test("sonnet:high returns sonnet with high thinking level", () => { - const result = parseModelPattern("sonnet:high", allModels); - expect(result.model?.id).toBe("claude-sonnet-4-5"); - expect(result.thinkingLevel).toBe("high"); - expect(result.warning).toBeUndefined(); - }); + test("keeps the model, warns, and drops an invalid thinking suffix", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const result = resolveModelScopeFromModels(["sonnet:random"], allModels); - test("gpt-4o:medium returns gpt-4o with medium thinking level", () => { - const result = parseModelPattern("gpt-4o:medium", allModels); - expect(result.model?.id).toBe("gpt-4o"); - expect(result.thinkingLevel).toBe("medium"); - expect(result.warning).toBeUndefined(); - }); - - test("all valid thinking levels work", () => { - for (const level of ["off", "minimal", "low", "medium", "high", "xhigh"]) { - const result = parseModelPattern(`sonnet:${level}`, allModels); - expect(result.model?.id).toBe("claude-sonnet-4-5"); - expect(result.thinkingLevel).toBe(level); - expect(result.warning).toBeUndefined(); - } - }); + expect(result).toEqual([{ model: mockModels[0], thinkingLevel: undefined }]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Invalid thinking level "random"')); + } finally { + warn.mockRestore(); + } }); - describe("patterns with invalid thinking levels", () => { - test("sonnet:random returns sonnet with undefined thinking level and warning", () => { - const result = parseModelPattern("sonnet:random", allModels); - expect(result.model?.id).toBe("claude-sonnet-4-5"); - expect(result.thinkingLevel).toBeUndefined(); - expect(result.warning).toContain("Invalid thinking level"); - expect(result.warning).toContain("random"); - }); - - test("gpt-4o:invalid returns gpt-4o with undefined thinking level and warning", () => { - const result = parseModelPattern("gpt-4o:invalid", allModels); - expect(result.model?.id).toBe("gpt-4o"); - expect(result.thinkingLevel).toBeUndefined(); - expect(result.warning).toContain("Invalid thinking level"); - }); - }); - - describe("OpenRouter models with colons in IDs", () => { - test("qwen3-coder:exacto matches the model with undefined thinking level", () => { - const result = parseModelPattern("qwen/qwen3-coder:exacto", allModels); - expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); - expect(result.thinkingLevel).toBeUndefined(); - expect(result.warning).toBeUndefined(); - }); - - test("openrouter/qwen/qwen3-coder:exacto matches with provider prefix", () => { - const result = parseModelPattern("openrouter/qwen/qwen3-coder:exacto", allModels); - expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); - expect(result.model?.provider).toBe("openrouter"); - expect(result.thinkingLevel).toBeUndefined(); - expect(result.warning).toBeUndefined(); - }); - - test("qwen3-coder:exacto:high matches model with high thinking level", () => { - const result = parseModelPattern("qwen/qwen3-coder:exacto:high", allModels); - expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); - expect(result.thinkingLevel).toBe("high"); - expect(result.warning).toBeUndefined(); - }); - - test("openrouter/qwen/qwen3-coder:exacto:high matches with provider and thinking level", () => { - const result = parseModelPattern("openrouter/qwen/qwen3-coder:exacto:high", allModels); - expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); - expect(result.model?.provider).toBe("openrouter"); - expect(result.thinkingLevel).toBe("high"); - expect(result.warning).toBeUndefined(); - }); - - test("gpt-4o:extended matches the extended model with undefined thinking level", () => { - const result = parseModelPattern("openai/gpt-4o:extended", allModels); - expect(result.model?.id).toBe("openai/gpt-4o:extended"); - expect(result.thinkingLevel).toBeUndefined(); - expect(result.warning).toBeUndefined(); - }); - }); - - describe("invalid thinking levels with OpenRouter models", () => { - test("qwen3-coder:exacto:random returns model with undefined thinking level and warning", () => { - const result = parseModelPattern("qwen/qwen3-coder:exacto:random", allModels); - expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); - expect(result.thinkingLevel).toBeUndefined(); - expect(result.warning).toContain("Invalid thinking level"); - expect(result.warning).toContain("random"); - }); - - test("qwen3-coder:exacto:high:random returns model with undefined thinking level and warning", () => { - const result = parseModelPattern("qwen/qwen3-coder:exacto:high:random", allModels); - expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); - expect(result.thinkingLevel).toBeUndefined(); - expect(result.warning).toContain("Invalid thinking level"); - expect(result.warning).toContain("random"); - }); - }); + test("preserves provider-qualified selections when model names overlap", () => { + const primeInferenceModel: Model<"anthropic-messages"> = { + ...mockModels[0]!, + id: "z-ai/glm-5.2", + name: "GLM 5.2", + provider: "prime-inference", + baseUrl: "https://api.pinference.ai/api/v1", + }; + const huggingFaceModel: Model<"anthropic-messages"> = { + ...primeInferenceModel, + id: "zai-org/GLM-5.2", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + }; - describe("edge cases", () => { - test("empty pattern matches via partial matching", () => { - const result = parseModelPattern("", allModels); - expect(result.model).not.toBeNull(); - expect(result.thinkingLevel).toBeUndefined(); - }); + const result = resolveModelScopeFromModels( + ["huggingface/zai-org/GLM-5.2"], + [primeInferenceModel, huggingFaceModel], + ); - test("pattern ending with colon treats empty suffix as invalid", () => { - const result = parseModelPattern("sonnet:", allModels); - expect(result.model?.id).toBe("claude-sonnet-4-5"); - expect(result.warning).toContain("Invalid thinking level"); - }); + expect(result).toEqual([{ model: huggingFaceModel, thinkingLevel: undefined }]); }); }); diff --git a/packages/coding-agent/test/queue-selection.test.ts b/packages/coding-agent/test/queue-selection.test.ts index c45d453bdd..68ebe27e61 100644 --- a/packages/coding-agent/test/queue-selection.test.ts +++ b/packages/coding-agent/test/queue-selection.test.ts @@ -26,28 +26,6 @@ describe("QueueSelection", () => { expect(selection.isBrowsing).toBe(false); }); - it("keeps, retargets, or drops the selection when the queue changes", () => { - const selection = new QueueSelection(); - selection.move(queue, "draft", -1); - selection.move(queue, "", -1); - selection.move(queue, "", -1); // s2 - expect(selection.sync({ steering: ["s1", "s2"], followUp: ["f2"] })).toBeUndefined(); - expect(selection.selected).toEqual({ lane: "steering", index: 1, text: "s2" }); - expect(selection.sync({ steering: ["s0", "s2"], followUp: [] })).toBeUndefined(); // retarget by text - expect(selection.selected).toEqual({ lane: "steering", index: 1, text: "s2" }); - expect(selection.sync({ steering: ["s0"], followUp: ["s2"] })).toBe("s2"); // same text, other lane: drop - expect(selection.isBrowsing).toBe(false); - }); - - it("keeps the stashed draft across an external selection drop", () => { - const selection = new QueueSelection(); - selection.move(queue, "my draft", -1); // editing f2 - selection.sync({ steering: [], followUp: [] }); // f2 delivered: selection dropped - expect(selection.isBrowsing).toBe(false); - selection.move({ steering: ["s9"], followUp: [] }, "f2 leftover text", -1); - expect(selection.reset()).toBe("my draft"); - }); - it("reset returns the stashed draft once", () => { const selection = new QueueSelection(); selection.move(queue, "my draft", -1); diff --git a/packages/coding-agent/test/rlm-ledger.test.ts b/packages/coding-agent/test/rlm-ledger.test.ts index 057f813877..7941b88e69 100644 --- a/packages/coding-agent/test/rlm-ledger.test.ts +++ b/packages/coding-agent/test/rlm-ledger.test.ts @@ -45,6 +45,7 @@ import { RLM_LEDGER_MAX_RECORDS, type RlmLedgerDeleteReason, RlmSpawnLedger, + readLegacyRlmSubagentRegistry, rlmLedgerPath, } from "../src/modes/daemon/rlm-ledger.js"; @@ -782,6 +783,40 @@ describe("rlm spawn ledger daemon wiring", () => { } }); + it("keeps a registry entry whose optional rlmMaxDepth is damaged, dropping only the field", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-rlm-ledger-damaged-maxdepth-")); + try { + const registryPath = join(tempDir, "rlm-subagents.jsonl"); + const entry = (childId: string, overrides: Record) => ({ + type: "rlm_subagent", + childId, + sessionName: `${childId}-worker`, + sessionDir: join(tempDir, childId), + sessionFile: join(tempDir, childId, "session.jsonl"), + status: "completed", + rlmDepth: 1, + rlmMaxDepth: 4, + createdAt: 1, + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }); + writeFileSync( + registryPath, + `${[ + JSON.stringify(entry("sub-11111111", {})), + JSON.stringify(entry("sub-22222222", { rlmMaxDepth: "corrupt" })), + ].join("\n")}\n`, + ); + + const entries = await readLegacyRlmSubagentRegistry(registryPath); + expect(entries.map((item) => item.childId).sort()).toEqual(["sub-11111111", "sub-22222222"]); + expect(entries.find((item) => item.childId === "sub-11111111")?.rlmMaxDepth).toBe(4); + expect(entries.find((item) => item.childId === "sub-22222222")?.rlmMaxDepth).toBeUndefined(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("leaves no ledger file behind an interrupted seed and re-seeds completely", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-rlm-ledger-seed-crash-")); try { diff --git a/packages/coding-agent/test/subagent-summary-line.test.ts b/packages/coding-agent/test/subagent-summary-line.test.ts index 38959d6eeb..71037d7a6b 100644 --- a/packages/coding-agent/test/subagent-summary-line.test.ts +++ b/packages/coding-agent/test/subagent-summary-line.test.ts @@ -133,7 +133,6 @@ describe("SubagentSummaryLine", () => { rlmNodeId: undefined, heartbeatCatalog: [], subagentSummaryLine: line, - updateScopedHeartbeats: vi.fn(), updateWorkingPulse: vi.fn(), syncWorkingLoader: vi.fn(), updateWorkingLoaderMessage: vi.fn(), @@ -159,7 +158,6 @@ describe("SubagentSummaryLine", () => { rlmNodeId: undefined, heartbeatCatalog: [], subagentSummaryLine: line, - updateScopedHeartbeats: vi.fn(), updateWorkingPulse: vi.fn(), syncWorkingLoader: vi.fn(), updateWorkingLoaderMessage: vi.fn(), @@ -188,7 +186,6 @@ describe("SubagentSummaryLine", () => { rlmNodeId: undefined, heartbeatCatalog: [], subagentSummaryLine: line, - updateScopedHeartbeats: vi.fn(), updateWorkingPulse: vi.fn(), syncWorkingLoader: vi.fn(), updateWorkingLoaderMessage: vi.fn(), @@ -220,7 +217,6 @@ describe("SubagentSummaryLine", () => { rlmNodeId: undefined, heartbeatCatalog: [], subagentSummaryLine: line, - updateScopedHeartbeats: vi.fn(), updateWorkingPulse: vi.fn(), syncWorkingLoader: vi.fn(), updateWorkingLoaderMessage: vi.fn(), diff --git a/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts b/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts index 22e97d3e91..2523173fef 100644 --- a/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts +++ b/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts @@ -521,4 +521,88 @@ describe("AgentSession bash and persistence characterization", () => { releaseExec?.(); await first; }); + + it("keeps an earlier bash abortable after a concurrent later bash finishes", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const abortableOperations: BashOperations = { + exec: async (_command, _cwd, options) => + await new Promise<{ exitCode: number | null }>((_resolve, reject) => { + options.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }), + }; + let releaseSecond: (() => void) | undefined; + const secondOperations: BashOperations = { + exec: async () => { + await new Promise((resolve) => { + releaseSecond = resolve; + }); + return { exitCode: 0 }; + }, + }; + + const first = harness.session.executeBash("first", undefined, { operations: abortableOperations }); + const second = harness.session.executeBash("second", undefined, { operations: secondOperations }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + releaseSecond?.(); + const secondResult = await second; + expect(secondResult.cancelled).toBe(false); + + // The earlier command stays running and abortable on its own controller. + expect(harness.session.isBashRunning).toBe(true); + harness.session.abortBash(); + const firstResult = await first; + expect(firstResult.cancelled).toBe(true); + expect(harness.session.isBashRunning).toBe(false); + }); + + it("cancels a pending user bash when abortBash arrives while another bash is in flight", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const operations: BashOperations = { + exec: async (_command, _cwd, options) => + await new Promise<{ exitCode: number | null }>((_resolve, reject) => { + options.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }), + }; + + // The user bash claims its slot first, then a concurrent wait-style + // command (unguarded executeBash path) occupies the controller set while + // the user bash is still in extension dispatch. + const run = harness.session.runUserBash("echo should-not-run"); + const waitBash = harness.session.executeBash("wait", undefined, { operations }); + harness.session.abortBash(); + await run; + expect((await waitBash).cancelled).toBe(true); + + const userBashMessage = harness.session.messages + .slice() + .reverse() + .find((message) => message.role === "bashExecution" && message.command === "echo should-not-run"); + expect(userBashMessage?.role).toBe("bashExecution"); + if (userBashMessage?.role === "bashExecution") { + expect(userBashMessage.cancelled).toBe(true); + expect(userBashMessage.output).toBe(""); + } + }); + + it("aborts every in-flight bash command with abortBash", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const makeAbortableOperations = (): BashOperations => ({ + exec: async (_command, _cwd, options) => + await new Promise<{ exitCode: number | null }>((_resolve, reject) => { + options.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }), + }); + + const first = harness.session.executeBash("first", undefined, { operations: makeAbortableOperations() }); + const second = harness.session.executeBash("second", undefined, { operations: makeAbortableOperations() }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + harness.session.abortBash(); + expect((await first).cancelled).toBe(true); + expect((await second).cancelled).toBe(true); + }); }); diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index 234179b9ec..f19ce5b304 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -4,6 +4,7 @@ import { type AssistantMessage, fauxAssistantMessage, type Model, type ToolResul import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SessionManager } from "../../src/core/session-manager.js"; import { createHarness, getMessageText, type Harness } from "./harness.js"; +import { createDeferred } from "./scheduling.js"; type SessionWithCompactionInternals = { _checkCompaction: ( @@ -267,6 +268,96 @@ describe("AgentSession compaction characterization", () => { } }); + it("waits for active manual compaction before continuing", async () => { + const compactionStarted = createDeferred(); + const compactionRelease = createDeferred(); + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => { + compactionStarted.resolve(); + await compactionRelease.promise; + return { + compaction: { + summary: "summary from extension", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: { source: "extension" }, + }, + }; + }); + }, + ], + }); + harnesses.push(harness); + const internals = harness.session as unknown as { + _schedulePostCompactionContinue(): void; + }; + harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]); + await harness.session.prompt("first"); + await harness.session.prompt("second"); + const pause = harness.session.acquireQueuedWorkPause(); + const continueAgent = vi.spyOn(harness.session.agent, "continue").mockResolvedValue(); + internals._schedulePostCompactionContinue(); + + const compaction = harness.session.compact(undefined, { skipAbort: true }); + await compactionStarted.promise; + pause.release(); + await new Promise(setImmediate); + expect(continueAgent).not.toHaveBeenCalled(); + + compactionRelease.resolve(); + await compaction; + await harness.session.waitForHeadlessIdle(); + expect(continueAgent).toHaveBeenCalledTimes(1); + }); + + it("waits for active auto-compaction before continuing", async () => { + const compactionStarted = createDeferred(); + const compactionRelease = createDeferred(); + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => { + compactionStarted.resolve(); + await compactionRelease.promise; + return { + compaction: { + summary: "summary from extension", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: { source: "extension" }, + }, + }; + }); + }, + ], + }); + harnesses.push(harness); + const internals = harness.session as unknown as SessionWithCompactionInternals & { + _schedulePostCompactionContinue(): void; + }; + harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]); + await harness.session.prompt("first"); + await harness.session.prompt("second"); + const pause = harness.session.acquireQueuedWorkPause(); + const continueAgent = vi.spyOn(harness.session.agent, "continue").mockResolvedValue(); + internals._schedulePostCompactionContinue(); + + const compaction = internals._runAutoCompaction("threshold", false); + await compactionStarted.promise; + pause.release(); + await new Promise(setImmediate); + expect(continueAgent).not.toHaveBeenCalled(); + + compactionRelease.resolve(); + await compaction; + await harness.session.waitForHeadlessIdle(); + expect(continueAgent).toHaveBeenCalledTimes(1); + }); + it("treats session-owned queued inputs as queued work after compaction", async () => { const harness = await createHarness({ settings: { compaction: { keepRecentTokens: 1 } }, @@ -310,6 +401,49 @@ describe("AgentSession compaction characterization", () => { } }); + it("releases the runner's suspended idle wait when the continuation is cancelled", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const session = harness.session; + const internals = session as unknown as { + _schedulePostCompactionContinue(): void; + _cancelPostCompactionContinue(): void; + _sessionInputCheckpointWaiters: Set<() => void>; + _sessionInputPumpSuspended: boolean; + }; + // A queued follow-up held back by a pause, then a pump suspension (the + // requestAbort teardown state): the queue stays populated but undispatchable. + const pause = session.acquireQueuedWorkPause(); + await session.followUp("queued across abort"); + expect(session.queuedActionCount).toBe(1); + session.requestAbort(); + pause.release(); + expect(internals._sessionInputPumpSuspended).toBe(true); + expect(session.queuedActionCount).toBe(1); + + // The runner passes its pre-dispatch guards (no pauses, agent idle) and + // parks in the session idle wait on a checkpoint waiter. + internals._schedulePostCompactionContinue(); + await vi.waitFor(() => { + expect(internals._sessionInputCheckpointWaiters.size).toBeGreaterThan(0); + }); + expect(session.hasPendingAdmissionWaiters).toBe(true); + + // Cancelling the continuation must release that waiter: a stuck waiter + // keeps hasPendingAdmissionWaiters true and blocks daemon passivation. + // The settle's own notify empties the set for a moment; a leaked runner + // re-parks within a microtask, so settle real time before asserting. + internals._cancelPostCompactionContinue(); + await new Promise((resolve) => setTimeout(resolve, 100)); + try { + expect(internals._sessionInputCheckpointWaiters.size).toBe(0); + expect(session.hasPendingAdmissionWaiters).toBe(false); + } finally { + session.clearQueue(); + session.resumeQueuedWork(); + } + }); + it("defers post-compaction refine behind a preparing session action", async () => { const preparationReached = vi.fn(); let releasePreparation = () => {}; @@ -875,12 +1009,19 @@ describe("AgentSession compaction characterization", () => { response: "continuation handled", tracked: true, }, - ])("does not continue again after the session pump handles $name", async ({ text, response, tracked }) => { + { + name: "an empty resume request", + text: "concurrent input", + response: "concurrent input handled", + tracked: false, + continueAfterSessionInput: true, + }, + ])("settles $name after the session pump runs", async ({ text, response, tracked, continueAfterSessionInput }) => { vi.useFakeTimers(); const harness = await createHarness(); harnesses.push(harness); const sessionInternals = harness.session as unknown as { - _schedulePostCompactionContinue(): void; + _schedulePostCompactionContinue(continueAfterSessionInput?: boolean): void; _postCompactionContinuationMessages: AgentMessage[]; _postCompactionContinuationScheduled: boolean; _createPreparedTurnAction( @@ -906,10 +1047,10 @@ describe("AgentSession compaction characterization", () => { ); const continueSpy = vi.spyOn(harness.session.agent, "continue"); - sessionInternals._schedulePostCompactionContinue(); + sessionInternals._schedulePostCompactionContinue(continueAfterSessionInput); await vi.advanceTimersByTimeAsync(200); - expect(continueSpy).not.toHaveBeenCalled(); + expect(continueSpy).toHaveBeenCalledTimes(continueAfterSessionInput ? 1 : 0); expect(sessionInternals._postCompactionContinuationScheduled).toBe(false); expect(sessionInternals._postCompactionContinuationMessages).toEqual([]); expect(harness.session.messages.at(-1)).toMatchObject({ @@ -919,7 +1060,6 @@ describe("AgentSession compaction characterization", () => { }); it("keeps autonomous threshold continuations when post-compaction continue must retry", async () => { - vi.useFakeTimers(); const harness = await createHarness({ autonomous: { enabled: true, @@ -933,6 +1073,7 @@ describe("AgentSession compaction characterization", () => { harnesses.push(harness); const sessionInternals = harness.session as unknown as { _schedulePostCompactionContinue(): void; + _cancelPostCompactionContinue(): void; _postCompactionContinuationMessages: AgentMessage[]; _postCompactionContinuationScheduled: boolean; }; @@ -945,16 +1086,82 @@ describe("AgentSession compaction characterization", () => { harness.session.agent.state.messages = [ { role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 }, ]; + const activeRunSettled = createDeferred(); const continueSpy = vi .spyOn(harness.session.agent, "continue") .mockRejectedValueOnce(new AgentContinueError("busy", "already processing")); + vi.spyOn(harness.session.agent, "waitForIdle").mockImplementation(() => + continueSpy.mock.calls.length === 0 ? Promise.resolve() : activeRunSettled.promise, + ); sessionInternals._schedulePostCompactionContinue(); - await vi.advanceTimersByTimeAsync(100); + await vi.waitFor(() => expect(continueSpy).toHaveBeenCalledTimes(1)); - expect(continueSpy).toHaveBeenCalledTimes(1); expect(sessionInternals._postCompactionContinuationMessages).toEqual([queuedMessage]); expect(sessionInternals._postCompactionContinuationScheduled).toBe(true); + sessionInternals._cancelPostCompactionContinue(); + activeRunSettled.resolve(); + }); + + it("keeps replacement continuation messages when a cancelled continue settles late", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const sessionInternals = harness.session as unknown as { + _schedulePostCompactionContinue(): void; + _cancelPostCompactionContinue(): void; + _postCompactionContinuationMessages: AgentMessage[]; + }; + const queuedMessage = { + role: "user", + content: [{ type: "text", text: "autonomous follow-up" }], + timestamp: Date.now(), + } satisfies AgentMessage; + sessionInternals._postCompactionContinuationMessages = [queuedMessage]; + const staleRun = createDeferred(); + const replacementRun = createDeferred(); + const continueSpy = vi + .spyOn(harness.session.agent, "continue") + .mockReturnValueOnce(staleRun.promise) + .mockReturnValueOnce(replacementRun.promise); + + sessionInternals._schedulePostCompactionContinue(); + await vi.waitFor(() => expect(continueSpy).toHaveBeenCalledTimes(1)); + sessionInternals._cancelPostCompactionContinue(); + sessionInternals._schedulePostCompactionContinue(); + await vi.waitFor(() => expect(continueSpy).toHaveBeenCalledTimes(2)); + + staleRun.resolve(); + await new Promise(setImmediate); + expect(sessionInternals._postCompactionContinuationMessages).toEqual([queuedMessage]); + + replacementRun.resolve(); + await harness.session.waitForHeadlessIdle(); + expect(sessionInternals._postCompactionContinuationMessages).toEqual([]); + }); + + it("waits for an in-flight refine application before continuing", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const sessionInternals = harness.session as unknown as { + _schedulePostCompactionContinue(): void; + _refineInFlight: Promise | undefined; + }; + const continueSpy = vi.spyOn(harness.session.agent, "continue").mockResolvedValue(); + const pause = harness.session.acquireQueuedWorkPause(); + sessionInternals._schedulePostCompactionContinue(); + await new Promise(setImmediate); + + // Refine enters its apply phase while the runner waits out the pause. + const refineApply = createDeferred(); + sessionInternals._refineInFlight = refineApply.promise; + pause.release(); + await new Promise(setImmediate); + expect(continueSpy).not.toHaveBeenCalled(); + + sessionInternals._refineInFlight = undefined; + refineApply.resolve(); + await harness.session.waitForHeadlessIdle(); + expect(continueSpy).toHaveBeenCalledTimes(1); }); it("clears queued autonomous threshold continuations when autonomous mode is disabled", async () => { diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 3c19d7cfe3..14e31404a1 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -39,7 +39,7 @@ type AutoRefineInternals = { _scheduleAutoRefine(reason: AutoRefineReason): void; _scheduleAutoRefineAfterCompaction(willContinueAfterCompaction: boolean): void; _scheduleAutoRefineAfterAgentEnd(): void; - _schedulePostCompactionContinue(): void; + _schedulePostCompactionContinue(continueAfterSessionInput?: boolean): void; _invalidatePendingAutoRefineForBranchChange(): Promise; _cancelPostCompactionContinue(): void; _assistantTurnsSinceAutoRefine: number; @@ -327,34 +327,71 @@ describe("AgentSession queue characterization", () => { } }); - it("retries a scheduled post-compaction continuation when another run starts first", async () => { - vi.useFakeTimers(); + it("waits for the active run to settle before retrying a post-compaction continuation", async () => { const harness = await createAutoRefineHarness({ settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, }); harnesses.push(harness); const internals = harness.session as unknown as AutoRefineInternals; + const activeRunSettled = createDeferred(); const continueAgent = vi .spyOn(harness.session.agent, "continue") .mockRejectedValueOnce( new AgentContinueError("busy", "Agent is already processing. Wait for completion before continuing."), ) .mockResolvedValueOnce(); + vi.spyOn(harness.session.agent, "waitForIdle").mockImplementation(() => + continueAgent.mock.calls.length === 0 ? Promise.resolve() : activeRunSettled.promise, + ); - try { - internals._schedulePostCompactionContinue(); - await vi.advanceTimersByTimeAsync(100); + internals._schedulePostCompactionContinue(); + await vi.waitFor(() => expect(continueAgent).toHaveBeenCalledTimes(1)); + expect(internals._postCompactionContinuationScheduled).toBe(true); - expect(continueAgent).toHaveBeenCalledTimes(1); - expect(internals._postCompactionContinuationScheduled).toBe(true); + activeRunSettled.resolve(); + await vi.waitFor(() => expect(continueAgent).toHaveBeenCalledTimes(2)); + expect(internals._postCompactionContinuationScheduled).toBe(false); + }); - await vi.advanceTimersByTimeAsync(100); + it("does not let a failed cancelled continuation reject its replacement", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + const cancelledRun = createDeferred(); + const replacementRun = createDeferred(); + const continueAgent = vi + .spyOn(harness.session.agent, "continue") + .mockReturnValueOnce(cancelledRun.promise) + .mockReturnValueOnce(replacementRun.promise); - expect(continueAgent).toHaveBeenCalledTimes(2); - expect(internals._postCompactionContinuationScheduled).toBe(false); - } finally { - vi.useRealTimers(); - } + internals._schedulePostCompactionContinue(); + await vi.waitFor(() => expect(continueAgent).toHaveBeenCalledTimes(1)); + internals._cancelPostCompactionContinue(); + internals._schedulePostCompactionContinue(); + await vi.waitFor(() => expect(continueAgent).toHaveBeenCalledTimes(2)); + const idle = harness.session.waitForHeadlessIdle(); + + cancelledRun.reject(new Error("cancelled continuation failed")); + await new Promise(setImmediate); + replacementRun.resolve(); + + await expect(idle).resolves.toBeUndefined(); + }); + + it("waits for a queued-work pause to release before post-compaction continuation", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const internals = harness.session as unknown as AutoRefineInternals; + const pause = harness.session.acquireQueuedWorkPause(); + const continueAgent = vi.spyOn(harness.session.agent, "continue").mockResolvedValue(); + + internals._schedulePostCompactionContinue(); + await new Promise(setImmediate); + expect(continueAgent).not.toHaveBeenCalled(); + + pause.release(); + await harness.session.waitForHeadlessIdle(); + expect(continueAgent).toHaveBeenCalledTimes(1); }); it("cancels scheduled post-compaction continuation on branch changes", async () => { @@ -407,24 +444,22 @@ describe("AgentSession queue characterization", () => { }); it("keeps scheduled post-compaction continuation when session-input pump compaction skips without aborting", async () => { - vi.useFakeTimers(); const harness = await createAutoRefineHarness({ settings: { autoRefine: { enabled: true, turnInterval: 25, cooldownMs: 0 } }, }); harnesses.push(harness); const internals = harness.session as unknown as AutoRefineInternals; - try { - internals._schedulePostCompactionContinue(); + const idle = createDeferred(); + vi.spyOn(harness.session.agent, "waitForIdle").mockReturnValue(idle.promise); + internals._schedulePostCompactionContinue(); - await expect(harness.session.compact(undefined, { skipAbort: true })).rejects.toThrow( - "Session is too short to compact", - ); + await expect(harness.session.compact(undefined, { skipAbort: true })).rejects.toThrow( + "Session is too short to compact", + ); - expect(internals._postCompactionContinuationScheduled).toBe(true); - } finally { - internals._cancelPostCompactionContinue(); - vi.useRealTimers(); - } + expect(internals._postCompactionContinuationScheduled).toBe(true); + internals._cancelPostCompactionContinue(); + idle.resolve(); }); it("auto-refine pending review uses the in-progress guard and catches refine failures", async () => { diff --git a/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts b/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts index bf8de05144..e10da95722 100644 --- a/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts +++ b/packages/coding-agent/test/suite/regressions/4509-side-questions.test.ts @@ -872,6 +872,7 @@ describe("ENG-4509 side questions", () => { isAgentCompacting: () => false, isBashRunning: () => true, applyConnectionStateSnapshot: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ messages: [], @@ -880,7 +881,7 @@ describe("ENG-4509 side questions", () => { })), renderSessionContext: vi.fn(async () => {}), restoreStreamingMessageFromSnapshot: vi.fn(async () => {}), - refreshConnectionQueue: vi.fn(async () => {}), + updatePendingMessagesDisplay: vi.fn(), flushCompactionQueue: vi.fn(async () => {}), flushPendingBashComponents: vi.fn(), updateTerminalTitle: vi.fn(), @@ -906,6 +907,7 @@ describe("ENG-4509 side questions", () => { messages: [], }); + expect(fakeThis.updatePendingMessagesDisplay).toHaveBeenCalledOnce(); expect(bashComponent.setComplete).toHaveBeenCalledWith(undefined, false); expect(finishBash).toHaveBeenCalledOnce(); expect(fakeThis.activeBashComponent).toBeUndefined(); diff --git a/packages/coding-agent/test/suite/regressions/4741-hint-placement.test.ts b/packages/coding-agent/test/suite/regressions/4741-hint-placement.test.ts index ccbe74f109..6d19c16a66 100644 --- a/packages/coding-agent/test/suite/regressions/4741-hint-placement.test.ts +++ b/packages/coding-agent/test/suite/regressions/4741-hint-placement.test.ts @@ -26,11 +26,13 @@ function createFeatureHintMode() { pendingMessagesContainer: new Container(), pendingBashComponents: [], queuedMessagesContainer: new Container(), - connectionQueue: { steering: [] as string[], followUp: [] as string[] }, compactionQueuedMessages: [], loadingAnimation: loader, workingVisible: true, - connectionState: { isStreaming: true }, + connectionState: { + isStreaming: true, + sessionActions: { queuedCount: 0, steering: [] as string[], followUps: [] as string[] }, + }, featureHintDeck: { next: vi.fn(() => ({ id: "test", text: "A useful feature hint." })) }, currentFeatureHint: undefined, featureHintEligibleAt: 0, @@ -148,11 +150,11 @@ describe("ENG-4741 hint placement", () => { vi.advanceTimersByTime(5_000); expect(featureHintContainer.children).toHaveLength(1); - mode.connectionQueue.followUp = ["Continue after this turn"]; + mode.connectionState.sessionActions.followUps = ["Continue after this turn"]; callPrivate(mode, "updatePendingMessagesDisplay"); expect(featureHintContainer.children).toHaveLength(0); - mode.connectionQueue.followUp = []; + mode.connectionState.sessionActions.followUps = []; callPrivate(mode, "updatePendingMessagesDisplay"); expect(featureHintContainer.children).toHaveLength(1); diff --git a/packages/coding-agent/test/telemetry.test.ts b/packages/coding-agent/test/telemetry.test.ts index 12b4e17c50..49c2ba4263 100644 --- a/packages/coding-agent/test/telemetry.test.ts +++ b/packages/coding-agent/test/telemetry.test.ts @@ -2,7 +2,7 @@ import { lstatSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileS import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AssistantMessage } from "@earendil-works/pi-ai"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentSession, AgentSessionEvent } from "../src/core/agent-session.js"; import { SettingsManager } from "../src/core/settings-manager.js"; import { @@ -236,7 +236,7 @@ describe("telemetry controls", () => { it("honors settings and environment opt-outs", () => { const settings = SettingsManager.inMemory({ telemetry: { enabled: true } }); - vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("DO_NOT_TRACK", "0"); expect(isTelemetryEnabled(settings)).toBe(true); vi.stubEnv("DO_NOT_TRACK", "1"); @@ -251,14 +251,6 @@ describe("telemetry controls", () => { expect(isTelemetryEnabled(settings)).toBe(false); }); - it("is disabled by default in tests unless explicitly enabled", () => { - const settings = SettingsManager.inMemory({ telemetry: { enabled: true } }); - vi.stubEnv("NODE_ENV", "test"); - expect(isTelemetryEnabled(settings)).toBe(false); - vi.stubEnv("PRIME_AGENT_TELEMETRY", "1"); - expect(isTelemetryEnabled(settings)).toBe(true); - }); - it("normalizes malformed telemetry settings before updating them", async () => { const settings = SettingsManager.inMemory({ telemetry: true as never }); const disabledSettings = SettingsManager.inMemory({ telemetry: false as never }); @@ -276,6 +268,10 @@ describe("telemetry controls", () => { }); describe("agent telemetry aggregation", () => { + beforeEach(() => { + vi.stubEnv("DO_NOT_TRACK", "0"); + }); + it("captures only allowlisted built-in command names", async () => { vi.stubEnv("PRIME_AGENT_TELEMETRY", "1"); const sink = new FakeTelemetrySink(); diff --git a/packages/coding-agent/vitest.config.ts b/packages/coding-agent/vitest.config.ts index 04ec6693a4..320ff2a983 100644 --- a/packages/coding-agent/vitest.config.ts +++ b/packages/coding-agent/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ globals: true, environment: "node", testTimeout: 30000, + env: { DO_NOT_TRACK: "1" }, tags: [ { name: "process-stress",