From 6d33028271bea50943c1d8162c0bc644df6c0a68 Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 4 Nov 2026 18:29:22 -0300 Subject: [PATCH 1/2] test: cover same-directory resume history and unrecorded session-end (INC-487) --- tests/test_cli_agent_resume.py | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_cli_agent_resume.py b/tests/test_cli_agent_resume.py index bcfe6b07..86d51e32 100644 --- a/tests/test_cli_agent_resume.py +++ b/tests/test_cli_agent_resume.py @@ -191,6 +191,49 @@ def test_opencode_session_end_without_an_id_keeps_the_pane_session(self) -> None self.assertEqual(printed.stdout.strip(), f"opencode --session '{OPENCODE_SESSION_ID}'") self.assertEqual(sent(rec), [], "--print must not type anything") + def write_hook_sessions(self, *records: tuple[str, str]) -> None: + now = time.time() + self.box.hook_state.write_text(json.dumps({"version": 1, "sessions": {session_id: { + "sessionId": session_id, "workspaceId": WORKSPACE_ID, "surfaceId": surface_id, + "cwd": str(self.box.project), "startedAt": now, "updatedAt": now, + } for session_id, surface_id in records}}), encoding="utf-8") + + def test_panes_in_the_same_directory_keep_each_others_ended_sessions(self) -> None: + # Pane A's session ends, then pane B's in the same directory; A must still resume its own. + other_session = "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" + self.write_hook_sessions((SESSION_ID, SURFACE_ID), (other_session, OTHER_SURFACE_ID)) + with self.recorder() as rec: + for session_id in (SESSION_ID, other_session): + payload = json.dumps({"session_id": session_id, "cwd": str(self.box.project)}) + ended = self.box.cli(rec, ["claude-hook", "session-end"], input_text=payload) + self.assertEqual(ended.returncode, 0, merged_output(ended)) + printed = self.box.cli(rec, ["resume", "--print", "--surface", SURFACE_ID]) + kept = json.loads(self.box.last_sessions.read_text(encoding="utf-8"))["sessions"] + self.assertEqual( + sorted((e["surfaceId"], e["sessionId"]) for e in kept), + sorted([(SURFACE_ID, SESSION_ID), (OTHER_SURFACE_ID, other_session)]), + kept, + ) + self.assertEqual(printed.returncode, 0, merged_output(printed)) + self.assertEqual(printed.stdout.strip(), f"claude --resume '{SESSION_ID}'") + + def test_ending_an_unrecorded_session_leaves_the_panes_live_session_alone(self) -> None: + # Claude /clear starts a new session in the pane before the old one's SessionEnd runs; + # an old id with no live record must not consume the new session's record. + live_session = "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f" + self.write_hook_sessions((live_session, SURFACE_ID)) + payload = json.dumps({"session_id": SESSION_ID, "cwd": str(self.box.project)}) + with self.recorder() as rec: + ended = self.box.cli(rec, ["claude-hook", "session-end"], input_text=payload) + self.assertEqual(ended.returncode, 0, merged_output(ended)) + live = json.loads(self.box.hook_state.read_text(encoding="utf-8"))["sessions"] + self.assertIn(live_session, live, "the newer live session must keep its record") + exits = [ + f for f in rec.frames + if f.get("method") == "agent.event" and f.get("params", {}).get("event_type") == "session.exited" + ] + self.assertEqual(exits, [], "the live session's agent state must not be cleared") + def test_refuses_while_an_agent_runs_in_the_pane(self) -> None: self.box.write_last_sessions(self.box.entry()) for state in ("working", "idle"): From 7a93207a5896247ed600a23c8122c763864b0a75 Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 4 Nov 2026 18:29:22 -0300 Subject: [PATCH 2/2] fix: keep per-pane resume history, ignore unrecorded session ends, check the prompt before banner Resume (INC-487) --- CLI/CLI+AgentResume.swift | 6 +++--- CLI/CLI+Hooks.swift | 8 ++++---- Sources/AgentInterruptedBanner.swift | 7 +++++++ Sources/CommandPaletteView+Commands.swift | 3 +-- docs/agent-usage.md | 2 +- docs/workspace-interface.md | 2 +- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/CLI/CLI+AgentResume.swift b/CLI/CLI+AgentResume.swift index 5cf1a743..956f18bf 100644 --- a/CLI/CLI+AgentResume.swift +++ b/CLI/CLI+AgentResume.swift @@ -18,7 +18,7 @@ private struct AgentLastSessionsFile: Codable, Equatable { } /// `~/.programa/agent-last-sessions.json`: newest first, at most one entry per surface and -/// per cwd, entries older than 7 days dropped. Shared by every agent provider's hook. +/// provider, entries older than 7 days dropped. Shared by every agent provider's hook. final class AgentLastSessionStore { private static let defaultPath = "~/.programa/agent-last-sessions.json" private static let maxAgeSeconds: TimeInterval = 60 * 60 * 24 * 7 @@ -50,10 +50,10 @@ final class AgentLastSessionStore { defer { _ = flock(fd, LOCK_UN) } // An unreadable file is rebuilt: it only caches ids for `programa resume`. + // Not keyed by cwd: two panes in one directory must keep each other's sessions. let others = (load()?.sessions ?? []).filter { existing in existing.sessionId != entry.sessionId - && (entry.surfaceId == nil || existing.surfaceId != entry.surfaceId) - && (entry.cwd == nil || existing.cwd != entry.cwd) + && (entry.surfaceId == nil || existing.surfaceId != entry.surfaceId || existing.provider != entry.provider) } let file = AgentLastSessionsFile(sessions: Array(pruned([entry] + others).prefix(Self.maxEntries))) let encoder = JSONEncoder() diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 7e8debe6..a6381e7d 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -144,11 +144,11 @@ final class ClaudeHookSessionStore { let normalizedWorkspace = normalizeOptional(workspaceId) let normalizedSurface = normalizeOptional(surfaceId) return try withLockedState { state in - if let normalizedSessionId, - let removed = state.sessions.removeValue(forKey: normalizedSessionId) { - return removed + // An unrecorded id must not fall back: the surface's newest record may be a newer + // live session (Claude /clear ends the old id after the new one started). + if let normalizedSessionId { + return state.sessions.removeValue(forKey: normalizedSessionId) } - guard let fallback = fallbackRecord( sessions: Array(state.sessions.values), workspaceId: normalizedWorkspace, diff --git a/Sources/AgentInterruptedBanner.swift b/Sources/AgentInterruptedBanner.swift index 5d492fc1..1162f676 100644 --- a/Sources/AgentInterruptedBanner.swift +++ b/Sources/AgentInterruptedBanner.swift @@ -87,11 +87,18 @@ extension Workspace { return line } + /// Whether a resume line may be typed into the panel: no agent runs there and shell + /// integration reports the shell at its prompt, so the line cannot land in vim or ssh. + func panelCanTypeAgentResume(panelId: UUID) -> Bool { + panelAgentPresence[panelId] == nil && panelShellActivityStates[panelId] == .promptIdle + } + func handleInterruptedAgentAction(_ action: AgentInterruptedBannerAction, panelId: UUID) { guard let state = interruptedAgentPanels[panelId] else { return } switch (action, state) { case (.resume, .shellAlive): guard let terminalPanel = terminalPanel(for: panelId), + panelCanTypeAgentResume(panelId: panelId), let line = agentResumeLine(panelId: panelId) else { NSSound.beep() return diff --git a/Sources/CommandPaletteView+Commands.swift b/Sources/CommandPaletteView+Commands.swift index 995cf9e1..dcb57d31 100644 --- a/Sources/CommandPaletteView+Commands.swift +++ b/Sources/CommandPaletteView+Commands.swift @@ -707,8 +707,7 @@ extension CommandPaletteView { // Re-checked here: the agent may have started since the palette opened. guard let context = Self.focusedPanelContext(in: tabManager), let terminalPanel = context.panel as? TerminalPanel, - context.workspace.panelAgentPresence[context.panelId] == nil, - context.workspace.panelShellActivityStates[context.panelId] == .promptIdle, + context.workspace.panelCanTypeAgentResume(panelId: context.panelId), let line = context.workspace.agentResumeLine(panelId: context.panelId) else { NSSound.beep() return diff --git a/docs/agent-usage.md b/docs/agent-usage.md index 7faeda21..a6df650b 100644 --- a/docs/agent-usage.md +++ b/docs/agent-usage.md @@ -66,7 +66,7 @@ The `claude-hook` and `codex-hook` commands keep one record per live session in `session_id`, with `workspaceId`, `surfaceId`, `cwd` and `transcriptPath`. Records expire 7 days after their last update. `session-end` removes the live record and adds the session to `~/.programa/agent-last-sessions.json`, which keeps at most one ended session per pane and per -directory, 50 entries, for 7 days. Ended sessions that a newer one replaced are no longer known. +agent, 50 entries, for 7 days. Ended sessions that a newer one replaced are no longer known. OpenCode hooks pass no transcript, so OpenCode sessions have no token counts. ## `programa usage` diff --git a/docs/workspace-interface.md b/docs/workspace-interface.md index 1e9458f3..069859e3 100644 --- a/docs/workspace-interface.md +++ b/docs/workspace-interface.md @@ -234,7 +234,7 @@ and the panel says why. ## Resume an agent session When an agent exits in a pane, its session-end hook keeps the session id in -`~/.programa/agent-last-sessions.json`, one entry per pane and per directory, for 7 days. +`~/.programa/agent-last-sessions.json`, one entry per pane and per agent, for 7 days. `programa resume` types that agent's resume command into the pane's shell and presses Enter: ```sh