package main // The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744). import ( "bytes" "fmt" "os" "path/filepath" "strings" "testing" "time" ) // 1744/AC1: the hook prints the session's id and how to give it to claude_code_wait. func TestSessionStartPrintsTheSessionsIDAndHowToGiveIt(t *testing.T) { waitsDir := t.TempDir() stdin := strings.NewReader(`{"session_id":"sess-9","hook_event_name":"SessionStart","source":"startup"}`) var out bytes.Buffer if code := runSessionStart(waitsDir, stdin, &out); code != 0 { t.Fatalf("code %d, stdout %s", code, out.String()) } if !strings.Contains(out.String(), "sess-9") || !strings.Contains(out.String(), "claude_code_wait") { t.Fatalf("stdout does not say how to name the session: %s", out.String()) } } // 1744/AC2: when the waits file holds open waits, it prints each (what, age, past bound or not). func TestSessionStartListsOpenWaitsAfterAClear(t *testing.T) { waitsDir := t.TempDir() at := time.Date(2026, 10, 21, 10, 0, 0, 0, time.UTC) f := WaitsFile{Session: "sess-10", At: at, Waits: []Wait{ {ID: "w-open", What: "the operator's Approve", Bound: 10, At: at}, {ID: "w-past", What: "a delivery to finish", Bound: 1, At: at}, }} writeWaitsFixture(t, filepath.Join(waitsDir, "sess-10.json"), f) now := at.Add(5 * time.Minute) msg := SessionStartMessage("sess-10", waitsDir, now) if !strings.Contains(msg, "w-open") || !strings.Contains(msg, "the operator's Approve") || !strings.Contains(msg, "no watcher") { t.Fatalf("the open, unwatched wait is not described:\n%s", msg) } if !strings.Contains(msg, "w-past") || !strings.Contains(msg, "past its bound") { t.Fatalf("the past-bound wait is not described:\n%s", msg) } if !strings.Contains(msg, "5m0s ago") { t.Fatalf("the age is not said:\n%s", msg) } } // 1744/AC3: a missing or malformed file prints only the id line, and the hook never fails the session start. func TestSessionStartNeverFailsOnAMissingOrBadFile(t *testing.T) { waitsDir := t.TempDir() if msg := SessionStartMessage("sess-11", waitsDir, time.Now()); !strings.Contains(msg, "sess-11") || strings.Contains(msg, "open") { t.Fatalf("a missing file printed more than the id line: %q", msg) } writeFile(t, filepath.Join(waitsDir, "sess-12.json"), "{not json") if msg := SessionStartMessage("sess-12", waitsDir, time.Now()); !strings.Contains(msg, "sess-12") || strings.Contains(msg, "open") { t.Fatalf("a malformed file printed more than the id line: %q", msg) } var out bytes.Buffer if code := runSessionStart(waitsDir, strings.NewReader("not json at all"), &out); code != 0 { t.Fatalf("unreadable stdin failed the session start: code %d", code) } var out2 bytes.Buffer if code := runSessionStart(waitsDir, panicReader{}, &out2); code != 0 { t.Fatalf("a panic was not recovered: code %d", code) } } // panicReader panics on Read, as a stuck pipe or a bug reading stdin might. type panicReader struct{} func (panicReader) Read([]byte) (int, error) { panic("boom") } // Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one is never used to // build a file path — reading outside the waits dir never happens — and the hook prints only a line saying // the id could not be used. func TestSessionStartRejectsAnUnsafeSessionID(t *testing.T) { root := t.TempDir() waitsDir := filepath.Join(root, "agent", "waits") if err := os.MkdirAll(waitsDir, 0o700); err != nil { t.Fatal(err) } // What "../../x" resolves to from waitsDir: root/x.json, outside waitsDir, with content that must never be // read through it. outside := filepath.Join(waitsDir, "../../x.json") writeWaitsFixture(t, outside, WaitsFile{Session: "x", At: time.Now(), Waits: []Wait{{ID: "w-secret", What: "SECRET-DATA", Bound: 10, At: time.Now()}}}) t.Cleanup(func() { _ = os.Remove(outside) }) for _, id := range []string{"../../x", "a/b"} { var out bytes.Buffer stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q}`, id)) if code := runSessionStart(waitsDir, stdin, &out); code != 0 { t.Fatalf("id %q: code %d", id, code) } if strings.Contains(out.String(), "SECRET-DATA") { t.Fatalf("id %q: read a file outside the waits dir: %s", id, out.String()) } if !strings.Contains(out.String(), "could not be used") { t.Fatalf("id %q: did not say the id could not be used: %s", id, out.String()) } // Item (a): the notice reaches the screen as the one JSON object Claude Code shows. if !strings.Contains(out.String(), "systemMessage") { t.Fatalf("id %q: the notice is not a systemMessage: %s", id, out.String()) } } var out bytes.Buffer if code := runSessionStart(waitsDir, strings.NewReader(`{"session_id":""}`), &out); code != 0 || out.Len() != 0 { t.Fatalf("blank id: code %d, out %q", code, out.String()) } } // 1744/AC4 is checked together with 1743/AC7 in terminal_only_test.go's // TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook. // ADR 0490 §2: every session start lists every name on this node with an open wait, from the node-wide // index, with the exact adopt call to make if it is yours — read directly, never by calling back into this // module. func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) { waitsDir := t.TempDir() lastCall := time.Date(2026, 10, 21, 9, 55, 0, 0, time.UTC) idx := NodeIndex{Node: "g14", At: lastCall, Names: []NodeIndexEntry{ {Name: "fix-1629", Open: 2, CurrentSession: "sess-old", LastCall: lastCall}, }} raw, err := indented(idx) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(waitsDir, "_index.json"), string(raw)) msg := SessionStartMessage("sess-brand-new", waitsDir, lastCall.Add(5*time.Minute)) if !strings.Contains(msg, "fix-1629") || !strings.Contains(msg, "2 open wait") { t.Fatalf("the name and its count are not listed:\n%s", msg) } want := `claude_code_wait {"op":"adopt","by":"fix-1629@g14","session":"sess-brand-new"}` if !strings.Contains(msg, want) { t.Fatalf("the exact adopt call is not given:\n%s\nwant it to contain:\n%s", msg, want) } } func writeWaitsFixture(t *testing.T, path string, f WaitsFile) { t.Helper() raw, err := indented(f) if err != nil { t.Fatal(err) } writeFile(t, path, string(raw)) }