Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions stations/notify/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- 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.

### Fixed
- 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.

Expand Down
12 changes: 7 additions & 5 deletions stations/notify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ agent-notify hooks print codex --profile agent-stop
notify = ["agent-notify", "--hook", "codex-notify", "--profile", "agent-stop"]
```

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.

## Adding a custom hook source (escape hatch)

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`:
Expand All @@ -296,11 +298,11 @@ Then point the upstream tool's hook config at `my-tool-notify.sh` instead.

## Channel formatting

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

## Why not <alternatives>?

Expand Down
47 changes: 47 additions & 0 deletions stations/notify/cmd/agent-notify/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -647,3 +647,50 @@ func TestRun_HooksPrintCodex(t *testing.T) {
t.Fatalf("stdout = %q, want %q", strings.TrimSpace(stdout), want)
}
}

// TestRun_CodexNotifyResolvesModelIdentity verifies the end-to-end path: a
// Codex notify event with thread-id/turn-id but no inline model resolves the
// model from the CODEX_HOME session rollout, and the Discord embed title
// renders the provider/model identity ("OpenAI · gpt-5.6-sol") instead of
// the generic "Codex (turn-N)" title.
func TestRun_CodexNotifyResolvesModelIdentity(t *testing.T) {
codexHome := t.TempDir()
rolloutDir := filepath.Join(codexHome, "sessions", "2026", "07", "25")
if err := os.MkdirAll(rolloutDir, 0o755); err != nil {
t.Fatalf("mkdir rollout dir: %v", err)
}
// Filename rollout-test-thread-7.jsonl matches the rollout-<ts>-<thread>
// convention with thread id "thread-7" (suffix "-thread-7.jsonl").
rolloutPath := filepath.Join(rolloutDir, "rollout-test-thread-7.jsonl")
turnContext := `{"type":"turn_context","payload":{"turn_id":"turn-7","model":"gpt-5.6-sol"}}`
if err := os.WriteFile(rolloutPath, []byte(turnContext+"\n"), 0o644); err != nil {
t.Fatalf("write rollout: %v", err)
}
t.Setenv("CODEX_HOME", codexHome)

var got map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &got)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()

event := `{"type":"agent-turn-complete","thread-id":"thread-7","turn-id":"turn-7","last-assistant-message":"Done."}`
code, _, stderr := runMain(t,
[]string{"agent-notify", "--hook", "codex-notify", event},
"",
map[string]string{"DISCORD_WEBHOOK_URL": srv.URL},
)
if code != 0 {
t.Fatalf("exit = %d, stderr = %s", code, stderr)
}
embeds, _ := got["embeds"].([]interface{})
if len(embeds) != 1 {
t.Fatalf("expected 1 embed, got %#v", got["embeds"])
}
embed, _ := embeds[0].(map[string]interface{})
if embed["title"] != "OpenAI · gpt-5.6-sol" {
t.Errorf("embed title = %#v, want OpenAI · gpt-5.6-sol", embed["title"])
}
}
4 changes: 2 additions & 2 deletions stations/notify/internal/adapter/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ func TestAutoDetect_PlainStringIsBody(t *testing.T) {
}

func TestAutoDetect_CanonicalJSONParsesAllFields(t *testing.T) {
in := `{"title":"T","body":"B","level":"warn","source":"s","tags":["x","y"]}`
in := `{"title":"T","body":"B","level":"warn","source":"s","model":"m","tags":["x","y"]}`
m, err := AutoDetect(strings.NewReader(in))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m.Title != "T" || m.Body != "B" || m.Level != "warn" || m.Source != "s" {
if m.Title != "T" || m.Body != "B" || m.Level != "warn" || m.Source != "s" || m.Model != "m" {
t.Errorf("fields wrong: %+v", m)
}
if len(m.Tags) != 2 {
Expand Down
126 changes: 126 additions & 0 deletions stations/notify/internal/adapter/codex_model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package adapter

import (
"bufio"
"bytes"
"encoding/json"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
)

// maxCodexRolloutRecord caps the size of a single JSONL record we will buffer
// when scanning a Codex rollout file. Codex turn_context records are small,
// but the rollout also carries large output records we never want to fully
// buffer; 8 MiB is a generous ceiling for any well-formed turn_context.
const maxCodexRolloutRecord = 8 * 1024 * 1024

// codexIdentifierRe matches the safe subset of identifiers we accept as a
// thread or turn id when looking up session rollouts. Rejecting anything
// broader (slashes, dots, etc.) keeps us from being talked into walking or
// opening files outside the session roots.
var codexIdentifierRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)

func validCodexIdentifier(s string) bool {
return s != "" && codexIdentifierRe.MatchString(s)
}

// resolveCodexModel looks up the model used for a specific Codex turn by
// scanning the Codex CLI session rollout files under CODEX_HOME (or
// ~/.codex when CODEX_HOME is unset). It walks the sessions and
// archived_sessions trees without following symlinks, finds rollout files
// whose names end in -<threadID>.jsonl, and returns the model from the
// turn_context record whose turn_id matches turnID. Any error or miss
// returns an empty string.
func resolveCodexModel(threadID, turnID string) string {
if !validCodexIdentifier(threadID) || !validCodexIdentifier(turnID) {
return ""
}
home := os.Getenv("CODEX_HOME")
if home == "" {
userHome, err := os.UserHomeDir()
if err != nil {
return ""
}
home = filepath.Join(userHome, ".codex")
}
suffix := "-" + threadID + ".jsonl"
for _, root := range []string{"sessions", "archived_sessions"} {
base := filepath.Join(home, root)
var found string
walkErr := filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// Best-effort walk: skip unreadable entries rather than aborting.
return nil
}
if d.IsDir() {
if d.Type()&fs.ModeSymlink != 0 {
return filepath.SkipDir
}
return nil
}
if d.Type()&fs.ModeSymlink != 0 {
// Do not follow symlinked rollouts.
return nil
}
if !d.Type().IsRegular() {
return nil
}
name := d.Name()
if !strings.HasPrefix(name, "rollout-") || !strings.HasSuffix(name, suffix) {
return nil
}
if m := modelFromCodexRollout(path, turnID); m != "" {
found = m
return fs.SkipAll
}
return nil
})
if walkErr == nil && found != "" {
return found
}
}
return ""
}

// modelFromCodexRollout scans a single Codex rollout JSONL file for a
// turn_context record whose turn_id matches turnID and returns the model
// field from that record. Every error (unreadable file, malformed line,
// missing fields) returns an empty string.
func modelFromCodexRollout(path, turnID string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), maxCodexRolloutRecord)
for scanner.Scan() {
line := scanner.Bytes()
// Fast path: skip lines that cannot be a turn_context record without
// paying for an unmarshal.
if !bytes.Contains(line, []byte("turn_context")) {
continue
}
var rec struct {
Type string `json:"type"`
Payload map[string]interface{} `json:"payload"`
}
if err := json.Unmarshal(line, &rec); err != nil {
continue
}
if rec.Type != "turn_context" {
continue
}
if firstString(rec.Payload, "turn_id", "turn-id") != turnID {
continue
}
if m := firstString(rec.Payload, "model", "model-name", "model_name"); m != "" {
return m
}
return ""
}
return ""
}
144 changes: 144 additions & 0 deletions stations/notify/internal/adapter/codex_model_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package adapter

import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
)

// writeCodexRollout creates a Codex CLI rollout JSONL file under
// CODEX_HOME/sessions/2026/07/25/rollout-<timestamp>-<thread>.jsonl and writes
// the given raw lines (one JSON record per line). It returns the file path.
func writeCodexRollout(t *testing.T, codexHome, thread, timestamp string, lines []string) string {
t.Helper()
dir := filepath.Join(codexHome, "sessions", "2026", "07", "25")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir rollout dir: %v", err)
}
path := filepath.Join(dir, fmt.Sprintf("rollout-%s-%s.jsonl", timestamp, thread))
f, err := os.Create(path)
if err != nil {
t.Fatalf("create rollout: %v", err)
}
defer f.Close()
w := bufio.NewWriter(f)
for _, l := range lines {
if _, err := w.WriteString(l + "\n"); err != nil {
t.Fatalf("write rollout line: %v", err)
}
}
if err := w.Flush(); err != nil {
t.Fatalf("flush rollout: %v", err)
}
return path
}

// turnContextLine returns a JSONL record for a Codex turn_context entry with
// the given turn_id and model.
func turnContextLine(turnID, model string) string {
rec := map[string]interface{}{
"type": "turn_context",
"payload": map[string]interface{}{
"turn_id": turnID,
"model": model,
},
}
b, err := json.Marshal(rec)
if err != nil {
panic(err)
}
return string(b)
}

func TestResolveCodexModel_MatchesExactTurn(t *testing.T) {
codexHome := t.TempDir()
writeCodexRollout(t, codexHome, "abc", "20260725-180000", []string{
turnContextLine("turn-old", "gpt-5.5"),
turnContextLine("turn-7", "gpt-5.6-sol"),
})
t.Setenv("CODEX_HOME", codexHome)
got := resolveCodexModel("abc", "turn-7")
if got != "gpt-5.6-sol" {
t.Errorf("resolveCodexModel = %q, want gpt-5.6-sol", got)
}
}

func TestResolveCodexModel_DoesNotUseAnotherTurn(t *testing.T) {
codexHome := t.TempDir()
writeCodexRollout(t, codexHome, "abc", "20260725-180000", []string{
turnContextLine("turn-old", "gpt-5.5"),
})
t.Setenv("CODEX_HOME", codexHome)
got := resolveCodexModel("abc", "turn-7")
if got != "" {
t.Errorf("resolveCodexModel = %q, want empty (must not fall back to another turn)", got)
}
}

func TestResolveCodexModel_IgnoresBadIdentifiersAndMalformedRecords(t *testing.T) {
t.Run("malformed JSON skipped", func(t *testing.T) {
codexHome := t.TempDir()
writeCodexRollout(t, codexHome, "abc", "20260725-180000", []string{
"{not valid json",
turnContextLine("turn-7", "gpt-5.6-sol"),
})
t.Setenv("CODEX_HOME", codexHome)
got := resolveCodexModel("abc", "turn-7")
if got != "gpt-5.6-sol" {
t.Errorf("resolveCodexModel = %q, want gpt-5.6-sol after skipping malformed line", got)
}
})

t.Run("path traversal thread rejected", func(t *testing.T) {
codexHome := t.TempDir()
writeCodexRollout(t, codexHome, "abc", "20260725-180000", []string{
turnContextLine("turn-7", "gpt-5.6-sol"),
})
t.Setenv("CODEX_HOME", codexHome)
got := resolveCodexModel("../abc", "turn-7")
if got != "" {
t.Errorf("resolveCodexModel = %q for ../thread, want empty", got)
}
})

t.Run("empty turn rejected", func(t *testing.T) {
codexHome := t.TempDir()
writeCodexRollout(t, codexHome, "abc", "20260725-180000", []string{
turnContextLine("turn-7", "gpt-5.6-sol"),
})
t.Setenv("CODEX_HOME", codexHome)
got := resolveCodexModel("abc", "")
if got != "" {
t.Errorf("resolveCodexModel = %q for empty turn, want empty", got)
}
})
}

func TestModelFromCodexRollout_UnreadablePathIsEmpty(t *testing.T) {
dir := t.TempDir()
// Passing a directory: os.Open succeeds but reading fails; must return empty.
got := modelFromCodexRollout(dir, "turn-7")
if got != "" {
t.Errorf("modelFromCodexRollout(dir) = %q, want empty", got)
}
}

func TestCodexNotify_ResolvesModelFromSession(t *testing.T) {
codexHome := t.TempDir()
writeCodexRollout(t, codexHome, "abc", "20260725-180000", []string{
turnContextLine("turn-old", "gpt-5.5"),
turnContextLine("turn-7", "gpt-5.6-sol"),
})
t.Setenv("CODEX_HOME", codexHome)
in := []byte(`{"type":"agent-turn-complete","thread-id":"abc","turn-id":"turn-7","last-assistant-message":"Built OK."}`)
m, err := CodexNotifyFromBytes(in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m.Model != "gpt-5.6-sol" {
t.Errorf("model = %q, want gpt-5.6-sol resolved from session rollout", m.Model)
}
}
13 changes: 13 additions & 0 deletions stations/notify/internal/adapter/codex_notify.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,22 @@ func CodexNotifyFromBytes(raw []byte) (canonical.Message, error) {
title = "Codex (" + turnID + ")"
}

model := firstString(ev, "model", "model-name", "model_name")
if model == "" {
// Codex's notify event often omits the model; fall back to the model
// recorded for this turn in the Codex CLI session rollout. Resolve by
// the explicit thread id only (never session_id, which is not a
// thread alias).
threadID := firstString(ev, "thread-id", "thread_id")
if threadID != "" && turnID != "" {
model = resolveCodexModel(threadID, turnID)
}
}

return canonical.Message{
Title: title,
Body: body,
Source: "codex",
Model: model,
}, nil
}
Loading
Loading