Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
218 lines
7.1 KiB
TypeScript
218 lines
7.1 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { parseHookEvent } from '../src/http/hook.js'
|
|
import type { HookEventFull } from '../src/http/hook.js'
|
|
|
|
const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
|
|
|
|
// ── Existing status / detail behaviour (must not regress) ───────────────────
|
|
// We use expect.objectContaining() so the new fields (at/eventClass/gate) do
|
|
// not break these assertion — they verify only the status-mapping contract.
|
|
|
|
describe('parseHookEvent — status mapping (regression guard)', () => {
|
|
it('maps PreToolUse / PostToolUse / UserPromptSubmit to "working"', () => {
|
|
for (const e of ['PreToolUse', 'PostToolUse', 'UserPromptSubmit']) {
|
|
expect(parseHookEvent(SID, { hook_event_name: e })).toEqual(
|
|
expect.objectContaining({ sessionId: SID, status: 'working' }),
|
|
)
|
|
}
|
|
})
|
|
|
|
it('maps PermissionRequest to "waiting" with tool_name as detail', () => {
|
|
expect(
|
|
parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' }),
|
|
).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting', detail: 'Bash' }))
|
|
})
|
|
|
|
it('maps Notification(permission_prompt) → "waiting"; other Notification → "idle"', () => {
|
|
expect(
|
|
parseHookEvent(SID, {
|
|
hook_event_name: 'Notification',
|
|
notification_type: 'permission_prompt',
|
|
}),
|
|
).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting' }))
|
|
|
|
expect(
|
|
parseHookEvent(SID, {
|
|
hook_event_name: 'Notification',
|
|
notification_type: 'idle_prompt',
|
|
}),
|
|
).toEqual(expect.objectContaining({ sessionId: SID, status: 'idle' }))
|
|
})
|
|
|
|
it('maps Stop / SessionEnd to "idle"', () => {
|
|
expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual(
|
|
expect.objectContaining({ sessionId: SID, status: 'idle' }),
|
|
)
|
|
expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual(
|
|
expect.objectContaining({ sessionId: SID, status: 'idle' }),
|
|
)
|
|
})
|
|
|
|
it('returns null for missing/empty sessionId, bad body, or unknown event', () => {
|
|
expect(parseHookEvent(undefined, { hook_event_name: 'Stop' })).toBeNull()
|
|
expect(parseHookEvent('', { hook_event_name: 'Stop' })).toBeNull()
|
|
expect(parseHookEvent(SID, null)).toBeNull()
|
|
expect(parseHookEvent(SID, 'nope')).toBeNull()
|
|
expect(parseHookEvent(SID, { hook_event_name: 'SomethingElse' })).toBeNull()
|
|
expect(parseHookEvent(SID, {})).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ── HookEventFull — new A4 / B4 fields ──────────────────────────────────────
|
|
|
|
describe('HookEventFull — at field (A4)', () => {
|
|
it('includes a numeric timestamp in [before, after] around the call', () => {
|
|
const before = Date.now()
|
|
const ev = parseHookEvent(SID, { hook_event_name: 'Stop' }) as HookEventFull
|
|
const after = Date.now()
|
|
|
|
expect(ev).not.toBeNull()
|
|
expect(typeof ev.at).toBe('number')
|
|
expect(ev.at).toBeGreaterThanOrEqual(before)
|
|
expect(ev.at).toBeLessThanOrEqual(after)
|
|
})
|
|
|
|
it('is absent from null results', () => {
|
|
const ev = parseHookEvent(SID, { hook_event_name: 'Unknown' })
|
|
expect(ev).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('HookEventFull — eventClass field (A4)', () => {
|
|
const EVENTS = [
|
|
'PreToolUse',
|
|
'PostToolUse',
|
|
'UserPromptSubmit',
|
|
'PermissionRequest',
|
|
'Stop',
|
|
'SessionEnd',
|
|
'Notification',
|
|
]
|
|
|
|
it('carries the raw hook_event_name for every whitelisted event', () => {
|
|
for (const e of EVENTS) {
|
|
const ev = parseHookEvent(SID, { hook_event_name: e }) as HookEventFull
|
|
expect(ev).not.toBeNull()
|
|
expect(ev.eventClass).toBe(e)
|
|
}
|
|
})
|
|
|
|
it('is not present when parseHookEvent returns null', () => {
|
|
expect(parseHookEvent(SID, { hook_event_name: 'NotAHook' })).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('HookEventFull — toolInput passthrough (A4)', () => {
|
|
it('includes toolInput when tool_input is present in the body', () => {
|
|
const input = { path: '/src/foo.ts', content: 'hello world' }
|
|
const ev = parseHookEvent(SID, {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_input: input,
|
|
}) as HookEventFull
|
|
|
|
expect(ev).not.toBeNull()
|
|
expect(ev.toolInput).toBe(input) // same reference, no copy/parse
|
|
})
|
|
|
|
it('passes toolInput through as-is (does not validate inner shape)', () => {
|
|
const ev = parseHookEvent(SID, {
|
|
hook_event_name: 'PostToolUse',
|
|
tool_input: 'just a string',
|
|
}) as HookEventFull
|
|
|
|
expect(ev.toolInput).toBe('just a string')
|
|
})
|
|
|
|
it('omits toolInput when tool_input is absent', () => {
|
|
const ev = parseHookEvent(SID, { hook_event_name: 'PreToolUse' }) as HookEventFull
|
|
expect(ev).not.toBeNull()
|
|
expect('toolInput' in ev).toBe(false)
|
|
})
|
|
|
|
it('includes toolInput even when its value is null', () => {
|
|
const ev = parseHookEvent(SID, {
|
|
hook_event_name: 'PostToolUse',
|
|
tool_input: null,
|
|
}) as HookEventFull
|
|
expect(ev).not.toBeNull()
|
|
expect('toolInput' in ev).toBe(true)
|
|
expect(ev.toolInput).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('HookEventFull — gate field (B4 plan-gate recognition)', () => {
|
|
it('sets gate="plan" for PermissionRequest with ExitPlanMode tool', () => {
|
|
const ev = parseHookEvent(SID, {
|
|
hook_event_name: 'PermissionRequest',
|
|
tool_name: 'ExitPlanMode',
|
|
}) as HookEventFull
|
|
|
|
expect(ev).not.toBeNull()
|
|
expect(ev.gate).toBe('plan')
|
|
})
|
|
|
|
it('sets gate="tool" for PermissionRequest with any other tool name', () => {
|
|
const ev = parseHookEvent(SID, {
|
|
hook_event_name: 'PermissionRequest',
|
|
tool_name: 'Bash',
|
|
}) as HookEventFull
|
|
|
|
expect(ev).not.toBeNull()
|
|
expect(ev.gate).toBe('tool')
|
|
})
|
|
|
|
it('sets gate="tool" for PermissionRequest with no tool_name', () => {
|
|
const ev = parseHookEvent(SID, { hook_event_name: 'PermissionRequest' }) as HookEventFull
|
|
|
|
expect(ev).not.toBeNull()
|
|
expect(ev.gate).toBe('tool')
|
|
})
|
|
|
|
it('omits gate for non-PermissionRequest events', () => {
|
|
const nonPermEvents = [
|
|
'PreToolUse',
|
|
'PostToolUse',
|
|
'UserPromptSubmit',
|
|
'Stop',
|
|
'SessionEnd',
|
|
'Notification',
|
|
]
|
|
for (const e of nonPermEvents) {
|
|
const ev = parseHookEvent(SID, { hook_event_name: e }) as HookEventFull
|
|
expect(ev).not.toBeNull()
|
|
expect('gate' in ev).toBe(false)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('HookEventFull — detail not set for Notification (no tool_name)', () => {
|
|
it('Notification(permission_prompt) has status=waiting but no detail', () => {
|
|
const ev = parseHookEvent(SID, {
|
|
hook_event_name: 'Notification',
|
|
notification_type: 'permission_prompt',
|
|
}) as HookEventFull
|
|
|
|
expect(ev).not.toBeNull()
|
|
expect(ev.status).toBe('waiting')
|
|
expect('detail' in ev).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('SEC-M7 — never throws on any body shape', () => {
|
|
const malformed = [
|
|
[],
|
|
42,
|
|
true,
|
|
undefined,
|
|
{ hook_event_name: null },
|
|
{ hook_event_name: 123 },
|
|
{ hook_event_name: 'PreToolUse', tool_input: { toString: null } },
|
|
]
|
|
|
|
it('returns null without throwing for every malformed body', () => {
|
|
for (const b of malformed) {
|
|
expect(() => parseHookEvent(SID, b)).not.toThrow()
|
|
}
|
|
})
|
|
})
|