Skip to content
Open
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
41 changes: 35 additions & 6 deletions agent/codex/appserver_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -1103,7 +1103,7 @@ func (s *appServerSession) handleNotification(method string, paramsRaw json.RawM
switch method {
case "turn/started":
var notif turnNotification
if err := json.Unmarshal(paramsRaw, &notif); err == nil {
if err := json.Unmarshal(paramsRaw, &notif); err == nil && s.acceptsThreadNotification(method, notif.ThreadID) {
s.stateMu.Lock()
s.currentTurn = notif.Turn.ID
s.pendingMsgs = s.pendingMsgs[:0]
Expand All @@ -1113,19 +1113,19 @@ func (s *appServerSession) handleNotification(method string, paramsRaw json.RawM

case "item/started":
var notif itemNotification
if err := json.Unmarshal(paramsRaw, &notif); err == nil {
if err := json.Unmarshal(paramsRaw, &notif); err == nil && s.acceptsTurnNotification(method, notif.ThreadID, notif.TurnID) {
s.handleItemStarted(notif.Item)
}

case "item/completed":
var notif itemNotification
if err := json.Unmarshal(paramsRaw, &notif); err == nil {
if err := json.Unmarshal(paramsRaw, &notif); err == nil && s.acceptsTurnNotification(method, notif.ThreadID, notif.TurnID) {
s.handleItemCompleted(notif.Item)
}

case "turn/completed":
var notif turnNotification
if err := json.Unmarshal(paramsRaw, &notif); err == nil {
if err := json.Unmarshal(paramsRaw, &notif); err == nil && s.acceptsTurnNotification(method, notif.ThreadID, notif.Turn.ID) {
s.completeTurn()
}

Expand All @@ -1136,7 +1136,7 @@ func (s *appServerSession) handleNotification(method string, paramsRaw json.RawM
Type string `json:"type"`
} `json:"status"`
}
if err := json.Unmarshal(paramsRaw, &notif); err == nil && notif.Status.Type == "idle" {
if err := json.Unmarshal(paramsRaw, &notif); err == nil && notif.Status.Type == "idle" && s.acceptsThreadNotification(method, notif.ThreadID) {
// In codex 0.125+, thread going idle signals turn completion.
s.completeTurn()
}
Expand All @@ -1149,7 +1149,7 @@ func (s *appServerSession) handleNotification(method string, paramsRaw json.RawM

case "thread/tokenUsage/updated":
var notif appServerThreadTokenUsageNotification
if err := json.Unmarshal(paramsRaw, &notif); err == nil {
if err := json.Unmarshal(paramsRaw, &notif); err == nil && s.acceptsTurnNotification(method, notif.ThreadID, notif.TurnID) {
s.storeContextUsage(mapAppServerTokenUsage(notif))
}

Expand All @@ -1161,6 +1161,35 @@ func (s *appServerSession) handleNotification(method string, paramsRaw json.RawM
}
}

func (s *appServerSession) acceptsThreadNotification(method, threadID string) bool {
currentThreadID := s.CurrentSessionID()
if threadID == "" || currentThreadID == "" || threadID == currentThreadID {
return true
}
slog.Debug("codex app-server: ignoring notification for another thread",
"method", method, "thread_id", threadID, "current_thread_id", currentThreadID)
return false
}

func (s *appServerSession) acceptsTurnNotification(method, threadID, turnID string) bool {
if !s.acceptsThreadNotification(method, threadID) {
return false
}
if turnID == "" {
return true
}

s.stateMu.Lock()
currentTurn := s.currentTurn
s.stateMu.Unlock()
if currentTurn == "" || turnID == currentTurn {
return true
}
slog.Debug("codex app-server: ignoring notification for another turn",
"method", method, "turn_id", turnID, "current_turn_id", currentTurn)
return false
}

func (s *appServerSession) handleItemStarted(item map[string]any) {
itemType, _ := item["type"].(string)
if itemType == "" {
Expand Down
112 changes: 112 additions & 0 deletions agent/codex/appserver_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,82 @@ func TestAppServerSession_HandleThreadTokenUsageUpdatedCachesContextUsage(t *tes
}
}

func TestAppServerSession_IgnoresSubagentLifecycleNotifications(t *testing.T) {
s := &appServerSession{events: make(chan core.Event, 8)}
s.threadID.Store("parent-thread")
s.currentTurn = "parent-turn"

s.handleNotification("turn/started", notificationProbe(t, map[string]any{
"threadId": "child-thread",
"turn": map[string]any{"id": "child-turn", "status": "inProgress"},
}))
if got := appServerCurrentTurn(s); got != "parent-turn" {
t.Fatalf("current turn = %q after child turn started, want parent-turn", got)
}

s.handleNotification("item/completed", notificationProbe(t, map[string]any{
"threadId": "child-thread",
"turnId": "child-turn",
"item": map[string]any{"type": "agentMessage", "text": "child-only answer"},
}))
if got := appServerPendingMessages(s); len(got) != 0 {
t.Fatalf("pending messages = %v after child item, want none", got)
}

s.handleNotification("turn/completed", notificationProbe(t, map[string]any{
"threadId": "child-thread",
"turn": map[string]any{"id": "child-turn", "status": "completed"},
}))
s.handleNotification("thread/status/changed", notificationProbe(t, map[string]any{
"threadId": "child-thread",
"status": map[string]any{"type": "idle"},
}))
if got := appServerCurrentTurn(s); got != "parent-turn" {
t.Fatalf("current turn = %q after child completion, want parent-turn", got)
}
select {
case event := <-s.events:
t.Fatalf("child lifecycle emitted event %#v, want none", event)
default:
}

s.handleNotification("item/completed", notificationProbe(t, map[string]any{
"threadId": "parent-thread",
"turnId": "parent-turn",
"item": map[string]any{"type": "agentMessage", "text": "parent answer"},
}))
s.handleNotification("turn/completed", notificationProbe(t, map[string]any{
"threadId": "parent-thread",
"turn": map[string]any{"id": "parent-turn", "status": "completed"},
}))

textEvent := <-s.events
if textEvent.Type != core.EventText || textEvent.Content != "parent answer" {
t.Fatalf("text event = %#v, want parent answer", textEvent)
}
resultEvent := <-s.events
if resultEvent.Type != core.EventResult || !resultEvent.Done {
t.Fatalf("result event = %#v, want completed parent result", resultEvent)
}
}

func TestAppServerSession_IgnoresSubagentTokenUsageNotifications(t *testing.T) {
s := &appServerSession{}
s.threadID.Store("parent-thread")
s.currentTurn = "parent-turn"

s.handleNotification("thread/tokenUsage/updated", tokenUsageNotificationProbe(t, "parent-thread", "parent-turn", 1234))
s.handleNotification("thread/tokenUsage/updated", tokenUsageNotificationProbe(t, "child-thread", "child-turn", 9876))

usage := s.GetContextUsage()
if usage == nil {
t.Fatal("GetContextUsage() = nil, want parent usage")
}
if usage.UsedTokens != 1234 {
t.Fatalf("used tokens = %d after child update, want parent value 1234", usage.UsedTokens)
}
}

func TestAppServerSession_RequestTimeoutIncludesBlockedStdinWrite(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Expand Down Expand Up @@ -432,6 +508,42 @@ func serverRequestProbe(t *testing.T, idJSON, method string, params any) map[str
}
}

func notificationProbe(t *testing.T, params any) json.RawMessage {
t.Helper()
raw, err := json.Marshal(params)
if err != nil {
t.Fatalf("marshal notification: %v", err)
}
return raw
}

func tokenUsageNotificationProbe(t *testing.T, threadID, turnID string, totalTokens int) json.RawMessage {
t.Helper()
return notificationProbe(t, map[string]any{
"threadId": threadID,
"turnId": turnID,
"tokenUsage": map[string]any{
"total": map[string]any{},
"last": map[string]any{
"totalTokens": totalTokens,
},
"modelContextWindow": 258400,
},
})
}

func appServerCurrentTurn(s *appServerSession) string {
s.stateMu.Lock()
defer s.stateMu.Unlock()
return s.currentTurn
}

func appServerPendingMessages(s *appServerSession) []string {
s.stateMu.Lock()
defer s.stateMu.Unlock()
return append([]string(nil), s.pendingMsgs...)
}

func waitForWrittenJSONLine(t *testing.T, w *lockedWriteCloser) string {
t.Helper()
deadline := time.After(time.Second)
Expand Down
Loading