Skip to content

Commit da48903

Browse files
authored
fix: resume opens the right session and banner Resume checks the prompt (INC-487) (#540)
* test: cover same-directory resume history and unrecorded session-end (INC-487) * fix: keep per-pane resume history, ignore unrecorded session ends, check the prompt before banner Resume (INC-487)
1 parent 003595a commit da48903

7 files changed

Lines changed: 60 additions & 11 deletions

‎CLI/CLI+AgentResume.swift‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ private struct AgentLastSessionsFile: Codable, Equatable {
1818
}
1919

2020
/// `~/.programa/agent-last-sessions.json`: newest first, at most one entry per surface and
21-
/// per cwd, entries older than 7 days dropped. Shared by every agent provider's hook.
21+
/// provider, entries older than 7 days dropped. Shared by every agent provider's hook.
2222
final class AgentLastSessionStore {
2323
private static let defaultPath = "~/.programa/agent-last-sessions.json"
2424
private static let maxAgeSeconds: TimeInterval = 60 * 60 * 24 * 7
@@ -50,10 +50,10 @@ final class AgentLastSessionStore {
5050
defer { _ = flock(fd, LOCK_UN) }
5151

5252
// An unreadable file is rebuilt: it only caches ids for `programa resume`.
53+
// Not keyed by cwd: two panes in one directory must keep each other's sessions.
5354
let others = (load()?.sessions ?? []).filter { existing in
5455
existing.sessionId != entry.sessionId
55-
&& (entry.surfaceId == nil || existing.surfaceId != entry.surfaceId)
56-
&& (entry.cwd == nil || existing.cwd != entry.cwd)
56+
&& (entry.surfaceId == nil || existing.surfaceId != entry.surfaceId || existing.provider != entry.provider)
5757
}
5858
let file = AgentLastSessionsFile(sessions: Array(pruned([entry] + others).prefix(Self.maxEntries)))
5959
let encoder = JSONEncoder()

‎CLI/CLI+Hooks.swift‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,11 @@ final class ClaudeHookSessionStore {
144144
let normalizedWorkspace = normalizeOptional(workspaceId)
145145
let normalizedSurface = normalizeOptional(surfaceId)
146146
return try withLockedState { state in
147-
if let normalizedSessionId,
148-
let removed = state.sessions.removeValue(forKey: normalizedSessionId) {
149-
return removed
147+
// An unrecorded id must not fall back: the surface's newest record may be a newer
148+
// live session (Claude /clear ends the old id after the new one started).
149+
if let normalizedSessionId {
150+
return state.sessions.removeValue(forKey: normalizedSessionId)
150151
}
151-
152152
guard let fallback = fallbackRecord(
153153
sessions: Array(state.sessions.values),
154154
workspaceId: normalizedWorkspace,

‎Sources/AgentInterruptedBanner.swift‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,18 @@ extension Workspace {
8787
return line
8888
}
8989

90+
/// Whether a resume line may be typed into the panel: no agent runs there and shell
91+
/// integration reports the shell at its prompt, so the line cannot land in vim or ssh.
92+
func panelCanTypeAgentResume(panelId: UUID) -> Bool {
93+
panelAgentPresence[panelId] == nil && panelShellActivityStates[panelId] == .promptIdle
94+
}
95+
9096
func handleInterruptedAgentAction(_ action: AgentInterruptedBannerAction, panelId: UUID) {
9197
guard let state = interruptedAgentPanels[panelId] else { return }
9298
switch (action, state) {
9399
case (.resume, .shellAlive):
94100
guard let terminalPanel = terminalPanel(for: panelId),
101+
panelCanTypeAgentResume(panelId: panelId),
95102
let line = agentResumeLine(panelId: panelId) else {
96103
NSSound.beep()
97104
return

‎Sources/CommandPaletteView+Commands.swift‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -707,8 +707,7 @@ extension CommandPaletteView {
707707
// Re-checked here: the agent may have started since the palette opened.
708708
guard let context = Self.focusedPanelContext(in: tabManager),
709709
let terminalPanel = context.panel as? TerminalPanel,
710-
context.workspace.panelAgentPresence[context.panelId] == nil,
711-
context.workspace.panelShellActivityStates[context.panelId] == .promptIdle,
710+
context.workspace.panelCanTypeAgentResume(panelId: context.panelId),
712711
let line = context.workspace.agentResumeLine(panelId: context.panelId) else {
713712
NSSound.beep()
714713
return

‎docs/agent-usage.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ The `claude-hook` and `codex-hook` commands keep one record per live session in
6666
`session_id`, with `workspaceId`, `surfaceId`, `cwd` and `transcriptPath`. Records expire 7 days
6767
after their last update. `session-end` removes the live record and adds the session to
6868
`~/.programa/agent-last-sessions.json`, which keeps at most one ended session per pane and per
69-
directory, 50 entries, for 7 days. Ended sessions that a newer one replaced are no longer known.
69+
agent, 50 entries, for 7 days. Ended sessions that a newer one replaced are no longer known.
7070
OpenCode hooks pass no transcript, so OpenCode sessions have no token counts.
7171

7272
## `programa usage`

‎docs/workspace-interface.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ and the panel says why.
234234
## Resume an agent session
235235

236236
When an agent exits in a pane, its session-end hook keeps the session id in
237-
`~/.programa/agent-last-sessions.json`, one entry per pane and per directory, for 7 days.
237+
`~/.programa/agent-last-sessions.json`, one entry per pane and per agent, for 7 days.
238238
`programa resume` types that agent's resume command into the pane's shell and presses Enter:
239239

240240
```sh

‎tests/test_cli_agent_resume.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,49 @@ def test_opencode_session_end_without_an_id_keeps_the_pane_session(self) -> None
191191
self.assertEqual(printed.stdout.strip(), f"opencode --session '{OPENCODE_SESSION_ID}'")
192192
self.assertEqual(sent(rec), [], "--print must not type anything")
193193

194+
def write_hook_sessions(self, *records: tuple[str, str]) -> None:
195+
now = time.time()
196+
self.box.hook_state.write_text(json.dumps({"version": 1, "sessions": {session_id: {
197+
"sessionId": session_id, "workspaceId": WORKSPACE_ID, "surfaceId": surface_id,
198+
"cwd": str(self.box.project), "startedAt": now, "updatedAt": now,
199+
} for session_id, surface_id in records}}), encoding="utf-8")
200+
201+
def test_panes_in_the_same_directory_keep_each_others_ended_sessions(self) -> None:
202+
# Pane A's session ends, then pane B's in the same directory; A must still resume its own.
203+
other_session = "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
204+
self.write_hook_sessions((SESSION_ID, SURFACE_ID), (other_session, OTHER_SURFACE_ID))
205+
with self.recorder() as rec:
206+
for session_id in (SESSION_ID, other_session):
207+
payload = json.dumps({"session_id": session_id, "cwd": str(self.box.project)})
208+
ended = self.box.cli(rec, ["claude-hook", "session-end"], input_text=payload)
209+
self.assertEqual(ended.returncode, 0, merged_output(ended))
210+
printed = self.box.cli(rec, ["resume", "--print", "--surface", SURFACE_ID])
211+
kept = json.loads(self.box.last_sessions.read_text(encoding="utf-8"))["sessions"]
212+
self.assertEqual(
213+
sorted((e["surfaceId"], e["sessionId"]) for e in kept),
214+
sorted([(SURFACE_ID, SESSION_ID), (OTHER_SURFACE_ID, other_session)]),
215+
kept,
216+
)
217+
self.assertEqual(printed.returncode, 0, merged_output(printed))
218+
self.assertEqual(printed.stdout.strip(), f"claude --resume '{SESSION_ID}'")
219+
220+
def test_ending_an_unrecorded_session_leaves_the_panes_live_session_alone(self) -> None:
221+
# Claude /clear starts a new session in the pane before the old one's SessionEnd runs;
222+
# an old id with no live record must not consume the new session's record.
223+
live_session = "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f"
224+
self.write_hook_sessions((live_session, SURFACE_ID))
225+
payload = json.dumps({"session_id": SESSION_ID, "cwd": str(self.box.project)})
226+
with self.recorder() as rec:
227+
ended = self.box.cli(rec, ["claude-hook", "session-end"], input_text=payload)
228+
self.assertEqual(ended.returncode, 0, merged_output(ended))
229+
live = json.loads(self.box.hook_state.read_text(encoding="utf-8"))["sessions"]
230+
self.assertIn(live_session, live, "the newer live session must keep its record")
231+
exits = [
232+
f for f in rec.frames
233+
if f.get("method") == "agent.event" and f.get("params", {}).get("event_type") == "session.exited"
234+
]
235+
self.assertEqual(exits, [], "the live session's agent state must not be cleared")
236+
194237
def test_refuses_while_an_agent_runs_in_the_pane(self) -> None:
195238
self.box.write_last_sessions(self.box.entry())
196239
for state in ("working", "idle"):

0 commit comments

Comments
 (0)