diff --git a/packages/ai/.changes/remove-overflow-pattern.md b/packages/ai/.changes/remove-overflow-pattern.md new file mode 100644 index 0000000000..3b412e4d0a --- /dev/null +++ b/packages/ai/.changes/remove-overflow-pattern.md @@ -0,0 +1 @@ +- Removed the unused overflow pattern helper from `utils/overflow.ts`. 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/kernel-snapshot-dispose-timeout.md b/packages/coding-agent/.changes/kernel-snapshot-dispose-timeout.md new file mode 100644 index 0000000000..e063919711 --- /dev/null +++ b/packages/coding-agent/.changes/kernel-snapshot-dispose-timeout.md @@ -0,0 +1 @@ +- Fixed graceful Python kernel disposal so timed-out final snapshots are cancelled before teardown. 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/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-config-cache-reset.md b/packages/coding-agent/.changes/remove-config-cache-reset.md new file mode 100644 index 0000000000..8150a69571 --- /dev/null +++ b/packages/coding-agent/.changes/remove-config-cache-reset.md @@ -0,0 +1 @@ +- Removed internal test-only configuration cache reset hooks. 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-overflow-patterns-export.md b/packages/coding-agent/.changes/remove-overflow-patterns-export.md new file mode 100644 index 0000000000..95a2b5226e --- /dev/null +++ b/packages/coding-agent/.changes/remove-overflow-patterns-export.md @@ -0,0 +1 @@ +- Removed the unused `getOverflowPatterns` public API. diff --git a/packages/coding-agent/.changes/remove-rpc-blanket-timeouts.md b/packages/coding-agent/.changes/remove-rpc-blanket-timeouts.md new file mode 100644 index 0000000000..b9e15b8d33 --- /dev/null +++ b/packages/coding-agent/.changes/remove-rpc-blanket-timeouts.md @@ -0,0 +1 @@ +- Allowed long-running RPC commands and agent turns to complete without fixed client timeouts. 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-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/session-path-predicate.md b/packages/coding-agent/.changes/session-path-predicate.md new file mode 100644 index 0000000000..d6393936b2 --- /dev/null +++ b/packages/coding-agent/.changes/session-path-predicate.md @@ -0,0 +1 @@ +- Made session path detection consistent across direct and daemon commands. 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/supervised-rename-authority.md b/packages/coding-agent/.changes/supervised-rename-authority.md new file mode 100644 index 0000000000..b56a24e5e5 --- /dev/null +++ b/packages/coding-agent/.changes/supervised-rename-authority.md @@ -0,0 +1 @@ +- Fixed supervised session renames failing after the supervisor approved an available name. diff --git a/packages/coding-agent/src/cli/daemon-command.ts b/packages/coding-agent/src/cli/daemon-command.ts index 2deb4080c6..4fd655ef95 100644 --- a/packages/coding-agent/src/cli/daemon-command.ts +++ b/packages/coding-agent/src/cli/daemon-command.ts @@ -8,6 +8,7 @@ import { expandTildePath } from "../config.js"; import type { AgentSessionEvent } from "../core/agent-session.js"; import type { AgentSessionRuntimeConfig } from "../core/agent-session-config.js"; import { type AgentCronJob, formatAgentCronJob } from "../core/cron-jobs.js"; +import { looksLikeSessionPath } from "../core/session-resolver.js"; import { DaemonClient, type DaemonClientMessageListener } from "../modes/daemon/daemon-client.js"; import type { DaemonOutbound, DaemonResponse } from "../modes/daemon/daemon-protocol.js"; import { matchesSessionIdSuffix } from "../modes/daemon/daemon-session-id.js"; @@ -621,10 +622,6 @@ function parseExtensionFlagOption( return { consumed: 0, daemonArg: arg }; } -function looksLikeSessionPath(value: string): boolean { - return value.includes("/") || value.includes("\\") || value.endsWith(".jsonl"); -} - function requireOptionValue(args: string[], index: number, option: string): string { const value = args[index + 1]; if (!value) { diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 9b0f660219..c9bd4c2d77 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -830,11 +830,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 { @@ -882,6 +883,10 @@ interface RlmChildRun { sessionDir: string; model: Model; status: RlmChildAgentStatus; + durationMs?: number; + answerPreview?: string; + toolUseCount: number; + activity?: RlmChildAgentActivity; error?: string; abort: () => void; publication: AgentMessageDeferred; @@ -911,6 +916,11 @@ interface RlmChildRun { unsubscribe?: () => void; } +interface RetainedRlmChild { + session: AgentSession; + run?: RlmChildRun; +} + interface RlmSubagentModelSelection { model: Model; } @@ -1163,7 +1173,7 @@ export class AgentSession { private _pendingRlmSubagentSessionNames = new Set(); // Inline mode keeps finished child sessions so the inspector can still read them; // the daemon does the same by leaving the child session resident in its registry. - private _rlmChildSessions = new Map(); + private _rlmChildSessions = new Map(); private _deletedRlmChildIds = new Set(); // Failed explicit deletes stay hidden from listings but retain their original // selector so a later delete can retry cleanup without orphaning the runtime. @@ -1198,7 +1208,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[] = []; @@ -4040,7 +4049,7 @@ export class AgentSession { unsubscribe(); } this._rlmChildUnsubscribes.clear(); - for (const session of this._rlmChildSessions.values()) { + for (const { session } of this._rlmChildSessions.values()) { await session.disposeAsync().catch(() => undefined); } this._rlmChildSessions.clear(); @@ -4102,7 +4111,7 @@ export class AgentSession { unsubscribe(); } this._rlmChildUnsubscribes.clear(); - for (const session of this._rlmChildSessions.values()) { + for (const { session } of this._rlmChildSessions.values()) { session.dispose(); } this._rlmChildSessions.clear(); @@ -7311,6 +7320,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; @@ -7379,7 +7389,7 @@ export class AgentSession { 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. @@ -7498,7 +7508,7 @@ 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; @@ -7508,10 +7518,6 @@ export class AgentSession { } private _cancelPostCompactionContinue(): void { - if (this._postCompactionContinuationTimer) { - clearTimeout(this._postCompactionContinuationTimer); - this._postCompactionContinuationTimer = undefined; - } this._postCompactionContinuationScheduled = false; this._scheduledPostCompactionContinuationMessages = []; this._settlePostCompactionContinue(); @@ -7607,75 +7613,124 @@ 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) { + 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.waitForIdle(); + 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; + 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; } } } @@ -8489,7 +8544,7 @@ export class AgentSession { (reason === "requested" || reason === "threshold") && (shouldContinueAfterCompaction || this.agent.hasQueuedMessages() || this.hasPendingSessionWork) ) { - this._schedulePostCompactionContinue(); + this._schedulePostCompactionContinue(shouldContinueAfterCompaction); } }; @@ -8541,13 +8596,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); @@ -9505,7 +9560,7 @@ export class AgentSession { }); recorded.add(run.id); } - for (const [childId, childSession] of this._rlmChildSessions) { + for (const [childId, { session: childSession }] of this._rlmChildSessions) { if ( this._deletingRlmChildren.has(childId) || recorded.has(childId) || @@ -9593,7 +9648,7 @@ export class AgentSession { return result; } } - for (const retained of this._rlmChildSessions.values()) { + for (const { session: retained } of this._rlmChildSessions.values()) { const result = await retained.deleteInactiveRlmSubagent(childId, isExternallyRunning); if (result !== "not_found") { return result; @@ -9870,7 +9925,7 @@ export class AgentSession { } this._emitRlmSubagentRemoval(subagent); - const retained = this._rlmChildSessions.get(childId); + const retained = this._rlmChildSessions.get(childId)?.session; try { await this._deleteRlmSubagentSession(childId, retained); } catch (error) { @@ -9906,7 +9961,7 @@ export class AgentSession { void session.disposeAsync().catch(() => undefined); return false; } - this._rlmChildSessions.set(childId, session); + this._rlmChildSessions.set(childId, { session, run: this._activeRlmChildRuns.get(childId) }); if (unsubscribe) { this._rlmChildUnsubscribes.set(childId, unsubscribe); } @@ -9917,15 +9972,72 @@ export class AgentSession { const run = this._activeRlmChildRuns.get(childId); if (run?.session === session && run.status === "done") { const unsubscribe = run.unsubscribe ?? noopRlmChildEventUnsubscribe; - run.unsubscribe = undefined; - this._activeRlmChildRuns.delete(childId); - return unsubscribe; + return () => { + run.unsubscribe = undefined; + this._activeRlmChildRuns.delete(childId); + unsubscribe(); + }; } - if (this._rlmChildSessions.get(childId) !== session) return false; + if (this._rlmChildSessions.get(childId)?.session !== session) return false; const unsubscribe = this._rlmChildUnsubscribes.get(childId) ?? noopRlmChildEventUnsubscribe; - this._rlmChildUnsubscribes.delete(childId); - this._rlmChildSessions.delete(childId); - return unsubscribe; + return () => { + this._rlmChildUnsubscribes.delete(childId); + this._rlmChildSessions.delete(childId); + unsubscribe(); + }; + } + + private _rlmChildSnapshotForRun( + run: RlmChildRun, + child = run.session ?? this._rlmChildSessions.get(run.id)?.session, + ): RlmChildAgentSnapshot { + const model = child?.model ?? run.model; + return { + id: run.id, + parentId: this._rlmParentNodeId, + sessionName: child?.sessionName ?? run.sessionName, + model: `${model.provider}/${model.id}`, + label: rlmChildLabel(run.prompt), + status: run.status, + durationMs: run.durationMs, + answerPreview: run.answerPreview, + toolUseCount: run.toolUseCount > 0 ? run.toolUseCount : undefined, + tokenCount: child?._contextTokensForCurrentMessages(), + recap: child?.getCurrentRecap(), + sessionDir: run.sessionDir, + activity: run.activity, + repliedSinceTask: child?._repliedToParentSinceTask, + error: run.error, + }; + } + + private _rlmChildSnapshotForSession(childId: string, child: AgentSession): RlmChildAgentSnapshot { + let answerPreview: string | undefined; + let toolUseCount = 0; + const messages = + child.state.streamingMessage?.role === "assistant" + ? [...child.messages, child.state.streamingMessage] + : child.messages; + for (const message of messages) { + if (message.role !== "assistant") continue; + const text = compactRlmText(readAssistantText(message)); + if (text) answerPreview = text; + toolUseCount += message.content.filter((block) => block.type === "toolCall").length; + } + return { + id: childId, + parentId: this._rlmParentNodeId, + sessionName: child.sessionName, + model: child.model ? `${child.model.provider}/${child.model.id}` : undefined, + label: child.sessionName ?? "child agent", + status: "done", + answerPreview, + toolUseCount: toolUseCount > 0 ? toolUseCount : undefined, + tokenCount: child._contextTokensForCurrentMessages(), + recap: child.getCurrentRecap(), + sessionDir: child._rlmSessionDir ?? child.sessionManager.getSessionDir(), + repliedSinceTask: child._repliedToParentSinceTask, + }; } /** Live recursive child roster from lifecycle state, including nested work under retained parents. */ @@ -9938,16 +10050,7 @@ export class AgentSession { run.detachedDeletion || this._deletingRlmChildren.has(run.id) || this._deletedRlmChildIds.has(run.id); const child = run.session; if (!hidden) { - const model = child?.model ?? run.model; - snapshots.push({ - id: run.id, - parentId: this._rlmParentNodeId, - sessionName: child?.sessionName ?? run.sessionName, - model: `${model.provider}/${model.id}`, - label: rlmChildLabel(run.prompt), - status: run.status, - sessionDir: run.sessionDir, - }); + snapshots.push(this._rlmChildSnapshotForRun(run)); recorded.add(run.id); } if (child) { @@ -9955,20 +10058,16 @@ export class AgentSession { snapshots.push(...child.getRlmChildSnapshots()); } } - for (const [childId, child] of this._rlmChildSessions) { + for (const [childId, { session: child, run }] of this._rlmChildSessions) { if (recorded.has(childId) || traversed.has(childId)) continue; const hidden = this._deletingRlmChildren.has(childId) || this._deletedRlmChildIds.has(childId); if (!hidden) { + const snapshot = run + ? this._rlmChildSnapshotForRun(run, child) + : this._rlmChildSnapshotForSession(childId, child); snapshots.push({ - id: childId, - parentId: this._rlmParentNodeId, - sessionName: child.sessionName, - model: child.model ? `${child.model.provider}/${child.model.id}` : undefined, - label: child.sessionName ?? "child agent", - // A failed delete retains the session solely for cleanup retry. Preserve - // its cancellation truth in snapshots rather than reviving it as done. - status: this._rlmChildCleanupFailures.has(childId) ? "cancelled" : "done", - sessionDir: child._rlmSessionDir ?? child.sessionManager.getSessionDir(), + ...snapshot, + status: this._rlmChildCleanupFailures.has(childId) ? "cancelled" : snapshot.status, }); } snapshots.push(...child.getRlmChildSnapshots()); @@ -9987,7 +10086,7 @@ export class AgentSession { } } // A finished direct child can still have a running nested subagent. - for (const session of this._rlmChildSessions.values()) { + for (const { session } of this._rlmChildSessions.values()) { if (session.hasRunningRlmChildren()) { return true; } @@ -9997,7 +10096,7 @@ export class AgentSession { private _rlmChildSessionSnapshot(): AgentSession[] { const sessions = new Set(); - for (const [childId, session] of this._rlmChildSessions) { + for (const [childId, { session }] of this._rlmChildSessions) { if (!this._abandonedRlmQuiescenceChildIds.has(childId)) sessions.add(session); } for (const run of this._activeRlmChildRuns.values()) { @@ -10068,7 +10167,7 @@ export class AgentSession { // Inline (non-daemon) mode only; daemon clients attach to the child session directly. getRlmChildSession(childId: string): AgentSession | undefined { - const direct = this._activeRlmChildRuns.get(childId)?.session ?? this._rlmChildSessions.get(childId); + const direct = this._activeRlmChildRuns.get(childId)?.session ?? this._rlmChildSessions.get(childId)?.session; if (direct) { return direct; } @@ -10078,7 +10177,7 @@ export class AgentSession { return nested; } } - for (const retained of this._rlmChildSessions.values()) { + for (const { session: retained } of this._rlmChildSessions.values()) { const nested = retained.getRlmChildSession(childId); if (nested) { return nested; @@ -10108,7 +10207,7 @@ export class AgentSession { return true; } } - for (const retained of this._rlmChildSessions.values()) { + for (const { session: retained } of this._rlmChildSessions.values()) { if (retained.cancelRlmChildRun(childId, reason)) { return true; } @@ -10125,7 +10224,7 @@ export class AgentSession { [...this._activeRlmChildRuns.values()].some( (run) => run.session?.sessionName === name || (!run.session && run.sessionName === name), ) || - [...this._rlmChildSessions.values()].some((session) => session.sessionName === name) || + [...this._rlmChildSessions.values()].some(({ session }) => session.sessionName === name) || [...this._rlmChildCleanupFailures.values()].some((entry) => entry.session_name === name); if (localConflict) { throw new Error(formatAgentSessionNameUnavailable(name, depth)); @@ -10245,12 +10344,7 @@ export class AgentSession { if (!requestedSessionName) await this._assertRlmSubagentSessionNameAvailable(sessionName); const startedAt = Date.now(); const parentAssistantForUsage = this._findLastAssistantMessage(); - const label = rlmChildLabel(prompt); - let answerPreview: string | undefined; - let durationMs: number | undefined; - let toolUseCount = 0; let runningToolCount = 0; - let activity: RlmChildAgentActivity | undefined; let childSession: AgentSession | undefined; const run: RlmChildRun = { id: childNodeId, @@ -10259,6 +10353,7 @@ export class AgentSession { sessionDir: childSessionDir, model: modelSelection.model, status: "queued", + toolUseCount: 0, settled: false, abort: noopRlmChildAbort, publication: createAgentMessageDeferred(), @@ -10271,27 +10366,7 @@ export class AgentSession { this._activeRlmChildRuns.set(run.id, run); this._unsettledRlmChildRuns.add(run); const emitChildUpdate = () => { - const childModel = childSession?.model ?? modelSelection.model; - this._emit({ - type: "rlm_child_update", - child: { - id: childNodeId, - parentId: this._rlmParentNodeId, - sessionName: childSession?.sessionName ?? sessionName, - model: `${childModel.provider}/${childModel.id}`, - label, - status: run.status, - durationMs, - answerPreview, - toolUseCount: toolUseCount > 0 ? toolUseCount : undefined, - tokenCount: childSession?._contextTokensForCurrentMessages(), - recap: childSession?.getCurrentRecap(), - sessionDir: childSessionDir, - activity, - repliedSinceTask: childSession?._repliedToParentSinceTask, - error: run.error, - }, - }); + this._emit({ type: "rlm_child_update", child: this._rlmChildSnapshotForRun(run) }); }; run.emitUpdate = emitChildUpdate; emitChildUpdate(); @@ -10374,10 +10449,10 @@ export class AgentSession { return; } if (event.type === "agent_start") { - activity = { kind: "waiting" }; + run.activity = { kind: "waiting" }; emitChildUpdate(); } else if (event.type === "agent_end") { - activity = undefined; + run.activity = undefined; emitChildUpdate(); } else if (event.type === "message_end" && event.message.role === "assistant") { const assistant = event.message as AssistantMessage; @@ -10408,24 +10483,24 @@ export class AgentSession { } } const text = compactRlmText(readAssistantText(assistant)); - if (text) answerPreview = text; + if (text) run.answerPreview = text; void flushAgentTraceUpload(child.sessionManager).catch(() => undefined); emitChildUpdate(); } else if (event.type === "message_start" || event.type === "message_update") { if (event.message.role === "assistant") { const text = compactRlmText(readAssistantText(event.message as AssistantMessage)); - if (text) answerPreview = text; - activity = { kind: "writing" }; + if (text) run.answerPreview = text; + run.activity = { kind: "writing" }; emitChildUpdate(); } } else if (event.type === "tool_execution_start") { - toolUseCount += 1; + run.toolUseCount += 1; runningToolCount += 1; - activity = { kind: "executing", toolName: event.toolName }; + run.activity = { kind: "executing", toolName: event.toolName }; emitChildUpdate(); } else if (event.type === "tool_execution_end") { runningToolCount = Math.max(0, runningToolCount - 1); - if (runningToolCount === 0) activity = { kind: "waiting" }; + if (runningToolCount === 0) run.activity = { kind: "waiting" }; emitChildUpdate(); } else if (event.type === "session_info_changed" || event.type === "recap_update") { emitChildUpdate(); @@ -10460,8 +10535,8 @@ export class AgentSession { await child.waitForRlmQuiescence(); if (run.error) throw new Error(run.error); run.status = "done"; - durationMs = Date.now() - startedAt; - activity = undefined; + run.durationMs = Date.now() - startedAt; + run.activity = undefined; emitChildUpdate(); if ( !run.detachedDeletion && @@ -10494,8 +10569,8 @@ export class AgentSession { run.status = "error"; run.error = runError.message; } - durationMs = Date.now() - startedAt; - activity = undefined; + run.durationMs = Date.now() - startedAt; + run.activity = undefined; emitChildUpdate(); if (!run.detachedDeletion && !run.suppressTerminalNotice) { if (run.status === "error") { diff --git a/packages/coding-agent/src/core/kernel/repl-manager.ts b/packages/coding-agent/src/core/kernel/repl-manager.ts index 28c39c3f0d..631d8226a3 100644 --- a/packages/coding-agent/src/core/kernel/repl-manager.ts +++ b/packages/coding-agent/src/core/kernel/repl-manager.ts @@ -37,7 +37,6 @@ import { parseDiffDisplay, parseSentAgentMessage, raceStartupWithAbort, - SNAPSHOT_DISPOSE_TIMEOUT_MS, SNAPSHOT_EXECUTION_TIMEOUT_MS, } from "./shared.js"; import { @@ -1100,19 +1099,21 @@ export class ReplKernelManager { } } - /** Best-effort final snapshot before a graceful dispose, bounded by a timeout. */ private async flushSnapshotForDispose(): Promise { if (!this.options.snapshot || !this.isRunning) return; + const pendingExecutions = this.executionQueue; + if (this.activeExecution) void this.interrupt().catch(() => undefined); let timeout: ReturnType | undefined; - const guard = new Promise((resolve) => { - timeout = globalThis.setTimeout(resolve, SNAPSHOT_DISPOSE_TIMEOUT_MS); - if (timeout && typeof timeout === "object" && "unref" in timeout) timeout.unref(); - }); - try { - await Promise.race([this.snapshotState().then(() => undefined), guard]); - } finally { - if (timeout) clearTimeout(timeout); - } + const queueSettled = await Promise.race([ + pendingExecutions.then(() => true), + new Promise((resolve) => { + timeout = globalThis.setTimeout(() => resolve(false), SNAPSHOT_EXECUTION_TIMEOUT_MS); + timeout.unref?.(); + }), + ]); + if (timeout) globalThis.clearTimeout(timeout); + if (!queueSettled) return; + await this.captureSnapshot({ executionTimeoutMs: SNAPSHOT_EXECUTION_TIMEOUT_MS }); } /** Graceful cleanup. Waits briefly for in-flight host request handlers before killing the child. */ diff --git a/packages/coding-agent/src/core/kernel/shared.ts b/packages/coding-agent/src/core/kernel/shared.ts index bdaec97126..e066d680c7 100644 --- a/packages/coding-agent/src/core/kernel/shared.ts +++ b/packages/coding-agent/src/core/kernel/shared.ts @@ -6,9 +6,6 @@ export const DEFAULT_MAX_OUTPUT_CHARS = 65536; export const HOST_REQUEST_DISPOSE_TIMEOUT_MS = 5000; export const KERNEL_SHUTDOWN_TIMEOUT_MS = 5000; export const DEFAULT_SNAPSHOT_DEBOUNCE_MS = 1500; -// Cap how long a graceful dispose waits on the final snapshot; the debounced -// on-disk copy is the fallback if this is exceeded. -export const SNAPSHOT_DISPOSE_TIMEOUT_MS = 5000; export const SNAPSHOT_EXECUTION_TIMEOUT_MS = 5000; export const KERNEL_ABORT_GRACE_MS = 1000; export const KERNEL_BUSY_REUSE_WAIT_MS = 5000; diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 0bd646d229..4ebf6c3b8d 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -37,7 +37,6 @@ import { } from "./prime-inference-models.js"; import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.js"; import { - clearConfigValueCache, resolveConfigValueOrThrow, resolveConfigValueUncached, resolveHeadersOrThrow, @@ -349,9 +348,6 @@ function applyModelOverride(model: Model, override: ModelOverride): Model 0 ? resolved : undefined; } - -/** Clear the config value command cache. Exported for testing. */ -export function clearConfigValueCache(): void { - commandResultCache.clear(); -} 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/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 0bda7e5ea5..f698d3939d 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 { type Api, getLogger, type Model } from "@earendil-works/pi-ai"; @@ -195,9 +195,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, @@ -405,17 +407,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; @@ -966,50 +957,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), + }); } /** @@ -2803,12 +2758,9 @@ export class AgentDaemon { try { await this.closeSession(state, "shutdown", true, false); } catch (error) { - // A pre-removal close failure must not strand a resident child outside its - // parent's ownership map or disconnect its event forwarder. if ( this.sessions.get(state.activeSessionId) === state && - this.sessions.get(parentActiveSessionId) === parentState && - parentState.runtime.session.registerRlmChildSession(childId, state.runtime.session, unsubscribeChild) + this.sessions.get(parentActiveSessionId) === parentState ) { throw error; } @@ -3988,7 +3940,7 @@ export class AgentDaemon { if (!name) { throw new Error("Session name cannot be empty"); } - await this.setStateSessionName(state, name); + await this.setStateSessionNameForCommand(state, name); return success(command.id, "rename", summaryForActiveSession(state)); } @@ -4002,7 +3954,7 @@ export class AgentDaemon { throw new Error("Session name cannot be empty"); } if (state) { - await this.setStateSessionName(state, name); + await this.setStateSessionNameForCommand(state, name); } else { const info = await readSessionInfo(command.sessionPath); if (!info) throw new Error(`Session not found: ${command.sessionPath}`); @@ -4898,7 +4850,7 @@ export class AgentDaemon { if (!name) { throw new Error("Session name cannot be empty"); } - await this.setStateSessionName(state, name); + await this.setStateSessionNameForCommand(state, name); return success(command.id, "set_session_name"); } @@ -5572,6 +5524,15 @@ export class AgentDaemon { } } + private setStateSessionNameForCommand(state: ActiveSessionState, name: string): Promise { + return this.options.worker ? this.applyStateSessionName(state, name) : this.setStateSessionName(state, name); + } + + private async applyStateSessionName(state: ActiveSessionState, name: string): Promise { + state.runtime.session.setSessionName(name); + await this.appendRlmLedgerRenameForState(state, name); + } + private async setStateSessionName(state: ActiveSessionState, name: string): Promise { const normalizedName = name.trim(); if (!normalizedName) { @@ -5594,8 +5555,7 @@ export class AgentDaemon { }, async () => { await this.assertStateSessionNameAvailable(state, normalizedName); - state.runtime.session.setSessionName(normalizedName); - await this.appendRlmLedgerRenameForState(state, normalizedName); + await this.applyStateSessionName(state, normalizedName); }, ); } @@ -5905,42 +5865,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( diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts index 49b5a16dc7..d41e83a67a 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -2,8 +2,7 @@ import { statSync } from "node:fs"; import { resolve } from "node:path"; import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Api, Model } from "@earendil-works/pi-ai"; -import { compactRlmText, rlmChildLabel } from "../../core/agent-session.js"; -import type { AgentSessionRuntimeMetadata } from "../../core/agent-session-runtime.js"; +import { compactRlmText } from "../../core/agent-session.js"; import type { AgentSessionRuntimeDiagnostic } from "../../core/agent-session-services.js"; import { type AgentCronJob, isHeartbeatCronJob } from "../../core/cron-jobs.js"; import type { SessionActionSnapshot } from "../../core/session-action-store.js"; @@ -329,87 +328,23 @@ export function summaryForInactiveSession( }; } -/** - * Build snapshots for all RLM child sessions hosted by the daemon under the - * given session, including grandchildren. Mirrors the shape of live - * rlm_child_update events so attach clients can seed their subagent state - * from daemon memory instead of replaying the event stream. - */ +/** Build the root AgentSession projection with daemon-only active session ids. */ export function buildRlmChildSnapshots( rootActiveSessionId: string, activeSessions: readonly ActiveSessionState[], ): AgentConnectionRlmChildAgentSnapshot[] { - const childrenByParent = new Map(); - for (const candidate of activeSessions) { - const metadata = candidate.runtime.metadata; - if (metadata.kind !== "subagent" || !metadata.parentActiveSessionId) { - continue; - } - const siblings = childrenByParent.get(metadata.parentActiveSessionId) ?? []; - siblings.push(candidate); - childrenByParent.set(metadata.parentActiveSessionId, siblings); - } - - const snapshots: AgentConnectionRlmChildAgentSnapshot[] = []; - const visit = (parent: ActiveSessionState | undefined, parentActiveSessionId: string): void => { - const parentNodeId = parent?.runtime.metadata.rlmChildId; - for (const child of childrenByParent.get(parentActiveSessionId) ?? []) { - snapshots.push(rlmChildSnapshotForActiveSession(child, child.runtime.metadata, parentNodeId, parent)); - // A child passes its own node id to its children as their parent id. - visit(child, child.activeSessionId); - } - }; const root = activeSessions.find((candidate) => candidate.activeSessionId === rootActiveSessionId); - visit(root, rootActiveSessionId); - return snapshots; -} - -function rlmChildSnapshotForActiveSession( - activeSession: ActiveSessionState, - metadata: AgentSessionRuntimeMetadata, - parentNodeId: string | undefined, - parent: ActiveSessionState | undefined, -): AgentConnectionRlmChildAgentSnapshot { - const session = activeSession.runtime.session; - let answerPreview: string | undefined; - let toolUseCount = 0; - const messages = - session.state.streamingMessage?.role === "assistant" - ? [...session.messages, session.state.streamingMessage] - : session.messages; - for (const message of messages) { - if (message.role === "assistant") { - const text = compactRlmText(readMessageText(message.content)); - if (text) { - answerPreview = text; - } - toolUseCount += message.content.filter((block) => block.type === "toolCall").length; - } - } - // The parent session's run tracker is the source of truth for child status; - // a daemon-hosted child whose agent is momentarily idle is still part of an - // active run. The streaming heuristic only covers parents the daemon does - // not host (e.g. children attributed to a session created by an older build). - const runStatus = metadata.rlmChildId - ? parent?.runtime.session.getRlmChildRunStatus(metadata.rlmChildId) - : undefined; - const status = runStatus ?? (session.isSessionActive ? "running" : "done"); - const isActive = status === "running" || session.isSessionActive; - return { - id: metadata.rlmChildId ?? activeSession.activeSessionId, - parentId: parentNodeId, - activeSessionId: activeSession.activeSessionId, - sessionName: session.sessionName, - model: session.model ? `${session.model.provider}/${session.model.id}` : undefined, - label: rlmChildLabel(metadata.prompt ?? ""), - status, - answerPreview, - toolUseCount: toolUseCount > 0 ? toolUseCount : undefined, - tokenCount: session._contextTokensForCurrentMessages(), - recap: session.getCurrentRecap(), - sessionDir: metadata.sessionDir ?? session.sessionManager.getSessionDir(), - activity: isActive ? { kind: session.isStreaming ? "writing" : "waiting" } : undefined, - }; + if (!root) return []; + const activeSessionIds = new Map( + activeSessions.flatMap((candidate) => { + const childId = candidate.runtime.metadata.rlmChildId; + return childId ? [[childId, candidate.activeSessionId] as const] : []; + }), + ); + return root.runtime.session.getRlmChildSnapshots().map((snapshot) => ({ + ...snapshot, + activeSessionId: activeSessionIds.get(snapshot.id), + })); } function firstUserMessageText(session: ActiveSessionState["runtime"]["session"]): string | undefined { diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index f96cc010fc..aa84f2d764 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -2,7 +2,7 @@ import { type ChildProcess, spawn } from "node:child_process"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { createServer, type Server, type Socket } from "node:net"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { Writable } from "node:stream"; import { getLogger } from "@earendil-works/pi-ai"; import { createCliSubprocessEnv, createCliSubprocessLaunchSpec } from "../../cli/subprocess-launch.js"; @@ -49,6 +49,7 @@ import { } from "../../core/session-action-store.js"; import { canonicalSessionPath, getProcessStartId, SessionAlreadyActiveError } from "../../core/session-lease.js"; import { getSessionArtifactPathForFile, readSessionInfo, type SessionInfo } from "../../core/session-manager.js"; +import { looksLikeSessionPath } from "../../core/session-resolver.js"; import { SettingsManager } from "../../core/settings-manager.js"; import { isProcessAlive, processIdExists, signalProcessGroupOrProcess } from "../../utils/child-process.js"; import type { AgentConnectionHeartbeat } from "../agent-connection/types.js"; @@ -546,10 +547,6 @@ function workerSocketPath(supervisorSocketPath: string, workerId: string): strin return join(defaultDaemonSocketDir(), `worker-${key}-${workerId.slice(0, 12)}.sock`); } -function looksLikeSessionPath(selector: string): boolean { - return isAbsolute(selector) || selector.endsWith(".jsonl") || selector.includes("/") || selector.includes("\\"); -} - function isFinalizedTranscriptEvent(eventType: string | undefined): boolean { return ( eventType === "message_end" || diff --git a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts index 0ab9ee9e28..2738c27ec6 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts @@ -103,22 +103,70 @@ 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)) || + (entry.rlmMaxDepth !== undefined && (!Number.isSafeInteger(entry.rlmMaxDepth) || entry.rlmMaxDepth < 0)) + ) { + continue; + } + latest.set(entry.childId, { + ...entry, + sessionDir: typeof entry.sessionDir === "string" ? entry.sessionDir : dirname(entry.sessionFile), + } 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 +181,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/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index db5abdb5c6..59de91fc4c 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, @@ -1017,12 +1017,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; @@ -2510,25 +2509,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 { @@ -2652,6 +2637,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; @@ -2665,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 }); @@ -2806,7 +2799,8 @@ export class InteractiveMode { this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true }); } this.subscribeToAgent(); - await Promise.all([this.refreshConnectionQueue(), this.refreshHeartbeatCatalog().catch(() => undefined)]); + this.updatePendingMessagesDisplay(); + await this.refreshHeartbeatCatalog().catch(() => undefined); await this.updateAvailableProviderCount(); this.updateEditorBorderColor(); this.updateTerminalTitle(); @@ -2829,8 +2823,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(); @@ -2888,9 +2882,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(); } @@ -2906,6 +2898,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; @@ -2916,7 +2909,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); @@ -4420,7 +4413,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; @@ -5363,10 +5357,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; } @@ -6990,9 +6981,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); + const text = this.queueSelection.move(this.getConnectionQueue(), this.editor.getText(), direction); if (text === undefined) return; this.setEditorTextFromQueueSelection(text); this.ui.requestRender(); @@ -7009,48 +7011,37 @@ 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 = this.queueSelection.selected; + if (!selected) return; + this.pendingQueueMove = true; + 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; + 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; } - 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(); - } - } else if (status === "unsupported") this.showStatus("Queue editing requires a newer daemon"); - else this.showStatus("Queue changed; reorder not applied"); }).catch((error) => { if (sessionGeneration === this.sessionEventGeneration) { this.showError(error instanceof Error ? error.message : String(error)); @@ -7064,9 +7055,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; @@ -7096,68 +7085,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); @@ -7422,10 +7383,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 { 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/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 7c32788164..fa3ea7de1a 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -32,9 +32,6 @@ import type { // Types // ============================================================================ -/** Extended response timeout for refine requests, which run an LLM pass. */ -export const REFINE_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; - /** Distributive Omit that works with union types */ type DistributiveOmit = T extends unknown ? Omit : never; @@ -66,6 +63,11 @@ export interface ModelInfo { export type RpcEventListener = (event: AgentEvent) => void; export type RpcObservedSessionListener = (event: RpcObservedSessionEvent) => void; +interface RpcEventCollection { + promise: Promise; + cancel(): void; +} + // ============================================================================ // RPC Client // ============================================================================ @@ -79,6 +81,8 @@ export class RpcClient { new Map(); private requestId = 0; private stderr = ""; + private transportError: Error | null = null; + private pendingEventWaiters = new Set<(error: Error) => void>(); constructor(private options: RpcClientOptions = {}) {} @@ -103,28 +107,40 @@ export class RpcClient { args.push(...this.options.args); } - this.process = spawn("node", [cliPath, ...args], { + this.transportError = null; + const child = spawn("node", [cliPath, ...args], { cwd: this.options.cwd, env: { ...process.env, ...this.options.env }, stdio: ["pipe", "pipe", "pipe"], }); + this.process = child; + child.on("error", (error) => { + this.failPendingOperations(new Error(`RPC process error: ${error.message}. Stderr: ${this.stderr}`)); + }); + child.stdout?.on("close", () => { + this.failPendingOperations(new Error(`RPC process output closed. Stderr: ${this.stderr}`)); + }); + child.on("close", () => { + this.process = null; + }); // Collect stderr for debugging - this.process.stderr?.on("data", (data) => { + child.stderr?.on("data", (data) => { this.stderr += data.toString(); process.stderr.write(data); }); // Set up strict JSONL reader for stdout. - this.stopReadingStdout = attachJsonlLineReader(this.process.stdout!, (line) => { + this.stopReadingStdout = attachJsonlLineReader(child.stdout!, (line) => { this.handleLine(line); }); // Wait a moment for process to initialize await new Promise((resolve) => setTimeout(resolve, 100)); - if (this.process.exitCode !== null) { - throw new Error(`Agent process exited immediately with code ${this.process.exitCode}. Stderr: ${this.stderr}`); + if (this.transportError) throw this.transportError; + if (child.exitCode !== null) { + throw new Error(`Agent process exited immediately with code ${child.exitCode}. Stderr: ${this.stderr}`); } } @@ -132,27 +148,20 @@ export class RpcClient { * Stop the RPC agent process. */ async stop(): Promise { - if (!this.process) return; + const child = this.process; + if (!child) return; this.stopReadingStdout?.(); this.stopReadingStdout = null; - this.process.kill("SIGTERM"); - - // Wait for process to exit + this.failPendingOperations(new Error(`RPC client stopped. Stderr: ${this.stderr}`)); await new Promise((resolve) => { - const timeout = setTimeout(() => { - this.process?.kill("SIGKILL"); - resolve(); - }, 1000); - - this.process?.on("exit", () => { + const timeout = setTimeout(() => child.kill("SIGKILL"), 1000); + child.once("close", () => { clearTimeout(timeout); resolve(); }); + child.kill("SIGTERM"); }); - - this.process = null; - this.pendingRequests.clear(); } /** @@ -195,7 +204,8 @@ export class RpcClient { * Use waitForIdle() to wait for completion. */ async prompt(message: string, images?: ImageContent[]): Promise { - await this.send({ type: "prompt", message, images }); + const response = await this.send({ type: "prompt", message, images }); + this.getData(response); } /** @@ -308,8 +318,6 @@ export class RpcClient { async refine( options: { instructions?: string; rollbackId?: string; global?: boolean } = {}, ): Promise { - // Refinement runs an LLM pass that routinely exceeds the default 30s response - // timeout, so use the same extended window as the daemon refine path. const command = { type: "refine", instructions: options.instructions, rollbackId: options.rollbackId } as { type: "refine"; instructions?: string; @@ -319,7 +327,7 @@ export class RpcClient { if (options.global !== undefined) { command.global = options.global; } - const response = await this.send(command, REFINE_REQUEST_TIMEOUT_MS); + const response = await this.send(command); return this.getData(response); } @@ -540,58 +548,98 @@ export class RpcClient { * Wait for agent to become idle (no streaming). * Resolves when agent_end event is received. */ - waitForIdle(timeout = 60000): Promise { + waitForIdle(timeout?: number): Promise { + if (this.transportError) return Promise.reject(this.transportError); return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + let timer: ReturnType | undefined; + const cleanup = () => { + if (timer) clearTimeout(timer); unsubscribe(); - reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.stderr}`)); - }, timeout); - + this.pendingEventWaiters.delete(onFailure); + }; + const onFailure = (error: Error) => { + cleanup(); + reject(error); + }; const unsubscribe = this.onEvent((event) => { if (event.type === "agent_end") { - clearTimeout(timer); - unsubscribe(); + cleanup(); resolve(); } }); + this.pendingEventWaiters.add(onFailure); + if (timeout !== undefined) { + timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.stderr}`)); + }, timeout); + } }); } /** * Collect events until agent becomes idle. */ - collectEvents(timeout = 60000): Promise { - return new Promise((resolve, reject) => { - const events: AgentEvent[] = []; - const timer = setTimeout(() => { - unsubscribe(); - reject(new Error(`Timeout collecting events. Stderr: ${this.stderr}`)); - }, timeout); - - const unsubscribe = this.onEvent((event) => { - events.push(event); - if (event.type === "agent_end") { - clearTimeout(timer); - unsubscribe(); - resolve(events); - } - }); - }); + collectEvents(timeout?: number): Promise { + return this.startEventCollection(timeout).promise; } /** * Send prompt and wait for completion, returning all events. */ - async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise { - const eventsPromise = this.collectEvents(timeout); - await this.prompt(message, images); - return eventsPromise; + async promptAndWait(message: string, images?: ImageContent[], timeout?: number): Promise { + const collection = this.startEventCollection(timeout); + try { + const [events] = await Promise.all([collection.promise, this.prompt(message, images)]); + return events; + } finally { + collection.cancel(); + } } // ========================================================================= // Internal // ========================================================================= + private startEventCollection(timeout?: number): RpcEventCollection { + if (this.transportError) { + return { promise: Promise.reject(this.transportError), cancel: () => undefined }; + } + let cancel = () => undefined; + const promise = new Promise((resolve, reject) => { + const events: AgentEvent[] = []; + let timer: ReturnType | undefined; + const cleanup = () => { + if (timer) clearTimeout(timer); + unsubscribe(); + this.pendingEventWaiters.delete(onFailure); + }; + const onFailure = (error: Error) => { + cleanup(); + reject(error); + }; + const unsubscribe = this.onEvent((event) => { + events.push(event); + if (event.type === "agent_end") { + cleanup(); + resolve(events); + } + }); + cancel = () => { + cleanup(); + resolve(events); + }; + this.pendingEventWaiters.add(onFailure); + if (timeout !== undefined) { + timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout collecting events. Stderr: ${this.stderr}`)); + }, timeout); + } + }); + return { promise, cancel }; + } + private handleLine(line: string): void { try { const data = JSON.parse(line); @@ -627,7 +675,8 @@ export class RpcClient { } } - private async send(command: RpcCommandBody, timeoutMs = 30000): Promise { + private async send(command: RpcCommandBody): Promise { + if (this.transportError) throw this.transportError; if (!this.process?.stdin) { throw new Error("Client not started"); } @@ -637,27 +686,21 @@ export class RpcClient { return new Promise((resolve, reject) => { this.pendingRequests.set(id, { resolve, reject }); - - const timeout = setTimeout(() => { - this.pendingRequests.delete(id); - reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`)); - }, timeoutMs); - - this.pendingRequests.set(id, { - resolve: (response) => { - clearTimeout(timeout); - resolve(response); - }, - reject: (error) => { - clearTimeout(timeout); - reject(error); - }, - }); - this.process!.stdin!.write(serializeJsonLine(fullCommand)); }); } + private failPendingOperations(error: Error): void { + this.transportError ??= error; + for (const [id, pending] of this.pendingRequests) { + pending.reject(this.transportError); + this.pendingRequests.delete(id); + } + for (const reject of [...this.pendingEventWaiters]) { + reject(this.transportError); + } + } + private getData(response: RpcResponse): T { if (!response.success) { const errorResponse = response as Extract; diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index 2c373352ba..f1797da7f7 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -18,7 +18,7 @@ import { createAgentSessionMessage, isAgentSessionMessage, } from "../src/core/agent-messages.js"; -import { AgentSession } from "../src/core/agent-session.js"; +import { AgentSession, type RlmChildAgentSnapshot } from "../src/core/agent-session.js"; import { AuthStorage } from "../src/core/auth-storage.js"; import type { LoadExtensionsResult } from "../src/core/extensions/index.js"; import { type HostRequestHandlers, ReplKernelManager } from "../src/core/kernel/index.js"; @@ -120,7 +120,7 @@ interface InspectableRlmSession { } >; _rlmChildCleanupFailures: Map>["subagents"][number]>; - _rlmChildSessions: Map; + _rlmChildSessions: Map; _rlmChildUnsubscribes: Map void>; _deletedRlmChildIds: Set; _rlmQuiescenceWaitAborts: Set; @@ -523,6 +523,10 @@ describe("AgentSession rlm recursion", () => { mkdirSync(childDir, { recursive: true }); const child = createSession({ rlmSessionDir: childDir }); child.setSessionName("restored-worker"); + const restoredAnswer = assistantMessage("restored answer", usage(7, 3)); + restoredAnswer.content.push({ type: "toolCall", id: "tool-1", name: "ipython", arguments: {} }); + child.agent.state.messages.push(restoredAnswer); + child.setCurrentRecap("restored recap"); const disposeChild = vi.spyOn(child, "disposeAsync"); const root = createSession(); const childStatuses: string[] = []; @@ -533,6 +537,15 @@ describe("AgentSession rlm recursion", () => { }); expect(root.registerRlmChildSession(childId, child)).toBe(true); + expect(root.getRlmChildSnapshots()).toEqual([ + expect.objectContaining({ + id: childId, + answerPreview: "restored answer", + toolUseCount: 1, + tokenCount: 10, + recap: "restored recap", + }), + ]); expect((await root.listRlmSubagents()).subagents).toEqual([ expect.objectContaining({ rlm_child_id: childId, @@ -651,9 +664,9 @@ describe("AgentSession rlm recursion", () => { }); // The same child can be visible in both lifecycle registries while // deletion settles; it must be traversed exactly once and remain hidden. - rootInternals._rlmChildSessions.set(id, parent); + rootInternals._rlmChildSessions.set(id, { session: parent }); } else { - rootInternals._rlmChildSessions.set(id, parent); + rootInternals._rlmChildSessions.set(id, { session: parent }); if (hiding === "deleted") { rootInternals._deletedRlmChildIds.add(id); } else { @@ -745,6 +758,14 @@ describe("AgentSession rlm recursion", () => { await waitFor(() => childUpdates.some((update) => update.status === "done")); const doneUpdate = [...childUpdates].reverse().find((update) => update.status === "done"); expect(doneUpdate?.answerPreview).toBe("child answer: summarize shard 1"); + expect(root.getRlmChildSnapshots()).toEqual([ + expect.objectContaining({ + id: result.rlm_child_id, + status: "done", + answerPreview: doneUpdate?.answerPreview, + durationMs: expect.any(Number), + }), + ]); const child = root.getRlmChildSession(result.rlm_child_id); expect(child?.messages[0]).toMatchObject({ role: "custom", @@ -842,6 +863,9 @@ describe("AgentSession rlm recursion", () => { }, }); const spawned = await root.runRlmChild("pending task", { name: "pending-child" }); + expect(root.getRlmChildSnapshots()).toEqual([ + expect.objectContaining({ id: spawned.rlm_child_id, status: "queued" }), + ]); const handlers = (root as unknown as InspectableRlmSession)._createKernelHostHandlers(); const send = handlers["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler"); @@ -2196,14 +2220,33 @@ describe("AgentSession rlm recursion", () => { if (!child) { throw new Error("Missing retained child session"); } + const rootInternals = root as unknown as InspectableRlmSession; + await waitFor(() => !rootInternals._activeRlmChildRuns.has(childId)); + const completeRelease = root.releaseRlmChildSession(childId, child); + if (!completeRelease) throw new Error("Failed to release retained child"); + child.setCurrentRecap("retained recap"); child.setSessionName("renamed-worker"); const childUpdates = events.filter( - (event): event is { type: "rlm_child_update"; child: { sessionName?: string } } => + (event): event is { type: "rlm_child_update"; child: RlmChildAgentSnapshot } => typeof event === "object" && event !== null && (event as { type?: string }).type === "rlm_child_update", ); - expect(childUpdates.at(-1)?.child.sessionName).toBe("renamed-worker"); + expect(childUpdates.at(-1)?.child).toMatchObject({ + sessionName: "renamed-worker", + durationMs: expect.any(Number), + tokenCount: 10, + recap: "retained recap", + repliedSinceTask: false, + }); + expect(root.getRlmChildSnapshots()).toEqual([ + expect.objectContaining({ + sessionName: "renamed-worker", + durationMs: expect.any(Number), + tokenCount: 10, + recap: "retained recap", + }), + ]); }); it("surfaces a child's recap on its snapshot once the summarizer sets it", 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/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 27b7ff7a0f..049d254206 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -5,7 +5,6 @@ import { registerOAuthProvider } from "@earendil-works/pi-ai/oauth"; import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.js"; -import { clearConfigValueCache } from "../src/core/resolve-config-value.js"; describe("AuthStorage", () => { let tempDir: string; @@ -22,7 +21,6 @@ describe("AuthStorage", () => { if (tempDir && existsSync(tempDir)) { rmSync(tempDir, { recursive: true }); } - clearConfigValueCache(); vi.restoreAllMocks(); }); @@ -835,26 +833,6 @@ describe("AuthStorage", () => { expect(count).toBe(1); }); - test("clearConfigValueCache allows command to run again", async () => { - const counterFile = join(tempDir, "counter"); - writeFileSync(counterFile, "0"); - - const counterPath = toShPath(counterFile); - const command = `!sh -c 'count=$(cat "${counterPath}"); echo $((count + 1)) > "${counterPath}"; echo "key-value"'`; - writeAuthJson({ - anthropic: { type: "api_key", key: command }, - }); - - authStorage = AuthStorage.create(authJsonPath); - await authStorage.getApiKey("anthropic"); - - clearConfigValueCache(); - await authStorage.getApiKey("anthropic"); - - const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10); - expect(count).toBe(2); - }); - test("different commands are cached separately", async () => { writeAuthJson({ anthropic: { type: "api_key", key: "!echo key-anthropic" }, diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 8f690b2642..2afd5d00ac 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -97,6 +97,40 @@ describe("daemon mode helpers", () => { expect(setSessionName).toHaveBeenCalledOnce(); }); + it("uses a supervisor-approved worker session name without validating it again", async () => { + const daemon = new AgentDaemon("/tmp/unused-worker.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + worker: { authenticationToken: "token" }, + }); + const setSessionName = vi.fn(); + const state = makeState("active"); + state.runtime = { + ...state.runtime, + session: { setSessionName }, + } as never; + const assertStateSessionNameAvailable = vi.fn(async () => { + throw new Error("stale peer name"); + }); + const internals = daemon as unknown as { + sessions: Map; + assertStateSessionNameAvailable: typeof assertStateSessionNameAvailable; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + internals.sessions.set(state.activeSessionId, state); + internals.assertStateSessionNameAvailable = assertStateSessionNameAvailable; + + await expect( + internals.handleCommand(makeClient("supervisor", state.activeSessionId), { + type: "set_session_name", + activeSessionId: state.activeSessionId, + name: "approved", + }), + ).resolves.toMatchObject({ success: true }); + expect(assertStateSessionNameAvailable).not.toHaveBeenCalled(); + expect(setSessionName).toHaveBeenCalledWith("approved"); + }); + it("treats a depth-zero fork as a sibling of another root", () => { const daemon = new AgentDaemon("/tmp/prime-agent-fork-family.sock", { defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, @@ -948,18 +982,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" })], @@ -1681,6 +1716,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"); @@ -6928,7 +7042,7 @@ describe("daemon mode helpers", () => { } }); - it("re-adopts a resident child when its passivation close fails", async () => { + it("keeps ownership of a resident child until passivation succeeds", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-passivation-close-failure-")); let releaseAbort!: () => void; const abortGate = new Promise((resolve) => { @@ -6952,7 +7066,6 @@ describe("daemon mode helpers", () => { const childState = await internals.createRuntime({ type: "create", sessionPath: fixture.childSessionFile }); const parentSession = parentState.runtime.session as unknown as { releaseRlmChildSession: ReturnType; - registerRlmChildSession: ReturnType; }; let parentOwnsChild = true; let forwarderActive = true; @@ -6965,16 +7078,11 @@ describe("daemon mode helpers", () => { }); parentSession.releaseRlmChildSession = vi.fn(() => { if (!parentOwnsChild) return false; - parentOwnsChild = false; - return unsubscribeForwarder; - }); - parentSession.registerRlmChildSession = vi.fn( - (_childId: string, _childSession: unknown, unsubscribe: () => void) => { - parentOwnsChild = true; - forwarderActive = unsubscribe === unsubscribeForwarder; - return true; - }, - ); + return () => { + parentOwnsChild = false; + unsubscribeForwarder(); + }; + }); childState.unsubscribe = vi .fn() .mockImplementationOnce(() => { @@ -7000,11 +7108,6 @@ describe("daemon mode helpers", () => { await expect(delivery).resolves.toMatchObject({ deliveryStatus: "delivered" }); expect(internals.sessions.get(childState.activeSessionId)).toBe(childState); expect(parentOwnsChild).toBe(true); - expect(parentSession.registerRlmChildSession).toHaveBeenCalledWith( - fixture.childId, - childState.runtime.session, - unsubscribeForwarder, - ); expect(unsubscribeForwarder).not.toHaveBeenCalled(); emitChildUpdate("recap after failed close"); expect(parentUpdates).toEqual(["recap after failed close"]); @@ -10274,6 +10377,7 @@ function makeRuntimeSession( }, setSubagentRuntimeHost: vi.fn(), getRlmChildRunStatus: vi.fn(() => "running"), + getRlmChildSnapshots: vi.fn(() => []), registerRlmChildSession: vi.fn(() => true), releaseRlmChildSession: vi.fn(() => vi.fn()), subscribe: vi.fn(() => vi.fn()), diff --git a/packages/coding-agent/test/daemon-session-list.test.ts b/packages/coding-agent/test/daemon-session-list.test.ts index a332372bfb..6cb772e27f 100644 --- a/packages/coding-agent/test/daemon-session-list.test.ts +++ b/packages/coding-agent/test/daemon-session-list.test.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { describe, expect, it } from "vitest"; +import type { RlmChildAgentSnapshot } from "../src/core/agent-session.js"; import type { AgentCronJob } from "../src/core/cron-jobs.js"; import type { SessionInfo } from "../src/core/session-manager.js"; import type { ActiveSessionState, DaemonSocketClient } from "../src/modes/daemon/active-session-state.js"; @@ -496,150 +497,42 @@ describe("summaryForActiveSession recap currency", () => { }); describe("buildRlmChildSnapshots", () => { - it("collects children and grandchildren with event-compatible parent ids", () => { - const parent = makeState({ activeSessionId: "parent", sessionFile: "/tmp/parent.jsonl" }); - const child = makeState({ - activeSessionId: "child", - model: { provider: "anthropic", id: "claude-opus-4-7" }, - isStreaming: true, - metadata: { - kind: "subagent", - createdAt: 1, - parentActiveSessionId: "parent", - rlmChildId: "sub-aaa", - rlmParentNodeId: "sub-aaa", - prompt: "Summarize the repo\nlayout", - sessionDir: "/tmp/artifacts/sub-aaa", - }, - messages: [ - { role: "user", content: "Summarize the repo layout" }, - { - role: "assistant", - content: [ - { type: "text", text: "The repo is an npm workspace." }, - { type: "toolCall", id: "tool-1", name: "ipython", arguments: {} }, - ], - }, - ] as AgentMessage[], - contextTokens: 41_000, - }); - const grandchild = makeState({ - activeSessionId: "grandchild", - metadata: { - kind: "subagent", - createdAt: 2, - parentActiveSessionId: "child", - rlmChildId: "sub-bbb", - rlmParentNodeId: "sub-bbb", - prompt: "Read the docs", - sessionDir: "/tmp/artifacts/sub-aaa/sub-bbb", - }, - }); - const unrelated = makeState({ - activeSessionId: "unrelated-child", - metadata: { - kind: "subagent", - createdAt: 3, - parentActiveSessionId: "someone-else", - rlmChildId: "sub-ccc", - }, - }); - - const snapshots = buildRlmChildSnapshots("parent", [parent, child, grandchild, unrelated]); - - expect(snapshots.map((snapshot) => [snapshot.id, snapshot.parentId, snapshot.status])).toEqual([ - ["sub-aaa", undefined, "running"], - ["sub-bbb", "sub-aaa", "done"], - ]); - expect(snapshots[0]).toMatchObject({ - model: "anthropic/claude-opus-4-7", - label: "Summarize the repo layout", - answerPreview: "The repo is an npm workspace.", - toolUseCount: 1, - tokenCount: 41_000, - sessionDir: "/tmp/artifacts/sub-aaa", - activeSessionId: "child", - }); - }); - - it("prefers the parent's run status over the streaming heuristic", () => { - // An idle child session is still part of an active run; only the parent's - // run tracker knows that. - const parent = makeState({ - activeSessionId: "parent", - sessionFile: "/tmp/parent.jsonl", - childRunStatuses: { "sub-aaa": "running" }, - }); - const idleChild = makeState({ - activeSessionId: "child", - isStreaming: false, - metadata: { - kind: "subagent", - createdAt: 1, - parentActiveSessionId: "parent", - rlmChildId: "sub-aaa", - rlmParentNodeId: "sub-aaa", - prompt: "Slow task", - sessionDir: "/tmp/artifacts/sub-aaa", - }, - }); - - const snapshots = buildRlmChildSnapshots("parent", [parent, idleChild]); - - expect(snapshots.map((snapshot) => [snapshot.id, snapshot.status])).toEqual([["sub-aaa", "running"]]); - }); - - it("keeps terminal run status while projecting a retained child's active follow-up", () => { + it("uses the AgentSession projection and adds resident active session ids", () => { + const queued = { + id: "sub-queued", + label: "Queued task", + status: "queued" as const, + sessionDir: "/tmp/artifacts/sub-queued", + }; + const executing = { + id: "sub-running", + label: "Running task", + status: "running" as const, + sessionDir: "/tmp/artifacts/sub-running", + activity: { kind: "executing" as const, toolName: "ipython" }, + }; const parent = makeState({ activeSessionId: "parent", - childRunStatuses: { "sub-aaa": "done" }, + childSnapshots: [queued, executing], }); - const activeRetainedChild = makeState({ - activeSessionId: "child", - isStreaming: true, + const residentChild = makeState({ + activeSessionId: "running-child", metadata: { kind: "subagent", createdAt: 1, parentActiveSessionId: "parent", - rlmChildId: "sub-aaa", + rlmChildId: "sub-running", }, }); - expect(buildRlmChildSnapshots("parent", [parent, activeRetainedChild])[0]).toMatchObject({ - status: "done", - activity: { kind: "writing" }, - }); - }); - - it("includes in-flight assistant output in child snapshots", () => { - const parent = makeState({ activeSessionId: "parent" }); - const child = makeState({ - activeSessionId: "child", - isStreaming: true, - metadata: { - kind: "subagent", - createdAt: 1, - parentActiveSessionId: "parent", - rlmChildId: "sub-aaa", - }, - streamingMessage: { - role: "assistant", - content: [ - { type: "text", text: "Still investigating" }, - { type: "toolCall", id: "tool-1", name: "search", arguments: {} }, - ], - } as AgentMessage, - }); - - expect(buildRlmChildSnapshots("parent", [parent, child])[0]).toMatchObject({ - answerPreview: "Still investigating", - toolUseCount: 1, - }); + expect(buildRlmChildSnapshots("parent", [parent, residentChild])).toEqual([ + { ...queued, activeSessionId: undefined }, + { ...executing, activeSessionId: "running-child" }, + ]); }); - it("returns no snapshots for sessions without children", () => { - const solo = makeState({ activeSessionId: "solo" }); - expect(buildRlmChildSnapshots("solo", [solo])).toEqual([]); + it("returns no snapshots when the root is not resident", () => { + expect(buildRlmChildSnapshots("missing", [])).toEqual([]); }); }); @@ -691,12 +584,12 @@ interface StateOptions { messages?: AgentMessage[]; hasUserContent?: boolean; summaryState?: ActiveSessionState["summaryState"]; - childRunStatuses?: Record; hasRunningRlmChildren?: boolean; hasAcceptedPromptInFlight?: boolean; unfinishedActionCount?: number; contextTokens?: number; streamingMessage?: AgentMessage; + childSnapshots?: RlmChildAgentSnapshot[]; rlmDepth?: number; metadata?: { kind: "top-level" | "subagent"; @@ -741,7 +634,7 @@ function makeState(options: StateOptions): ActiveSessionState { hasUserContent: () => options.hasUserContent ?? false, }, messages: options.messages ?? ([] as AgentMessage[]), - getRlmChildRunStatus: (childId: string) => options.childRunStatuses?.[childId], + getRlmChildSnapshots: () => options.childSnapshots ?? [], hasRunningRlmChildren: () => options.hasRunningRlmChildren ?? false, hasAcceptedPromptInFlight: options.hasAcceptedPromptInFlight ?? false, unfinishedActionCount: options.unfinishedActionCount ?? (options.hasAcceptedPromptInFlight ? 1 : 0), diff --git a/packages/coding-agent/test/fixtures/rpc-client-hanging-fixture.mjs b/packages/coding-agent/test/fixtures/rpc-client-hanging-fixture.mjs new file mode 100644 index 0000000000..72ca140967 --- /dev/null +++ b/packages/coding-agent/test/fixtures/rpc-client-hanging-fixture.mjs @@ -0,0 +1 @@ +process.stdin.resume(); 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..12d5a7e0bb 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 }, 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 4ef691e164..c9cac26fce 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1265,7 +1265,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 = { @@ -1435,74 +1435,51 @@ 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 harness = { + unsubscribe: undefined, + localSessionHost: undefined, + toolDefinitionCache: { clear: vi.fn() }, + applyRuntimeSettings: vi.fn(), + bindLocalSessionExtensions: true, + bindCurrentSessionExtensions: vi.fn(async () => {}), + subscribeToAgent: 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(); }); test("restores in-flight assistant state on every session render", async () => { @@ -1696,6 +1673,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() }]]); @@ -1729,6 +1764,7 @@ describe("InteractiveMode connection events", () => { streamingComponent: {}, streamingMessage: {}, applyConnectionStateSnapshot: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), updateWorkingLoaderMessage: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ @@ -1738,7 +1774,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(), @@ -1785,6 +1821,7 @@ describe("InteractiveMode connection events", () => { isAgentCompacting: () => true, isBashRunning: () => true, applyConnectionStateSnapshot: vi.fn(), + refreshQueueSelectionFromState: vi.fn(), restoreTurnStartFromMessages: vi.fn(), replaceSubagentSummary: vi.fn(), getSessionContextFromConnectionSnapshot: vi.fn(() => ({ @@ -1794,7 +1831,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(), @@ -1832,7 +1869,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"), }; @@ -1842,7 +1879,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 () => { @@ -3124,7 +3161,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"); @@ -3160,7 +3197,7 @@ describe("InteractiveMode session switch command catalog", () => { 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(), diff --git a/packages/coding-agent/test/interactive-queue-edit.test.ts b/packages/coding-agent/test/interactive-queue-edit.test.ts index b53b21c975..67519c288d 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,104 +350,156 @@ 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.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(2, "steering", 1, "s3", { - type: "move", - direction: -1, - }); - expect(harness.connectionQueue.steering).toEqual(["s3", "s1", "s2"]); + + expect(harness.getConnectionQueue()).toEqual({ steering: ["s2", "s1"], followUp: [] }); + expect(harness.queueSelection.selected).toEqual({ lane: "steering", index: 0, text: "s2" }); }); - it("preserves a queued reorder when an edit immediately exits browse mode", async () => { + 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"; + }); + harness.editor.setText("draft"); harness.browseQueueSelection(-1); harness.moveQueueSelection(-1); - const edited = harness.applyQueueSelection("s2 edited", "steering"); await harness.queueMutationChain; - await edited; - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(1, "steering", 1, "s2", { - type: "move", - direction: -1, - }); - expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(2, "steering", 0, "s2", { - type: "replace", - text: "s2 edited", - images: [], - lane: "steering", - }); + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("draft"); }); - 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("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"; + }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + await harness.queueMutationChain; - harness.browseQueueSelection(-1); // f1 edited - await harness.applyQueueSelection(" ", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: ["s1", "s2"], followUp: [] }); + expect(harness.queueSelection.isBrowsing).toBe(false); + expect(harness.editor.getText()).toBe("draft"); }); - it("does not double-apply a delete when the queue event lands before the response", async () => { - const harness = createHarness({ steering: [], followUp: ["dup", "dup"] }); + 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 () => { - // 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); + emitQueueUpdate(harness, { steering: ["s1"], followUp: [] }); return "applied"; }); - harness.browseQueueSelection(-1); // dup at followUp index 1 - await harness.applyQueueSelection(" ", "followUp"); - expect(harness.connectionQueue).toEqual({ steering: [], followUp: ["dup"] }); - }); + harness.editor.setText("draft"); + harness.browseQueueSelection(-1); + harness.moveQueueSelection(-1); + harness.editor.setText(""); + await harness.applyQueueSelection("s2 edited", "steering"); - 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"] }); + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledOnce(); + expect(harness.editor.getText()).toBe("s2 edited"); + expect(harness.showStatus).toHaveBeenCalledWith("Queue changed; edit kept in the editor"); }); - it("restores the stashed draft when the browsed item is consumed externally", () => { - const harness = createHarness({ steering: [], followUp: ["f1"] }); - harness.editor.setText("draft"); + 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); - 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.moveQueueSelection(-1); + harness.moveQueueSelection(-1); + const edited = harness.applyQueueSelection("s3 edited", "steering"); + await edited; + + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenNthCalledWith(1, "steering", 2, "s3", { + type: "move", + direction: -1, + }); + 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: "s3 edited", + images: [], + lane: "steering", + }); + expect(harness.getConnectionQueue()).toEqual({ steering: ["s3 edited", "s1", "s2"], followUp: [] }); }); - it("synchronizes queue browsing when a reconnect refresh replaces the queue", async () => { - const harness = createHarness({ steering: [], followUp: ["queued"] }); - harness.editor.setText("draft"); + 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); - harness.agentConnection.getQueue.mockResolvedValue({ steering: [], followUp: [] }); - - await harness.refreshConnectionQueue(); + const pending = harness.applyQueueSelection("edited", "followUp"); + setQueue(harness, { steering: [], followUp: ["dup"] }); + releaseMutationChain(); + await pending; - expect(harness.queueSelection.isBrowsing).toBe(false); - expect(harness.editor.getText()).toBe("draft"); + expect(harness.agentConnection.mutateQueuedMessage).toHaveBeenCalledWith("followUp", 1, "dup", { + type: "replace", + text: "edited", + images: [], + lane: "followUp", + }); }); it("deduplicates repeated image markers in a replace", () => { 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-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 4add0e701b..0afca0a61f 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -6,7 +6,7 @@ import { getApiProvider } from "@earendil-works/pi-ai"; import { getOAuthProvider, registerOAuthProvider } from "@earendil-works/pi-ai/oauth"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.js"; -import { clearApiKeyCache, ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.js"; +import { ModelRegistry, type ProviderConfigInput } from "../src/core/model-registry.js"; describe("ModelRegistry", () => { let tempDir: string; @@ -24,7 +24,6 @@ describe("ModelRegistry", () => { if (tempDir && existsSync(tempDir)) { rmSync(tempDir, { recursive: true }); } - clearApiKeyCache(); }); function providerConfig( 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/repl-kernel-abort.test.ts b/packages/coding-agent/test/repl-kernel-abort.test.ts index 6187e4fa95..4881ef8942 100644 --- a/packages/coding-agent/test/repl-kernel-abort.test.ts +++ b/packages/coding-agent/test/repl-kernel-abort.test.ts @@ -216,7 +216,7 @@ describe("ReplKernelManager abort handling", () => { manager.disposeSync(); }); - it("starts the snapshot timeout after earlier kernel work finishes", async () => { + it("cancels a hung final snapshot execution before teardown", async () => { vi.useFakeTimers(); const manager = new ReplKernelManager({ cwd: process.cwd(), @@ -240,33 +240,71 @@ describe("ReplKernelManager abort handling", () => { ); }), ); + const cleanupResources = vi.fn(); Object.assign( manager as unknown as { state: "running"; executionQueue: Promise; executeInner: typeof executeInner; start: () => Promise; + cleanupResources: () => void; }, - { state: "running", executionQueue: previousExecution, executeInner, start: async () => {} }, + { state: "running", executionQueue: previousExecution, executeInner, start: async () => {}, cleanupResources }, ); - const snapshot = ( - manager as unknown as { - captureSnapshot: (options?: { executionTimeoutMs?: number }) => Promise; - } - ).captureSnapshot({ executionTimeoutMs: 5000 }); - await vi.advanceTimersByTimeAsync(5000); + const disposal = manager.dispose(); expect(executeInner).not.toHaveBeenCalled(); - releaseQueue(); await waitForCalls(executeInner, 1); const signal = executeInner.mock.calls[0]?.[2].signal; expect(signal?.aborted).toBe(false); await vi.advanceTimersByTimeAsync(4999); expect(signal?.aborted).toBe(false); + expect(cleanupResources).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); + expect(signal?.aborted).toBe(true); - await expect(snapshot).resolves.toBeNull(); + await expect(disposal).resolves.toBeUndefined(); + expect(cleanupResources).toHaveBeenCalledOnce(); + }); + + it("tears down when the final snapshot is blocked behind a hung execution", async () => { + vi.useFakeTimers(); + const manager = new ReplKernelManager({ + cwd: process.cwd(), + snapshot: { path: "/tmp/test-state.dill", manifestPath: "/tmp/test-state.json" }, + }); + const executeInner = vi.fn(); + const interrupt = vi.fn(async () => {}); + const cleanupResources = vi.fn(); + Object.assign( + manager as unknown as { + state: "running"; + executionQueue: Promise; + activeExecution: object; + executeInner: typeof executeInner; + interrupt: typeof interrupt; + cleanupResources: () => void; + }, + { + state: "running", + executionQueue: new Promise(() => {}), + activeExecution: {}, + executeInner, + interrupt, + cleanupResources, + }, + ); + + const disposal = manager.dispose(); + expect(interrupt).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(4999); + expect(cleanupResources).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + await expect(disposal).resolves.toBeUndefined(); + expect(executeInner).not.toHaveBeenCalled(); + expect(cleanupResources).toHaveBeenCalledOnce(); }); it("routes null-id and stale-id stream events into backgroundOutput, not stdout", async () => { diff --git a/packages/coding-agent/test/rpc-client-refine.test.ts b/packages/coding-agent/test/rpc-client-refine.test.ts deleted file mode 100644 index 098293bef8..0000000000 --- a/packages/coding-agent/test/rpc-client-refine.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { REFINE_REQUEST_TIMEOUT_MS, RpcClient } from "../src/modes/rpc/rpc-client.js"; - -type RpcClientPrivate = { - send: (command: { type: string }, timeoutMs?: number) => Promise; - getData: (response: unknown) => T; -}; - -describe("RpcClient refine", () => { - it("sends the refine command with the extended timeout", async () => { - const client = new RpcClient(); - const privateClient = client as unknown as RpcClientPrivate; - const send = vi.fn(async () => ({ - type: "response", - command: "refine", - success: true, - data: { id: "refine_1", appliedEdits: [], harnessStatePath: "/tmp/harness_state.json" }, - })); - privateClient.send = send; - privateClient.getData = (response: unknown): T => { - return (response as { data: T }).data; - }; - - const result = await client.refine({ instructions: "tighten validation" }); - - expect(send).toHaveBeenCalledWith( - { type: "refine", instructions: "tighten validation" }, - REFINE_REQUEST_TIMEOUT_MS, - ); - expect(REFINE_REQUEST_TIMEOUT_MS).toBeGreaterThan(30000); - expect(result).toMatchObject({ id: "refine_1" }); - }); -}); diff --git a/packages/coding-agent/test/rpc-client-timeout.test.ts b/packages/coding-agent/test/rpc-client-timeout.test.ts new file mode 100644 index 0000000000..f505a53b2c --- /dev/null +++ b/packages/coding-agent/test/rpc-client-timeout.test.ts @@ -0,0 +1,107 @@ +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RpcClient } from "../src/modes/rpc/rpc-client.js"; + +const fixturePath = fileURLToPath(new URL("./fixtures/rpc-client-hanging-fixture.mjs", import.meta.url)); +const clients = new Set(); + +async function createClient(): Promise { + const client = new RpcClient({ cliPath: fixturePath }); + await client.start(); + clients.add(client); + return client; +} + +describe("RpcClient operation completion", () => { + afterEach(async () => { + vi.useRealTimers(); + await Promise.all([...clients].map((client) => client.stop())); + clients.clear(); + }); + + it("does not time out a long RPC command by default", async () => { + const client = await createClient(); + vi.useFakeTimers(); + const result = client.bash("sleep 120"); + + await vi.advanceTimersByTimeAsync(120_000); + expect(await Promise.race([result, Promise.resolve("pending")])).toBe("pending"); + + client["handleLine"]( + JSON.stringify({ + id: "req_1", + type: "response", + command: "bash", + success: true, + data: { output: "done", exitCode: 0, cancelled: false, truncated: false }, + }), + ); + await expect(result).resolves.toMatchObject({ output: "done", exitCode: 0 }); + }); + + it("rejects promptAndWait when the prompt response fails", async () => { + const client = await createClient(); + const result = expect(client.promptAndWait("rejected prompt")).rejects.toThrow("prompt rejected"); + + client["handleLine"]( + JSON.stringify({ + id: "req_1", + type: "response", + command: "prompt", + success: false, + error: "prompt rejected", + }), + ); + + await result; + expect(client["pendingEventWaiters"].size).toBe(0); + }); + + it("does not time out agent completion by default", async () => { + const client = await createClient(); + vi.useFakeTimers(); + const idle = client.waitForIdle(); + const events = client.collectEvents(); + + await vi.advanceTimersByTimeAsync(120_000); + expect(await Promise.race([idle, Promise.resolve("pending")])).toBe("pending"); + expect(await Promise.race([events, Promise.resolve("pending")])).toBe("pending"); + + client["handleLine"](JSON.stringify({ type: "agent_end" })); + await expect(idle).resolves.toBeUndefined(); + await expect(events).resolves.toEqual([{ type: "agent_end" }]); + }); + + it("waits for child close before restarting", async () => { + const client = await createClient(); + + await client.stop(); + await client.start(); + const state = client.getState(); + client["handleLine"]( + JSON.stringify({ id: "req_1", type: "response", command: "get_state", success: true, data: {} }), + ); + + await expect(state).resolves.toEqual({}); + }); + + it("rejects start when the child cannot spawn", async () => { + const client = new RpcClient({ cliPath: fixturePath, env: { PATH: "" } }); + + await expect(client.start()).rejects.toThrow("RPC process error"); + }); + + it("rejects pending commands and completion waits when the child output closes", async () => { + const client = await createClient(); + const child = client["process"]; + if (!child) throw new Error("RPC child did not start"); + const command = expect(client.getState()).rejects.toThrow("RPC process output closed"); + const idle = expect(client.waitForIdle()).rejects.toThrow("RPC process output closed"); + const events = expect(client.collectEvents()).rejects.toThrow("RPC process output closed"); + + child.kill("SIGTERM"); + + await Promise.all([command, idle, events]); + clients.delete(client); + }); +}); 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..ac5f775e5f 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,51 @@ 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("treats session-owned queued inputs as queued work after compaction", async () => { const harness = await createHarness({ settings: { compaction: { keepRecentTokens: 1 } }, @@ -875,12 +921,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 +959,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 +972,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 +985,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 +998,21 @@ 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("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",