Skip to content

Commit c9e22e0

Browse files
committed
test(app): assert reopened rows leave the list; supersede SPEC-29 Decision 6
CodeRabbit review on PR #157. Both reopen tests asserted only that the request went out: their fakes returned the same closed list forever, so the tests passed even if the row never left the view — which is precisely the user-visible failure worth guarding. Each fake now behaves like the server and drops a reopened session from `session.listClosed`, and both tests assert the row is gone (mobile ClosedScreen and the desktop closed sidebar). SPEC-29's Decision 6 still specified `session.archive`, the `archived` flag and a bare `adapter.kill()` while the amendment at the top of the spec described the close/reopen contract — one spec, two contradictory lifecycles. Decision 6 is now marked superseded and restates the shipped contract: graceful agent-side release then a verified reap, queued input dropped, teardown serialized against input, `closed` persisted, `session.listClosed`/`session.reopen`, transparent reopen on a message, `sub` never respawning, and the idle-sweep guards that keep every auto-close reversible.
1 parent 3d76830 commit c9e22e0

3 files changed

Lines changed: 66 additions & 17 deletions

File tree

app/test/desktop/chat/closed_sidebar_view_test.dart

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,25 @@ class _ClosedConn extends ConnectionController {
1919
_ClosedConn(this.closed) : super(const _NoStore());
2020
final List<Map<String, dynamic>> closed;
2121
final sent = <Map<String, dynamic>>[];
22+
23+
/// Ids this fake has reopened — they must stop being reported as closed, as
24+
/// the real server does, or a reopen test passes even when the row never left.
25+
final reopened = <String>{};
26+
2227
@override
2328
Future<Map<String, dynamic>> request(MsgType t, Map<String, dynamic> body) {
2429
sent.add(body);
30+
if (body['kind'] == 'session.reopen') {
31+
reopened.add(body['sessionId'] as String);
32+
return Future.value(const {});
33+
}
2534
if (body['kind'] == 'session.listClosed') {
26-
return Future.value({'sessions': closed});
35+
return Future.value({
36+
'sessions': [
37+
for (final s in closed)
38+
if (!reopened.contains(s['id'])) s,
39+
],
40+
});
2741
}
2842
return Future.value(const {});
2943
}
@@ -168,6 +182,13 @@ void main() {
168182
orElse: () => const {},
169183
);
170184
expect(u['sessionId'], 's1');
185+
// What the user actually sees: the row must leave the list, not merely the
186+
// request go out.
187+
expect(
188+
find.text('Adapter resume'),
189+
findsNothing,
190+
reason: 'the reopened row must leave the closed list',
191+
);
171192
});
172193

173194
testWidgets('Reopen reloads the list without surfacing an error', (

app/test/ui/home/closed_screen_test.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@ class _FakeStore extends StoreController {
3636
// FutureBuilder as a future error rather than before it can subscribe.
3737
await Future<void>.delayed(Duration.zero);
3838
if (throws) throw StateError('offline');
39-
return closed;
39+
// Behave like the server: a reopened session is no longer closed, so it must
40+
// leave this list. Returning `closed` verbatim let the reopen test pass even
41+
// if the row never disappeared.
42+
return [
43+
for (final session in closed)
44+
if (!restored.contains(session.id)) session,
45+
];
4046
}
4147

4248
@override
@@ -128,6 +134,11 @@ void main() {
128134
await tester.pumpAndSettle();
129135

130136
expect(store.restored, ['a']);
137+
expect(
138+
find.text('sess-a'),
139+
findsNothing,
140+
reason: 'the reopened row must leave the list',
141+
);
131142
// Reloaded so the restored row leaves the closed list.
132143
expect(store.loadCount, 2);
133144
});

docs/specs/2026-07-26-SPEC-29-session-lifecycle-resume-list-delete.md

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -140,21 +140,38 @@ compatible**.
140140

141141
## Decisions (frozen)
142142

143-
> **Decision 6 UPDATED (2026-07-26) — archive, don't hard-delete.** The primary
144-
> destructive action is **archive** (recoverable), not delete. `session.archive`
145-
> sets a persisted `archived` flag on the makit session, stops the live agent
146-
> (`adapter.kill()`), and swaps in the `DetachedAdapter`. It is **makit-side
147-
> only** — it does **not** call any back-end native archive (codex
148-
> `thread/archive` is deliberately avoided so the underlying thread stays
149-
> directly resumable; the transcript + event log + resume handle are **kept**,
150-
> never destroyed). Archived sessions are **excluded from the active session
151-
> list** (`listSessions()``sessions.snapshot`) but survive a restart
152-
> (rehydration keeps them archived) and are restored with `session.unarchive`
153-
> (clears the flag; the session stays cold until the next subscribe re-attaches
154-
> it). No rows are deleted from the event log. A truly permanent hard-delete
155-
> (SQL delete + agent `thread/delete`/`session/delete`) is deferred to a
156-
> separate explicit
157-
> "delete permanently" action and is NOT wired in this pass.
143+
> **Decision 6 SUPERSEDED (2026-08-11) — close (release the agent), don't
144+
> archive.** The 2026-07-26 revision of this decision specified `session.archive`
145+
> / `session.unarchive`, a persisted `archived` flag, and "stops the live agent
146+
> (`adapter.kill()`)". All of it is replaced; see the AMENDMENT at the top of this
147+
> spec for why (one unverified SIGTERM left agents resident for days).
148+
>
149+
> The primary non-destructive action is **close** (recoverable) — not delete, and
150+
> no longer "archive". `session.close` releases the agent in two required steps:
151+
> the back end's own primitive (ACP `session/close` gated on
152+
> `sessionCapabilities.close`; codex `thread/unsubscribe`), then a **verified**
153+
> process reap (`SIGTERM``SIGKILL` after a grace period). Both are required
154+
> because makit runs one agent process per session, so the agent-side release
155+
> alone reclaims no memory. The manager bounds and swallows the graceful step,
156+
> drops pending mid-turn input ("stop means stop", SPEC-35), and serializes
157+
> teardown against input, then swaps in the `DetachedAdapter` and sets the
158+
> persisted **`closed`** flag.
159+
>
160+
> It stays **makit-side only** for the *record*: no back-end native archive is
161+
> called (codex `thread/archive` is deliberately avoided so the underlying
162+
> session/thread stays directly resumable and listable; the transcript + event log
163+
> + resume handle are **kept**, never destroyed). Closed sessions are **excluded
164+
> from the active session list** (`listSessions()``sessions.snapshot`), survive
165+
> a restart (rehydration keeps them closed), are enumerated by
166+
> `session.listClosed`, and come back via `session.reopen` — or transparently, by
167+
> sending a message (`ensureLiveForInput`). Reading a closed transcript (`sub`)
168+
> deliberately does **not** respawn an agent. Sessions idle beyond
169+
> `MAKIT_IDLE_CLOSE_MIN` (default 60, `0` disables) close automatically; that
170+
> sweep never touches a session that is busy, awaiting the user, a draft, already
171+
> cold, or not resumable, so every auto-close is reversible. A truly permanent
172+
> hard-delete (SQL delete + agent `thread/delete`/`session/delete`) remains
173+
> deferred to a separate explicit "delete permanently" action and is NOT wired in
174+
> this pass.
158175
159176
1. **Capability negotiation lives on the adapter, not on `agent === "pi"`.**
160177
The `AgentAdapter` seam gains a `capabilities()` (or `readonly capabilities`)

0 commit comments

Comments
 (0)