Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
148 commits
Select commit Hold shift + click to select a range
fef6bdd
[Spec 1313] Initial specification draft
mohidmakhdoomi Aug 1, 2026
b0bc028
chore(porch): 1313 init spir
mohidmakhdoomi Aug 1, 2026
c726f0e
chore(porch): 1313 specify build-complete
mohidmakhdoomi Aug 1, 2026
c483f88
[Spec 1313] Specification with multi-agent review
mohidmakhdoomi Aug 1, 2026
ba58c14
chore(porch): 1313 spec-approval gate-requested
mohidmakhdoomi Aug 1, 2026
6697add
[Spec 1313] Rebuttal to iteration-1 spec consultation
mohidmakhdoomi Aug 1, 2026
c0ef095
[Spec 1313] Thread: record spec-approval gate reached (specify phase …
mohidmakhdoomi Aug 1, 2026
444cd76
chore(porch): 1313 spec-approval gate-approved
mohidmakhdoomi Aug 1, 2026
44c8a6e
chore(porch): 1313 plan phase-transition
mohidmakhdoomi Aug 1, 2026
0884add
[Spec 1313] Initial implementation plan
mohidmakhdoomi Aug 1, 2026
89b8753
chore(porch): 1313 plan build-complete
mohidmakhdoomi Aug 1, 2026
5989fa4
[Spec 1313] Plan with multi-agent review
mohidmakhdoomi Aug 1, 2026
17c83be
chore(porch): 1313 plan re-iter (iter 2)
mohidmakhdoomi Aug 1, 2026
33aba39
chore(porch): 1313 plan build-complete
mohidmakhdoomi Aug 1, 2026
0d2855e
[Spec 1313] Plan iter-2: implementation-seam fixes
mohidmakhdoomi Aug 1, 2026
a486cad
chore(porch): 1313 plan-approval gate-requested
mohidmakhdoomi Aug 1, 2026
56da958
[Spec 1313] Thread: plan-approval gate reached (plan phase complete)
mohidmakhdoomi Aug 1, 2026
c35142a
[Spec 1313] Plan iter-2 consultation context (porch artifact)
mohidmakhdoomi Aug 1, 2026
36582b2
chore(porch): 1313 plan-approval gate-approved
mohidmakhdoomi Aug 1, 2026
5f9f9b5
chore(porch): 1313 implement phase-transition
mohidmakhdoomi Aug 1, 2026
aa51e85
[Spec 1313][Phase: mailbox-persistence] feat: Add mailbox persistence…
mohidmakhdoomi Aug 1, 2026
d4e291d
[Spec 1313] Thread: Phase 1 (mailbox store) implemented + verified green
mohidmakhdoomi Aug 1, 2026
66e35eb
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
638d568
chore(porch): 1313 advance plan phase → phase_2
mohidmakhdoomi Aug 1, 2026
3a79651
[Spec 1313][Phase: render-gate] feat: Add rendered-empty gate + claud…
mohidmakhdoomi Aug 1, 2026
2803f1f
[Spec 1313] Thread: Phase 2 (render-gate) committed + verified green
mohidmakhdoomi Aug 1, 2026
0d81411
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
9cc8d85
[Spec 1313][Phase: render-gate] fix: address iter-1 review (claude-pi…
mohidmakhdoomi Aug 1, 2026
041203d
[Spec 1313] Phase 2 iter-1 rebuttal (porch artifact)
mohidmakhdoomi Aug 1, 2026
661c4bf
[Spec 1313] Thread: Phase 2 iter-1 review addressed (claude-picker, p…
mohidmakhdoomi Aug 1, 2026
675c74a
chore(porch): 1313 implement re-iter (iter 2)
mohidmakhdoomi Aug 1, 2026
574fe17
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
57938ef
chore(porch): 1313 advance plan phase → phase_3
mohidmakhdoomi Aug 1, 2026
04b7959
[Spec 1313][Phase: agy-profile] feat: Add agy classifier profile (col…
mohidmakhdoomi Aug 1, 2026
98ae1b7
[Spec 1313] Thread: Phase 3 (agy profile) — measurement note + implem…
mohidmakhdoomi Aug 1, 2026
a0b5707
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
650ddf2
chore(porch): 1313 advance plan phase → phase_4
mohidmakhdoomi Aug 1, 2026
744eb3e
[Spec 1313] Thread: Phase 3 approved (unanimous); phase_4 scope recorded
mohidmakhdoomi Aug 1, 2026
365068e
[Spec 1313] Thread: Phase 4 design note (recovery anchor)
mohidmakhdoomi Aug 1, 2026
ff3b66e
[Spec 1313][Phase: delivery-orchestration] feat: mailbox-first send d…
mohidmakhdoomi Aug 1, 2026
7988a06
[Spec 1313] Thread: Phase 4 committed — e2e root-caused (pre-existing…
mohidmakhdoomi Aug 1, 2026
188c087
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
cd4a4cf
[Spec 1313][Phase: delivery-orchestration] fix: address phase-4 revie…
mohidmakhdoomi Aug 1, 2026
a62c25b
[Spec 1313] Thread: Phase 4 review iter1 — 3 Codex fixes landed (rete…
mohidmakhdoomi Aug 1, 2026
c2b6590
chore(porch): 1313 implement re-iter (iter 2)
mohidmakhdoomi Aug 1, 2026
e4a4e45
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
5bcdd8e
chore(porch): 1313 advance plan phase → phase_5
mohidmakhdoomi Aug 1, 2026
764a0e0
[Spec 1313] Thread: Phase 4 approved (unanimous iter2); starting phase_5
mohidmakhdoomi Aug 1, 2026
62855a8
[Spec 1313][Phase: fast-delivery-triggers] feat: submit + quiescence …
mohidmakhdoomi Aug 1, 2026
fcfeffd
[Spec 1313] Thread: Phase 5 implemented (submit+quiescence triggers);…
mohidmakhdoomi Aug 1, 2026
bcae48d
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
0fc2655
[Spec 1313][Phase: fast-delivery-triggers] fix: consolidate user-inpu…
mohidmakhdoomi Aug 1, 2026
0fe4df9
[Spec 1313] Thread: Phase 5 review iter1 — Codex fix landed (consolid…
mohidmakhdoomi Aug 1, 2026
3df3181
chore(porch): 1313 implement re-iter (iter 2)
mohidmakhdoomi Aug 1, 2026
eb21352
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
0287cf3
chore(porch): 1313 advance plan phase → phase_6
mohidmakhdoomi Aug 1, 2026
e38d892
[Spec 1313][Phase: cron-rerouting] feat: route cron delivery through …
mohidmakhdoomi Aug 1, 2026
267311e
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
92b6627
chore(porch): 1313 advance plan phase → phase_7
mohidmakhdoomi Aug 1, 2026
8ba22a0
[Spec 1313][Phase: inbox-broadcasts-escalation] feat: afx inbox CLI, …
mohidmakhdoomi Aug 1, 2026
2a817ab
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
9c9ef34
chore(porch): 1313 implement re-iter (iter 2)
mohidmakhdoomi Aug 1, 2026
18ba65b
[Spec 1313][Phase: inbox-broadcasts-escalation] fix: address iter-1 C…
mohidmakhdoomi Aug 1, 2026
f98cff5
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
40ef8a5
chore(porch): 1313 implement re-iter (iter 3)
mohidmakhdoomi Aug 1, 2026
905f707
[Spec 1313][Phase: inbox-broadcasts-escalation] fix: scope `afx inbox…
mohidmakhdoomi Aug 1, 2026
2d7969b
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
af21e60
[Spec 1313][Phase: inbox-broadcasts-escalation] fix: guard `POST /api…
mohidmakhdoomi Aug 1, 2026
58bdb65
chore(porch): 1313 implement force-advance (safety ceiling reached at…
mohidmakhdoomi Aug 1, 2026
1691c0e
chore(porch): 1313 advance plan phase → phase_8
mohidmakhdoomi Aug 1, 2026
1451e20
[Spec 1313][Phase: dashboard-vscode-indicators] feat: held-count indi…
mohidmakhdoomi Aug 1, 2026
1610f64
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
01d5441
[Spec 1313][Phase: dashboard-vscode-indicators] test: Playwright dash…
mohidmakhdoomi Aug 1, 2026
413a976
chore(porch): 1313 implement re-iter (iter 2)
mohidmakhdoomi Aug 1, 2026
ee4fbf7
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
2b084e5
chore(porch): 1313 advance plan phase → phase_9
mohidmakhdoomi Aug 1, 2026
6ad5805
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
bdaca64
chore(porch): 1313 implement re-iter (iter 2)
mohidmakhdoomi Aug 1, 2026
a7c5f77
[Spec 1313][Phase: documentation-skeleton-mirror] docs: afx send outc…
mohidmakhdoomi Aug 1, 2026
41ea94b
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 1, 2026
d7dca56
chore(porch): 1313 all plan phases complete → review
mohidmakhdoomi Aug 1, 2026
9056e3b
[Spec 1313][Phase: review] docs: review doc + arch/lessons routing
mohidmakhdoomi Aug 1, 2026
960c4c8
chore(porch): 1313 review build-complete
mohidmakhdoomi Aug 1, 2026
bb2e087
[Spec 1313][Phase: review] fix: close two mailbox delivery races (ite…
mohidmakhdoomi Aug 1, 2026
fd83fa5
chore(porch): 1313 pr gate-requested
mohidmakhdoomi Aug 1, 2026
4640095
[Spec 1313][Phase: review] feat: add `afx inbox show <id>` (per-id bo…
mohidmakhdoomi Aug 1, 2026
7bb991a
chore(porch): 1313 pr gate-approved
mohidmakhdoomi Aug 1, 2026
50f4c47
chore(porch): 1313 verify phase-transition
mohidmakhdoomi Aug 1, 2026
f237203
chore(porch): 1313 verify-approval gate-requested
mohidmakhdoomi Aug 1, 2026
549f50c
[Spec 1313][Phase: review] test: CI-aware bound for render-gate seed-…
mohidmakhdoomi Aug 1, 2026
f59c719
[Spec 1313][Phase: review] fix: resolve afx send to architects (rende…
mohidmakhdoomi Aug 2, 2026
05bf08c
[Spec 1313][Phase: review] fix: honor TOWER_ARCHITECT_CMD in reconcil…
mohidmakhdoomi Aug 2, 2026
681b860
[Spec 1313][Phase: review] docs: record round-2/3 re-CMAP + migration…
mohidmakhdoomi Aug 2, 2026
e6d238b
[Spec 1313][Phase: review] fix: render-gate whole-ring hardening (cap…
mohidmakhdoomi Aug 2, 2026
4a5c21b
[Spec 1313][Phase: verify] docs: record live e2e ALL-PASS in builder …
mohidmakhdoomi Aug 2, 2026
6a50091
[Spec 1313] merge origin/main into builder/spir-1313 (resolve send-pa…
mohidmakhdoomi Aug 2, 2026
79507c0
chore(porch): 1313 rollback verify → implement
mohidmakhdoomi Aug 2, 2026
4e042ea
[Spec 1313][Phase: implement] fix: reconcile merge-integration test f…
mohidmakhdoomi Aug 3, 2026
6f92538
[Spec 1313][Phase: implement] feat: remove render-gate over-ceiling h…
mohidmakhdoomi Aug 3, 2026
44be6ba
[Spec 1313][Phase: implement] fix: address 3-way CMAP round 1 on over…
mohidmakhdoomi Aug 3, 2026
5bc7d56
[Spec 1313][Phase: implement] fix: address 3-way CMAP round 2 (verifi…
mohidmakhdoomi Aug 3, 2026
9ba8b5b
[Spec 1313][Phase: implement] fix: address 3-way CMAP round 3 (verify…
mohidmakhdoomi Aug 3, 2026
9c3ae2a
[Spec 1313][Phase: implement] fix: address 3-way CMAP round 4 (verify…
mohidmakhdoomi Aug 3, 2026
4e31f9b
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
25b3a98
chore(porch): 1313 advance plan phase → phase_2
mohidmakhdoomi Aug 3, 2026
444877f
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
fd902ea
chore(porch): 1313 implement re-iter (iter 2)
mohidmakhdoomi Aug 3, 2026
4b85c54
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
37d6642
chore(porch): 1313 advance plan phase → phase_3
mohidmakhdoomi Aug 3, 2026
8e3c865
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
486260e
chore(porch): 1313 advance plan phase → phase_4
mohidmakhdoomi Aug 3, 2026
81e18de
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
5fd15c1
chore(porch): 1313 implement re-iter (iter 2)
mohidmakhdoomi Aug 3, 2026
730e61f
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
6077847
chore(porch): 1313 advance plan phase → phase_5
mohidmakhdoomi Aug 3, 2026
9cd20aa
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
f0b451a
chore(porch): 1313 implement re-iter (iter 2)
mohidmakhdoomi Aug 3, 2026
875baba
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
1538ba1
chore(porch): 1313 advance plan phase → phase_6
mohidmakhdoomi Aug 3, 2026
1cefe53
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
943db48
chore(porch): 1313 advance plan phase → phase_7
mohidmakhdoomi Aug 3, 2026
273a0ad
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
78fff81
chore(porch): 1313 implement re-iter (iter 2)
mohidmakhdoomi Aug 3, 2026
71548cc
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
9913665
chore(porch): 1313 implement re-iter (iter 3)
mohidmakhdoomi Aug 3, 2026
80333e6
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
61680e1
chore(porch): 1313 implement force-advance (safety ceiling reached at…
mohidmakhdoomi Aug 3, 2026
3d80922
chore(porch): 1313 advance plan phase → phase_8
mohidmakhdoomi Aug 3, 2026
fcf70c7
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
0ee7f52
chore(porch): 1313 implement re-iter (iter 2)
mohidmakhdoomi Aug 3, 2026
102d928
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
5a5bf98
chore(porch): 1313 advance plan phase → phase_9
mohidmakhdoomi Aug 3, 2026
af55453
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
7babed7
chore(porch): 1313 implement re-iter (iter 2)
mohidmakhdoomi Aug 3, 2026
3697ca7
[Spec 1313][Phase: implement] docs: thread — resume for phase_9 finis…
mohidmakhdoomi Aug 3, 2026
a984a58
chore(porch): 1313 implement build-complete
mohidmakhdoomi Aug 3, 2026
93246d1
chore(porch): 1313 all plan phases complete → review
mohidmakhdoomi Aug 3, 2026
701ba10
[Spec 1313][Phase: review] docs: rewrite review from scratch + arch/l…
mohidmakhdoomi Aug 3, 2026
9a9dc52
chore(porch): 1313 review build-complete
mohidmakhdoomi Aug 3, 2026
4956aba
chore(porch): 1313 review re-iter (iter 2)
mohidmakhdoomi Aug 3, 2026
bd371f3
chore(porch): 1313 review build-complete
mohidmakhdoomi Aug 3, 2026
851b484
[Spec 1313][Phase: review] docs: address review round-2 COMMENT (Stat…
mohidmakhdoomi Aug 3, 2026
98cb13b
chore(porch): 1313 pr gate-requested
mohidmakhdoomi Aug 3, 2026
308f66a
[Spec 1313][Phase: review] docs: thread — STOPPED at pr gate (review …
mohidmakhdoomi Aug 3, 2026
becc6e1
[Spec 1313][Phase: review] fix: hold on dropped PTY write (silent-los…
mohidmakhdoomi Aug 3, 2026
7c4e72d
[Spec 1313][Phase: review] docs: thread — round-3 fix landed, re-veri…
mohidmakhdoomi Aug 3, 2026
f00322b
[Spec 1313][Phase: review] revert: restore spec-1280 T16 guard (1280 …
mohidmakhdoomi Aug 3, 2026
5dda0b3
[Spec 1313][Phase: review] docs: thread — spec-1280 T16 restored (128…
mohidmakhdoomi Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,15 @@ Agents within a workspace communicate through `afx send`. Four addressing forms
| `afx send architect:<name> "msg"` | Explicit per-architect addressing. **Architects (including `main`)**: open address grammar — any architect can address any other architect. This is the sibling-architect messaging form. **Builders**: allowed ONLY when `<name>` matches the builder's own `spawnedByArchitect`. Mismatches are rejected by the spoofing check at `tower-messages.ts:213-218`. From a builder, this is an explicit form of the affinity routing, NOT an override. | Any sender (with the spoofing constraint above for builders). |
| `afx send <workspace>:architect "msg"` | Cross-workspace addressing (e.g. `afx send marketmaker:architect "..."`). | Any sender. |

### Send outcomes: delivered vs held (Spec 1313)

`afx send` reports the real first outcome, not an unconditional success:

- **delivered** — written to the recipient's prompt after a clean render-gate pass (a verified-empty prompt).
- **held** — the prompt wasn't clear, so the message is persisted in Tower's durable mailbox and delivers automatically once the prompt is clean (after a submit, on output quiescence, or a poll backstop). The response carries a why-held reason — `busy` (a draft/menu/dialog/wrapper occupies the prompt), `no-profile` (unknown app; only `claude`, `codex`, and `agy` are modeled), or `no-live-pty` (no live terminal — delivers on respawn, since rows address agents not PTYs) — plus a mailbox id.

A held message is **never force-injected** onto a busy line, so it can't fuse with a half-typed draft, and held rows survive Tower restart/shutdown. See held mail with `afx inbox`, read one (including its body) with `afx inbox show <id>`, and clear one with `afx inbox dismiss <id>` (dismissal is CLI-only; the dashboard and VSCode held-count indicators are read-only). `afx send --interrupt` remains the explicit, deliberate bypass (it interrupts the agent and skips holding).

### Sibling-architect messaging

When a workspace hosts more than one architect (added via `afx workspace add-architect --name <name>`), sibling architects message each other via the `architect:<name>` form. Example:
Expand Down
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,15 @@ Agents within a workspace communicate through `afx send`. Four addressing forms
| `afx send architect:<name> "msg"` | Explicit per-architect addressing. **Architects (including `main`)**: open address grammar — any architect can address any other architect. This is the sibling-architect messaging form. **Builders**: allowed ONLY when `<name>` matches the builder's own `spawnedByArchitect`. Mismatches are rejected by the spoofing check at `tower-messages.ts:213-218`. From a builder, this is an explicit form of the affinity routing, NOT an override. | Any sender (with the spoofing constraint above for builders). |
| `afx send <workspace>:architect "msg"` | Cross-workspace addressing (e.g. `afx send marketmaker:architect "..."`). | Any sender. |

### Send outcomes: delivered vs held (Spec 1313)

`afx send` reports the real first outcome, not an unconditional success:

- **delivered** — written to the recipient's prompt after a clean render-gate pass (a verified-empty prompt).
- **held** — the prompt wasn't clear, so the message is persisted in Tower's durable mailbox and delivers automatically once the prompt is clean (after a submit, on output quiescence, or a poll backstop). The response carries a why-held reason — `busy` (a draft/menu/dialog/wrapper occupies the prompt), `no-profile` (unknown app; only `claude`, `codex`, and `agy` are modeled), or `no-live-pty` (no live terminal — delivers on respawn, since rows address agents not PTYs) — plus a mailbox id.

A held message is **never force-injected** onto a busy line, so it can't fuse with a half-typed draft, and held rows survive Tower restart/shutdown. See held mail with `afx inbox`, read one (including its body) with `afx inbox show <id>`, and clear one with `afx inbox dismiss <id>` (dismissal is CLI-only; the dashboard and VSCode held-count indicators are read-only). `afx send --interrupt` remains the explicit, deliberate bypass (it interrupts the agent and skips holding).

### Sibling-architect messaging

When a workspace hosts more than one architect (added via `afx workspace add-architect --name <name>`), sibling architects message each other via the `architect:<name>` form. Example:
Expand Down
5 changes: 5 additions & 0 deletions apps/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,11 @@
"default": true,
"description": "Show a VSCode notification toast when a builder reaches a human-approval gate (plan-approval, code-review, etc.)"
},
"codev.mailboxEscalationToasts.enabled": {
"type": "boolean",
"default": true,
"description": "Show a VSCode notification toast when a held afx-send message crosses the escalation age (default 60s). The persistent held-count status-bar indicator is unaffected by this toggle. Read or dismiss held messages with the `afx inbox` CLI."
},
"codev.overviewRefreshSeconds": {
"type": "number",
"default": 60,
Expand Down
131 changes: 131 additions & 0 deletions apps/vscode/src/__tests__/mailbox-escalation-toast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Spec 1313 Phase 8: unit tests for the `mailbox-escalation` toast handler.
* `vscode` is mocked (this is a `src/__tests__` vitest unit, not the Electron
* `src/test` harness); we drive the SSE callback the handler subscribes to and
* assert on `window.showWarningMessage`.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';

const h = vi.hoisted(() => ({
showWarningMessage: vi.fn(),
getBool: vi.fn((_key: string, dflt: boolean) => dflt),
}));

vi.mock('vscode', () => ({
window: { showWarningMessage: h.showWarningMessage },
workspace: {
getConfiguration: () => ({ get: (key: string, dflt: boolean) => h.getBool(key, dflt) }),
},
}));

const { activateMailboxEscalationToasts } = await import('../notifications/mailbox-escalation-toast.js');

type SSEHandler = (e: { type: string; data: string }) => void;

function makeCtx() {
return { subscriptions: [] as { dispose(): void }[] };
}

function makeConnectionManager(workspacePath: string | null) {
let handler: SSEHandler | null = null;
return {
getWorkspacePath: () => workspacePath,
onSSEEvent: (fn: SSEHandler) => {
handler = fn;
return { dispose() {} };
},
/** Simulate Tower pushing an SSE `data:` payload. */
fire: (data: string) => handler?.({ type: 'message', data }),
};
}

function escalationEvent(overrides: Record<string, unknown> = {}): string {
const payload = {
workspacePath: '/ws',
toAgent: 'spir-1',
mailboxId: 'mb1',
ageMs: 65_000,
reason: 'busy',
...overrides,
};
return JSON.stringify({ type: 'mailbox-escalation', body: JSON.stringify(payload) });
}

function activate(cm: ReturnType<typeof makeConnectionManager>) {
const ctx = makeCtx();
// Structural fakes stand in for vscode.ExtensionContext / ConnectionManager.
activateMailboxEscalationToasts(ctx as any, cm as any);
return ctx;
}

beforeEach(() => {
h.showWarningMessage.mockClear();
h.getBool.mockReset();
h.getBool.mockImplementation((_key: string, dflt: boolean) => dflt);
});

describe('activateMailboxEscalationToasts', () => {
it('raises a warning toast for a matching escalation, with metadata (no body)', () => {
const cm = makeConnectionManager('/ws');
activate(cm);
cm.fire(escalationEvent({ toAgent: 'architect:main', ageMs: 63_000, reason: 'busy' }));

expect(h.showWarningMessage).toHaveBeenCalledTimes(1);
const msg = h.showWarningMessage.mock.calls[0][0] as string;
expect(msg).toContain('architect:main');
expect(msg).toContain('63s');
expect(msg).toContain('afx inbox');
});

it('dedupes by mailboxId — a redelivered event does not re-toast', () => {
const cm = makeConnectionManager('/ws');
activate(cm);
cm.fire(escalationEvent({ mailboxId: 'dup' }));
cm.fire(escalationEvent({ mailboxId: 'dup' }));
expect(h.showWarningMessage).toHaveBeenCalledTimes(1);
});

it('toasts again for a different mailboxId', () => {
const cm = makeConnectionManager('/ws');
activate(cm);
cm.fire(escalationEvent({ mailboxId: 'a' }));
cm.fire(escalationEvent({ mailboxId: 'b' }));
expect(h.showWarningMessage).toHaveBeenCalledTimes(2);
});

it('ignores escalations for a different workspace on a shared Tower', () => {
const cm = makeConnectionManager('/ws');
activate(cm);
cm.fire(escalationEvent({ workspacePath: '/other' }));
expect(h.showWarningMessage).not.toHaveBeenCalled();
});

it('ignores non-escalation SSE envelope types', () => {
const cm = makeConnectionManager('/ws');
activate(cm);
cm.fire(JSON.stringify({ type: 'overview-changed', body: '{}' }));
expect(h.showWarningMessage).not.toHaveBeenCalled();
});

it('ignores malformed (non-JSON) SSE data without throwing', () => {
const cm = makeConnectionManager('/ws');
activate(cm);
expect(() => cm.fire('not-json')).not.toThrow();
expect(h.showWarningMessage).not.toHaveBeenCalled();
});

it('does not toast when disabled via codev.mailboxEscalationToasts.enabled', () => {
h.getBool.mockImplementation(() => false);
const cm = makeConnectionManager('/ws');
activate(cm);
cm.fire(escalationEvent());
expect(h.showWarningMessage).not.toHaveBeenCalled();
});

it('ignores a payload missing its mailboxId', () => {
const cm = makeConnectionManager('/ws');
activate(cm);
cm.fire(escalationEvent({ mailboxId: '' }));
expect(h.showWarningMessage).not.toHaveBeenCalled();
});
});
179 changes: 179 additions & 0 deletions apps/vscode/src/__tests__/mailbox-indicators.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Spec 1313 Phase 8: pure unit tests for the VSCode held-mail indicator
* helpers. No `vscode` mock — these are deliberately vscode-free so the
* count / tooltip / attention / toast-text math is testable in isolation.
*/
import { describe, it, expect } from 'vitest';
import {
heldStatusSegment,
heldTooltipClause,
heldBadgeCount,
composeStatusBarText,
composeActivityBadge,
escalationToastText,
escalationMatchesWorkspace,
} from '../mailbox-indicators.js';

function makePayload(overrides: Partial<{
workspacePath: string;
toAgent: string;
mailboxId: string;
ageMs: number;
reason: string | null;
}> = {}) {
return {
workspacePath: '/ws',
toAgent: 'spir-1',
mailboxId: 'mb1',
ageMs: 65_000,
reason: 'busy' as string | null,
...overrides,
};
}

describe('heldStatusSegment', () => {
it('is empty when nothing is held', () => {
expect(heldStatusSegment(0, false)).toBe('');
expect(heldStatusSegment(0, true)).toBe('');
});

it('is empty for a negative or absent count (defensive)', () => {
expect(heldStatusSegment(-1, false)).toBe('');
// Simulates an older Tower that omits the field (undefined at runtime).
expect(heldStatusSegment(undefined as unknown as number, false)).toBe('');
});

it('renders a mail-icon segment when held and not escalated', () => {
expect(heldStatusSegment(2, false)).toBe(' · $(mail) 2 held');
});

it('swaps to the warning icon when escalated (the attention state)', () => {
expect(heldStatusSegment(2, true)).toBe(' · $(warning) 2 held');
});
});

describe('heldTooltipClause', () => {
it('is empty when nothing is held', () => {
expect(heldTooltipClause(0)).toBe('');
expect(heldTooltipClause(-3)).toBe('');
});

it('is singular for one and plural for many', () => {
expect(heldTooltipClause(1)).toBe('1 held message');
expect(heldTooltipClause(4)).toBe('4 held messages');
});
});

describe('heldBadgeCount', () => {
it('clamps negatives and absent values to 0', () => {
expect(heldBadgeCount(-1)).toBe(0);
expect(heldBadgeCount(0)).toBe(0);
expect(heldBadgeCount(undefined as unknown as number)).toBe(0);
});

it('passes a positive count through unchanged', () => {
expect(heldBadgeCount(5)).toBe(5);
});
});

describe('composeStatusBarText', () => {
it('renders the base builder count with no extras when nothing needs attention', () => {
expect(composeStatusBarText(2, 0, 0, 0, false)).toBe('$(server) Codev: 2 builders');
});

it('appends blocked, waiting, and held segments in order', () => {
expect(composeStatusBarText(3, 1, 2, 4, false)).toBe(
'$(server) Codev: 3 builders · $(bell) 1 blocked · $(comment-discussion) 2 waiting · $(mail) 4 held',
);
});

it('uses the warning icon for the held segment when escalated', () => {
expect(composeStatusBarText(1, 0, 0, 2, true)).toBe('$(server) Codev: 1 builders · $(warning) 2 held');
});

it('omits the held segment entirely when nothing is held', () => {
expect(composeStatusBarText(5, 1, 0, 0, true)).toBe('$(server) Codev: 5 builders · $(bell) 1 blocked');
});
});

describe('composeActivityBadge', () => {
it('is undefined when nothing needs the user', () => {
expect(composeActivityBadge(0, 0, 0)).toBeUndefined();
// A negative/absent held count is clamped, so it cannot fabricate a badge.
expect(composeActivityBadge(0, 0, -2)).toBeUndefined();
});

it('folds held-only into the badge with a held tooltip', () => {
expect(composeActivityBadge(0, 0, 3)).toEqual({ value: 3, tooltip: '3 held messages' });
});

it('preserves the singular/plural blocked-only phrasing', () => {
expect(composeActivityBadge(1, 0, 0)).toEqual({ value: 1, tooltip: '1 builder blocked at a human-approval gate' });
expect(composeActivityBadge(2, 0, 0)).toEqual({ value: 2, tooltip: '2 builders blocked at human-approval gates' });
});

it('preserves the idle-only phrasing', () => {
expect(composeActivityBadge(0, 1, 0)).toEqual({ value: 1, tooltip: '1 builder waiting on input' });
});

it('combines blocked + idle with the compact phrasing', () => {
expect(composeActivityBadge(2, 3, 0)).toEqual({ value: 5, tooltip: '2 blocked, 3 waiting on input' });
});

it('folds held into blocked + idle and joins the clauses', () => {
expect(composeActivityBadge(1, 1, 2)).toEqual({
value: 4,
tooltip: '1 blocked, 1 waiting on input · 2 held messages',
});
expect(composeActivityBadge(2, 0, 1)).toEqual({
value: 3,
tooltip: '2 builders blocked at human-approval gates · 1 held message',
});
});
});

describe('escalationToastText', () => {
it('names the recipient, the held duration in seconds, and the why-held reason', () => {
const text = escalationToastText(makePayload({ toAgent: 'architect:main', ageMs: 62_000, reason: 'busy' }));
expect(text).toContain('architect:main');
expect(text).toContain('62s');
expect(text).toContain('(busy)');
expect(text).toContain('afx inbox');
});

it('omits the reason parens when the reason is null', () => {
const text = escalationToastText(makePayload({ reason: null }));
expect(text).not.toContain('(');
});

it('rounds sub-second/odd ages and never goes negative', () => {
expect(escalationToastText(makePayload({ ageMs: 60_500 }))).toContain('61s');
expect(escalationToastText(makePayload({ ageMs: -10 }))).toContain('0s');
});

it('carries no message body (redaction — payload has none to leak)', () => {
// The payload type has no body field; assert the text is metadata only by
// confirming it is fully determined by the metadata we passed.
const text = escalationToastText(makePayload({ toAgent: 'b', ageMs: 60_000, reason: 'no-profile' }));
expect(text).toBe('Codev: a message to b has been held 60s (no-profile) — past the escalation age. Review with: afx inbox');
});
});

describe('escalationMatchesWorkspace', () => {
it('matches an identical path', () => {
expect(escalationMatchesWorkspace('/ws/a', '/ws/a')).toBe(true);
});

it('normalizes trailing slashes and . / .. segments', () => {
expect(escalationMatchesWorkspace('/ws/a/', '/ws/a')).toBe(true);
expect(escalationMatchesWorkspace('/ws/a/../a', '/ws/a')).toBe(true);
});

it('rejects a different workspace', () => {
expect(escalationMatchesWorkspace('/ws/a', '/ws/b')).toBe(false);
});

it('matches everything when no active workspace is known yet (startup)', () => {
expect(escalationMatchesWorkspace('/ws/a', null)).toBe(true);
});
});
Loading
Loading