Skip to content

Commit b88a5d2

Browse files
feat(notify): show Codex model identity (#533)
Resolve the exact per-turn Codex model from notify metadata or the matching local rollout and render provider identity across notification channels. Co-Authored-By: Cursor <cursoragent@cursor.com>
1 parent cb3b764 commit b88a5d2

16 files changed

Lines changed: 517 additions & 14 deletions

File tree

stations/notify/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Codex notifications now show the verified per-turn model from the notify payload or matching local rollout metadata. Informational Telegram and Signal messages use an OpenAI text mark instead of the generic information symbol.
12+
1013
### Fixed
1114
- Channel transport failures now return bounded provider, stage, status, and cause fields without retaining request URLs or credentials; the dispatcher sanitizes errors again before writing stderr.
1215

stations/notify/README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@ agent-notify hooks print codex --profile agent-stop
271271
notify = ["agent-notify", "--hook", "codex-notify", "--profile", "agent-stop"]
272272
```
273273

274+
Model enrichment reads only the matching local Codex turn_context, is best effort, and falls back to the existing Codex title when metadata is missing.
275+
274276
## Adding a custom hook source (escape hatch)
275277

276278
If a built-in adapter ever breaks because an upstream tool changes its event schema, write a small shell wrapper that extracts the fields you want and pipes canonical JSON to `agent-notify`:
@@ -296,11 +298,11 @@ Then point the upstream tool's hook config at `my-tool-notify.sh` instead.
296298

297299
## Channel formatting
298300

299-
| Channel | Format |
300-
|---------|--------|
301-
| Discord | Embed with title + body. Color by level (info=blue, warn=yellow, error=red, success=green). Tags as inline fields. Source as footer. |
302-
| Telegram | Markdown V2. Level emoji prefix (ℹ️ / ⚠️ / 🚨 / ✅). Title bolded. Tags as italicized footer. |
303-
| Signal | Plain text. Level emoji prefix. Title on its own line. Tags as `[tag1, tag2]` footer. |
301+
| Channel | Format | Codex model identity |
302+
|---------|--------|----------------------|
303+
| Discord | Embed with title + body. Color by level (info=blue, warn=yellow, error=red, success=green). Tags as inline fields. Source as footer. | Embed title becomes `OpenAI · <model>` for verified Codex turns. |
304+
| Telegram | Markdown V2. Level emoji prefix (ℹ️ / ⚠️ / 🚨 / ✅). Title bolded. Tags as italicized footer. | Informational Codex turns render `◉ OpenAI · <model>`. Warn/error/success emoji keep precedence. |
305+
| Signal | Plain text. Level emoji prefix. Title on its own line. Tags as `[tag1, tag2]` footer. | Same identity and severity precedence as Telegram. |
304306

305307
## Why not <alternatives>?
306308

stations/notify/cmd/agent-notify/main_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,3 +647,50 @@ func TestRun_HooksPrintCodex(t *testing.T) {
647647
t.Fatalf("stdout = %q, want %q", strings.TrimSpace(stdout), want)
648648
}
649649
}
650+
651+
// TestRun_CodexNotifyResolvesModelIdentity verifies the end-to-end path: a
652+
// Codex notify event with thread-id/turn-id but no inline model resolves the
653+
// model from the CODEX_HOME session rollout, and the Discord embed title
654+
// renders the provider/model identity ("OpenAI · gpt-5.6-sol") instead of
655+
// the generic "Codex (turn-N)" title.
656+
func TestRun_CodexNotifyResolvesModelIdentity(t *testing.T) {
657+
codexHome := t.TempDir()
658+
rolloutDir := filepath.Join(codexHome, "sessions", "2026", "07", "25")
659+
if err := os.MkdirAll(rolloutDir, 0o755); err != nil {
660+
t.Fatalf("mkdir rollout dir: %v", err)
661+
}
662+
// Filename rollout-test-thread-7.jsonl matches the rollout-<ts>-<thread>
663+
// convention with thread id "thread-7" (suffix "-thread-7.jsonl").
664+
rolloutPath := filepath.Join(rolloutDir, "rollout-test-thread-7.jsonl")
665+
turnContext := `{"type":"turn_context","payload":{"turn_id":"turn-7","model":"gpt-5.6-sol"}}`
666+
if err := os.WriteFile(rolloutPath, []byte(turnContext+"\n"), 0o644); err != nil {
667+
t.Fatalf("write rollout: %v", err)
668+
}
669+
t.Setenv("CODEX_HOME", codexHome)
670+
671+
var got map[string]interface{}
672+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
673+
body, _ := io.ReadAll(r.Body)
674+
_ = json.Unmarshal(body, &got)
675+
w.WriteHeader(http.StatusNoContent)
676+
}))
677+
defer srv.Close()
678+
679+
event := `{"type":"agent-turn-complete","thread-id":"thread-7","turn-id":"turn-7","last-assistant-message":"Done."}`
680+
code, _, stderr := runMain(t,
681+
[]string{"agent-notify", "--hook", "codex-notify", event},
682+
"",
683+
map[string]string{"DISCORD_WEBHOOK_URL": srv.URL},
684+
)
685+
if code != 0 {
686+
t.Fatalf("exit = %d, stderr = %s", code, stderr)
687+
}
688+
embeds, _ := got["embeds"].([]interface{})
689+
if len(embeds) != 1 {
690+
t.Fatalf("expected 1 embed, got %#v", got["embeds"])
691+
}
692+
embed, _ := embeds[0].(map[string]interface{})
693+
if embed["title"] != "OpenAI · gpt-5.6-sol" {
694+
t.Errorf("embed title = %#v, want OpenAI · gpt-5.6-sol", embed["title"])
695+
}
696+
}

stations/notify/internal/adapter/adapter_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ func TestAutoDetect_PlainStringIsBody(t *testing.T) {
1616
}
1717

1818
func TestAutoDetect_CanonicalJSONParsesAllFields(t *testing.T) {
19-
in := `{"title":"T","body":"B","level":"warn","source":"s","tags":["x","y"]}`
19+
in := `{"title":"T","body":"B","level":"warn","source":"s","model":"m","tags":["x","y"]}`
2020
m, err := AutoDetect(strings.NewReader(in))
2121
if err != nil {
2222
t.Fatalf("unexpected error: %v", err)
2323
}
24-
if m.Title != "T" || m.Body != "B" || m.Level != "warn" || m.Source != "s" {
24+
if m.Title != "T" || m.Body != "B" || m.Level != "warn" || m.Source != "s" || m.Model != "m" {
2525
t.Errorf("fields wrong: %+v", m)
2626
}
2727
if len(m.Tags) != 2 {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package adapter
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"encoding/json"
7+
"io/fs"
8+
"os"
9+
"path/filepath"
10+
"regexp"
11+
"strings"
12+
)
13+
14+
// maxCodexRolloutRecord caps the size of a single JSONL record we will buffer
15+
// when scanning a Codex rollout file. Codex turn_context records are small,
16+
// but the rollout also carries large output records we never want to fully
17+
// buffer; 8 MiB is a generous ceiling for any well-formed turn_context.
18+
const maxCodexRolloutRecord = 8 * 1024 * 1024
19+
20+
// codexIdentifierRe matches the safe subset of identifiers we accept as a
21+
// thread or turn id when looking up session rollouts. Rejecting anything
22+
// broader (slashes, dots, etc.) keeps us from being talked into walking or
23+
// opening files outside the session roots.
24+
var codexIdentifierRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
25+
26+
func validCodexIdentifier(s string) bool {
27+
return s != "" && codexIdentifierRe.MatchString(s)
28+
}
29+
30+
// resolveCodexModel looks up the model used for a specific Codex turn by
31+
// scanning the Codex CLI session rollout files under CODEX_HOME (or
32+
// ~/.codex when CODEX_HOME is unset). It walks the sessions and
33+
// archived_sessions trees without following symlinks, finds rollout files
34+
// whose names end in -<threadID>.jsonl, and returns the model from the
35+
// turn_context record whose turn_id matches turnID. Any error or miss
36+
// returns an empty string.
37+
func resolveCodexModel(threadID, turnID string) string {
38+
if !validCodexIdentifier(threadID) || !validCodexIdentifier(turnID) {
39+
return ""
40+
}
41+
home := os.Getenv("CODEX_HOME")
42+
if home == "" {
43+
userHome, err := os.UserHomeDir()
44+
if err != nil {
45+
return ""
46+
}
47+
home = filepath.Join(userHome, ".codex")
48+
}
49+
suffix := "-" + threadID + ".jsonl"
50+
for _, root := range []string{"sessions", "archived_sessions"} {
51+
base := filepath.Join(home, root)
52+
var found string
53+
walkErr := filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
54+
if err != nil {
55+
// Best-effort walk: skip unreadable entries rather than aborting.
56+
return nil
57+
}
58+
if d.IsDir() {
59+
if d.Type()&fs.ModeSymlink != 0 {
60+
return filepath.SkipDir
61+
}
62+
return nil
63+
}
64+
if d.Type()&fs.ModeSymlink != 0 {
65+
// Do not follow symlinked rollouts.
66+
return nil
67+
}
68+
if !d.Type().IsRegular() {
69+
return nil
70+
}
71+
name := d.Name()
72+
if !strings.HasPrefix(name, "rollout-") || !strings.HasSuffix(name, suffix) {
73+
return nil
74+
}
75+
if m := modelFromCodexRollout(path, turnID); m != "" {
76+
found = m
77+
return fs.SkipAll
78+
}
79+
return nil
80+
})
81+
if walkErr == nil && found != "" {
82+
return found
83+
}
84+
}
85+
return ""
86+
}
87+
88+
// modelFromCodexRollout scans a single Codex rollout JSONL file for a
89+
// turn_context record whose turn_id matches turnID and returns the model
90+
// field from that record. Every error (unreadable file, malformed line,
91+
// missing fields) returns an empty string.
92+
func modelFromCodexRollout(path, turnID string) string {
93+
f, err := os.Open(path)
94+
if err != nil {
95+
return ""
96+
}
97+
defer f.Close()
98+
scanner := bufio.NewScanner(f)
99+
scanner.Buffer(make([]byte, 0, 64*1024), maxCodexRolloutRecord)
100+
for scanner.Scan() {
101+
line := scanner.Bytes()
102+
// Fast path: skip lines that cannot be a turn_context record without
103+
// paying for an unmarshal.
104+
if !bytes.Contains(line, []byte("turn_context")) {
105+
continue
106+
}
107+
var rec struct {
108+
Type string `json:"type"`
109+
Payload map[string]interface{} `json:"payload"`
110+
}
111+
if err := json.Unmarshal(line, &rec); err != nil {
112+
continue
113+
}
114+
if rec.Type != "turn_context" {
115+
continue
116+
}
117+
if firstString(rec.Payload, "turn_id", "turn-id") != turnID {
118+
continue
119+
}
120+
if m := firstString(rec.Payload, "model", "model-name", "model_name"); m != "" {
121+
return m
122+
}
123+
return ""
124+
}
125+
return ""
126+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package adapter
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
)
11+
12+
// writeCodexRollout creates a Codex CLI rollout JSONL file under
13+
// CODEX_HOME/sessions/2026/07/25/rollout-<timestamp>-<thread>.jsonl and writes
14+
// the given raw lines (one JSON record per line). It returns the file path.
15+
func writeCodexRollout(t *testing.T, codexHome, thread, timestamp string, lines []string) string {
16+
t.Helper()
17+
dir := filepath.Join(codexHome, "sessions", "2026", "07", "25")
18+
if err := os.MkdirAll(dir, 0o755); err != nil {
19+
t.Fatalf("mkdir rollout dir: %v", err)
20+
}
21+
path := filepath.Join(dir, fmt.Sprintf("rollout-%s-%s.jsonl", timestamp, thread))
22+
f, err := os.Create(path)
23+
if err != nil {
24+
t.Fatalf("create rollout: %v", err)
25+
}
26+
defer f.Close()
27+
w := bufio.NewWriter(f)
28+
for _, l := range lines {
29+
if _, err := w.WriteString(l + "\n"); err != nil {
30+
t.Fatalf("write rollout line: %v", err)
31+
}
32+
}
33+
if err := w.Flush(); err != nil {
34+
t.Fatalf("flush rollout: %v", err)
35+
}
36+
return path
37+
}
38+
39+
// turnContextLine returns a JSONL record for a Codex turn_context entry with
40+
// the given turn_id and model.
41+
func turnContextLine(turnID, model string) string {
42+
rec := map[string]interface{}{
43+
"type": "turn_context",
44+
"payload": map[string]interface{}{
45+
"turn_id": turnID,
46+
"model": model,
47+
},
48+
}
49+
b, err := json.Marshal(rec)
50+
if err != nil {
51+
panic(err)
52+
}
53+
return string(b)
54+
}
55+
56+
func TestResolveCodexModel_MatchesExactTurn(t *testing.T) {
57+
codexHome := t.TempDir()
58+
writeCodexRollout(t, codexHome, "abc", "20260725-180000", []string{
59+
turnContextLine("turn-old", "gpt-5.5"),
60+
turnContextLine("turn-7", "gpt-5.6-sol"),
61+
})
62+
t.Setenv("CODEX_HOME", codexHome)
63+
got := resolveCodexModel("abc", "turn-7")
64+
if got != "gpt-5.6-sol" {
65+
t.Errorf("resolveCodexModel = %q, want gpt-5.6-sol", got)
66+
}
67+
}
68+
69+
func TestResolveCodexModel_DoesNotUseAnotherTurn(t *testing.T) {
70+
codexHome := t.TempDir()
71+
writeCodexRollout(t, codexHome, "abc", "20260725-180000", []string{
72+
turnContextLine("turn-old", "gpt-5.5"),
73+
})
74+
t.Setenv("CODEX_HOME", codexHome)
75+
got := resolveCodexModel("abc", "turn-7")
76+
if got != "" {
77+
t.Errorf("resolveCodexModel = %q, want empty (must not fall back to another turn)", got)
78+
}
79+
}
80+
81+
func TestResolveCodexModel_IgnoresBadIdentifiersAndMalformedRecords(t *testing.T) {
82+
t.Run("malformed JSON skipped", func(t *testing.T) {
83+
codexHome := t.TempDir()
84+
writeCodexRollout(t, codexHome, "abc", "20260725-180000", []string{
85+
"{not valid json",
86+
turnContextLine("turn-7", "gpt-5.6-sol"),
87+
})
88+
t.Setenv("CODEX_HOME", codexHome)
89+
got := resolveCodexModel("abc", "turn-7")
90+
if got != "gpt-5.6-sol" {
91+
t.Errorf("resolveCodexModel = %q, want gpt-5.6-sol after skipping malformed line", got)
92+
}
93+
})
94+
95+
t.Run("path traversal thread rejected", func(t *testing.T) {
96+
codexHome := t.TempDir()
97+
writeCodexRollout(t, codexHome, "abc", "20260725-180000", []string{
98+
turnContextLine("turn-7", "gpt-5.6-sol"),
99+
})
100+
t.Setenv("CODEX_HOME", codexHome)
101+
got := resolveCodexModel("../abc", "turn-7")
102+
if got != "" {
103+
t.Errorf("resolveCodexModel = %q for ../thread, want empty", got)
104+
}
105+
})
106+
107+
t.Run("empty turn rejected", func(t *testing.T) {
108+
codexHome := t.TempDir()
109+
writeCodexRollout(t, codexHome, "abc", "20260725-180000", []string{
110+
turnContextLine("turn-7", "gpt-5.6-sol"),
111+
})
112+
t.Setenv("CODEX_HOME", codexHome)
113+
got := resolveCodexModel("abc", "")
114+
if got != "" {
115+
t.Errorf("resolveCodexModel = %q for empty turn, want empty", got)
116+
}
117+
})
118+
}
119+
120+
func TestModelFromCodexRollout_UnreadablePathIsEmpty(t *testing.T) {
121+
dir := t.TempDir()
122+
// Passing a directory: os.Open succeeds but reading fails; must return empty.
123+
got := modelFromCodexRollout(dir, "turn-7")
124+
if got != "" {
125+
t.Errorf("modelFromCodexRollout(dir) = %q, want empty", got)
126+
}
127+
}
128+
129+
func TestCodexNotify_ResolvesModelFromSession(t *testing.T) {
130+
codexHome := t.TempDir()
131+
writeCodexRollout(t, codexHome, "abc", "20260725-180000", []string{
132+
turnContextLine("turn-old", "gpt-5.5"),
133+
turnContextLine("turn-7", "gpt-5.6-sol"),
134+
})
135+
t.Setenv("CODEX_HOME", codexHome)
136+
in := []byte(`{"type":"agent-turn-complete","thread-id":"abc","turn-id":"turn-7","last-assistant-message":"Built OK."}`)
137+
m, err := CodexNotifyFromBytes(in)
138+
if err != nil {
139+
t.Fatalf("unexpected error: %v", err)
140+
}
141+
if m.Model != "gpt-5.6-sol" {
142+
t.Errorf("model = %q, want gpt-5.6-sol resolved from session rollout", m.Model)
143+
}
144+
}

stations/notify/internal/adapter/codex_notify.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,22 @@ func CodexNotifyFromBytes(raw []byte) (canonical.Message, error) {
4343
title = "Codex (" + turnID + ")"
4444
}
4545

46+
model := firstString(ev, "model", "model-name", "model_name")
47+
if model == "" {
48+
// Codex's notify event often omits the model; fall back to the model
49+
// recorded for this turn in the Codex CLI session rollout. Resolve by
50+
// the explicit thread id only (never session_id, which is not a
51+
// thread alias).
52+
threadID := firstString(ev, "thread-id", "thread_id")
53+
if threadID != "" && turnID != "" {
54+
model = resolveCodexModel(threadID, turnID)
55+
}
56+
}
57+
4658
return canonical.Message{
4759
Title: title,
4860
Body: body,
4961
Source: "codex",
62+
Model: model,
5063
}, nil
5164
}

0 commit comments

Comments
 (0)