Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
390 lines
16 KiB
Go
390 lines
16 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
type codexAppServerReducer struct {
|
|
adapter *CodexAppServerAdapter
|
|
}
|
|
|
|
type codexAppServerReduction struct {
|
|
Events []activityshared.Event
|
|
}
|
|
|
|
func newCodexAppServerReducer(adapter *CodexAppServerAdapter) codexAppServerReducer {
|
|
return codexAppServerReducer{adapter: adapter}
|
|
}
|
|
|
|
func (r codexAppServerReducer) ReduceNotification(
|
|
client *codexAppServerClient,
|
|
session Session,
|
|
turnID string,
|
|
message acpMessage,
|
|
normalizer *acpTurnNormalizer,
|
|
emitCommands CommandSnapshotSink,
|
|
) codexAppServerReduction {
|
|
return r.reduceNotification(client, session, turnID, message, normalizer, emitCommands, false)
|
|
}
|
|
|
|
func (r codexAppServerReducer) reduceNotification(
|
|
client *codexAppServerClient, session Session, turnID string, message acpMessage,
|
|
normalizer *acpTurnNormalizer, emitCommands CommandSnapshotSink, replayingBuffered bool,
|
|
) codexAppServerReduction {
|
|
a := r.adapter
|
|
if a == nil {
|
|
return codexAppServerReduction{}
|
|
}
|
|
rootAgentSessionID := session.AgentSessionID
|
|
params := map[string]any{}
|
|
if len(message.Params) > 0 {
|
|
_ = json.Unmarshal(message.Params, ¶ms)
|
|
}
|
|
providerTurnID := appServerNotificationProviderTurnID(params)
|
|
if !replayingBuffered && message.Method != appServerNotifyTurnStarted &&
|
|
message.Method != appServerNotifyThreadGoalUpdated &&
|
|
a.bufferPendingGoalTurnNotification(session.AgentSessionID, providerTurnID, message) {
|
|
return codexAppServerReduction{}
|
|
}
|
|
diagnosticTurn := a.activeTurnForNormalizer(rootAgentSessionID, normalizer)
|
|
route := a.appServerNotificationRoute(session, turnID, message.Method, params)
|
|
if route.drop {
|
|
return codexAppServerReduction{Events: route.events}
|
|
}
|
|
if route.session.AgentSessionID != "" {
|
|
session = route.session
|
|
}
|
|
if route.normalizer != nil {
|
|
normalizer = route.normalizer
|
|
}
|
|
turnID = firstNonEmpty(route.turnID, turnID)
|
|
prefixEvents := route.events
|
|
if _, canceled := a.rootTurnCanceled(rootAgentSessionID); canceled && route.child == nil &&
|
|
message.Method != appServerNotifyTurnStarted && message.Method != appServerNotifyTurnCompleted {
|
|
// The route still runs first so a late spawn edge can interrupt its native
|
|
// child threads. No transcript/progress from the canceled execution is
|
|
// projected after the durable cancel boundary.
|
|
return codexAppServerReduction{}
|
|
}
|
|
if diagnosticTurn != nil {
|
|
diagnosticTurn.diagnostics.ObserveNotification(message.Method, params)
|
|
}
|
|
emit := func(events []activityshared.Event) codexAppServerReduction {
|
|
events = appServerEventsForChild(events, route.child)
|
|
combined := make([]activityshared.Event, 0, len(prefixEvents)+len(events))
|
|
combined = append(combined, prefixEvents...)
|
|
combined = append(combined, events...)
|
|
prefixEvents = nil
|
|
return codexAppServerReduction{Events: combined}
|
|
}
|
|
switch message.Method {
|
|
case appServerNotifyTurnStarted:
|
|
// Record the provider turn id (needed for turn/interrupt and
|
|
// turn/steer) only while a turn context is registered, so stray
|
|
// turns (for example compaction) cannot block future prompts.
|
|
// First-wins: once the live turn's id is recorded, a stray
|
|
// server-initiated turn/started on the same thread must not overwrite
|
|
// it — the strict completion match would then drop the real
|
|
// turn/completed and awaitTurnCompletion would never settle. After
|
|
// completion clears the id, the next turn/started (for example a goal
|
|
// continuation) records normally.
|
|
providerTurnID = strings.TrimSpace(asString(payloadObject(params["turn"])["id"]))
|
|
if activeTurn := a.sessionActiveTurn(session.AgentSessionID); activeTurn != nil {
|
|
if turn := payloadObject(params["turn"]); turn != nil {
|
|
providerTurnID = strings.TrimSpace(asString(turn["id"]))
|
|
if recorded := a.sessionActiveTurnID(session.AgentSessionID); recorded != "" && recorded != providerTurnID {
|
|
return codexAppServerReduction{}
|
|
}
|
|
if a.setSessionActiveTurnID(session.AgentSessionID, activeTurn, providerTurnID) {
|
|
a.interruptActiveTurnAsync(&codexAppServerSession{
|
|
client: client,
|
|
threadID: firstNonEmpty(asString(params["threadId"]), session.ProviderSessionID),
|
|
}, session, activeTurn, providerTurnID, "queued cancel")
|
|
}
|
|
a.confirmSessionActiveTurnStarted(session.AgentSessionID, providerTurnID)
|
|
}
|
|
} else if turn := payloadObject(params["turn"]); turn != nil {
|
|
// Server-initiated turn with no registered turn context: codex
|
|
// drives goal continuation on its own, so adopt the turn while the
|
|
// goal is active — otherwise its output would be dropped and the
|
|
// GUI would freeze while codex keeps working invisibly.
|
|
providerTurnID = strings.TrimSpace(asString(turn["id"]))
|
|
goal := a.sessionGoal(session.AgentSessionID)
|
|
goalStatus := strings.TrimSpace(asString(goal["status"]))
|
|
canceledRootTurnID, rootTurnCanceled := a.rootTurnCanceled(session.AgentSessionID)
|
|
switch {
|
|
case rootTurnCanceled:
|
|
slog.Warn("agent session app-server interrupting unowned turn after root cancellation",
|
|
"event", "agent_session.app_server.turn.unowned_after_cancel",
|
|
"agent_session_id", session.AgentSessionID,
|
|
"root_turn_id", canceledRootTurnID,
|
|
"provider_turn_id", providerTurnID,
|
|
)
|
|
// Async: a synchronous RPC on the read loop would block its own
|
|
// response from being dispatched.
|
|
go a.sendThreadInterrupt(client, session,
|
|
firstNonEmpty(asString(params["threadId"]), session.ProviderSessionID),
|
|
providerTurnID, "root turn canceled")
|
|
return emit(nil)
|
|
case goalStatus == "paused":
|
|
// The user explicitly stopped the goal (Stop pauses it); codex
|
|
// must not keep running turns for it.
|
|
slog.Warn("agent session app-server interrupting unowned turn for paused goal",
|
|
"event", "agent_session.app_server.turn.unowned_interrupted",
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_turn_id", providerTurnID,
|
|
"goal_status", goalStatus,
|
|
)
|
|
// Async: a synchronous RPC on the read loop would block its own
|
|
// response from being dispatched.
|
|
go a.sendThreadInterrupt(client, session,
|
|
firstNonEmpty(asString(params["threadId"]), session.ProviderSessionID),
|
|
providerTurnID, "goal paused")
|
|
case len(goal) > 0:
|
|
// A goal exists (in whatever state — codex may legitimately run
|
|
// a wrap-up turn while flipping the goal to complete). Presence of
|
|
// that mutable snapshot is not provenance: wait for the provider's
|
|
// turn-scoped goal generation evidence before adopting.
|
|
a.queueGoalTurnForProvenance(session, providerTurnID)
|
|
default:
|
|
slog.Info("agent session app-server unowned turn ignored",
|
|
"event", "agent_session.app_server.turn.unowned",
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_turn_id", providerTurnID,
|
|
"goal_status", goalStatus,
|
|
)
|
|
}
|
|
}
|
|
if providerTurnID == "" {
|
|
return emit(nil)
|
|
}
|
|
if ctx, ok := activityEventContext(session, "root-provider-turn-started:"+providerTurnID, turnID); ok {
|
|
started := activityshared.NewRootProviderTurnStarted(
|
|
ctx,
|
|
turnID,
|
|
providerTurnID,
|
|
)
|
|
if binding, err := a.WriteProviderTurnBinding(
|
|
ProviderTurnBindingWriteInput{
|
|
Kind: ProviderTurnBindingWriteStarted,
|
|
ProviderTurnID: providerTurnID,
|
|
},
|
|
); err == nil {
|
|
started.Payload.ProviderTurnBindingJSON = binding
|
|
}
|
|
return emit([]activityshared.Event{started})
|
|
}
|
|
return emit(nil)
|
|
case appServerNotifyTurnCompleted:
|
|
// Deliver the final turn payload to the goroutine waiting in Exec.
|
|
a.completeActiveTurn(session.AgentSessionID, payloadObject(params["turn"]))
|
|
return codexAppServerReduction{}
|
|
case appServerNotifyAgentMessageDelta:
|
|
if normalizer == nil {
|
|
return codexAppServerReduction{}
|
|
}
|
|
return emit(normalizer.AppendAssistantChunk(session, turnID, asStringRaw(params["delta"])))
|
|
case appServerNotifyCommandOutputDelta:
|
|
if normalizer == nil {
|
|
return codexAppServerReduction{}
|
|
}
|
|
return emit(normalizer.AppendToolOutputDelta(
|
|
session,
|
|
turnID,
|
|
asString(params["itemId"]),
|
|
asStringRaw(params["delta"]),
|
|
))
|
|
case appServerNotifyReasoningSummaryPart, appServerNotifyThreadSettingsUpdated:
|
|
return codexAppServerReduction{}
|
|
case appServerNotifyReasoningDelta, appServerNotifyReasoningSummary:
|
|
if normalizer == nil {
|
|
return codexAppServerReduction{}
|
|
}
|
|
return emit(normalizer.AppendThinkingChunk(session, turnID, appServerReasoningDeltaText(params)))
|
|
case appServerNotifyItemStarted:
|
|
item := payloadObject(params["item"])
|
|
if asString(item["type"]) == "subAgentActivity" && route.registeredChildCount == 0 {
|
|
return emit(nil)
|
|
}
|
|
return emit(a.appServerItemEvents(session, turnID, item, false, normalizer))
|
|
case appServerNotifyItemCompleted:
|
|
item := payloadObject(params["item"])
|
|
if asString(item["type"]) == "subAgentActivity" && route.registeredChildCount == 0 {
|
|
return emit(nil)
|
|
}
|
|
return emit(a.appServerItemEvents(session, turnID, item, true, normalizer))
|
|
case appServerNotifyFileChangePatchUpdated:
|
|
if normalizer == nil {
|
|
return codexAppServerReduction{}
|
|
}
|
|
rawChanges := params["changes"]
|
|
changes := payloadArray(rawChanges)
|
|
if len(changes) == 0 {
|
|
return codexAppServerReduction{}
|
|
}
|
|
update, ok := appServerItemToolCallUpdate(map[string]any{
|
|
"id": asString(params["itemId"]),
|
|
"type": "fileChange",
|
|
"status": "inProgress",
|
|
"changes": rawChanges,
|
|
}, false)
|
|
if !ok {
|
|
return codexAppServerReduction{}
|
|
}
|
|
events, _ := normalizer.ToolCallEvents(session, turnID, update)
|
|
events = append(events, normalizer.recordFileChangesEvents(session, turnID, changes)...)
|
|
return emit(events)
|
|
case appServerNotifyPlanUpdated:
|
|
if normalizer == nil {
|
|
return codexAppServerReduction{}
|
|
}
|
|
update := appServerPlanUpdate(turnID, params)
|
|
if update == nil {
|
|
return codexAppServerReduction{}
|
|
}
|
|
events, _ := normalizer.ToolCallEvents(session, turnID, update)
|
|
return emit(events)
|
|
case appServerNotifyTokenUsage:
|
|
a.applyTokenUsage(session.AgentSessionID, params)
|
|
if event, ok := normalizedUsageUpdatedEvent(session); ok {
|
|
return emit([]activityshared.Event{event})
|
|
}
|
|
return codexAppServerReduction{}
|
|
case appServerNotifyRateLimitsUpdated:
|
|
a.applyRateLimits(session.AgentSessionID, payloadObject(params["rateLimits"]))
|
|
if event, ok := normalizedUsageUpdatedEvent(session); ok {
|
|
return emit([]activityshared.Event{event})
|
|
}
|
|
return codexAppServerReduction{}
|
|
case appServerNotifyAccountUpdated:
|
|
a.applyAccountUpdate(session.AgentSessionID, params)
|
|
return codexAppServerReduction{}
|
|
case appServerNotifyThreadNameUpdated:
|
|
threadName := asString(params["threadName"])
|
|
if isInternalMentionRoutingTitle(threadName) {
|
|
return codexAppServerReduction{}
|
|
}
|
|
if event, ok := normalizedSessionTitleEvent(session, map[string]any{
|
|
"title": threadName,
|
|
}); ok {
|
|
return emit([]activityshared.Event{event})
|
|
}
|
|
return codexAppServerReduction{}
|
|
case appServerNotifyError:
|
|
if willRetry, _ := params["willRetry"].(bool); willRetry {
|
|
turnError := payloadObject(params["error"])
|
|
detail := asString(turnError["message"])
|
|
return emit([]activityshared.Event{appServerSystemNoticeEvent(session, turnID, "transport_retry", "", detail)})
|
|
}
|
|
a.failActiveTurnFromAppServerError(session.AgentSessionID, params)
|
|
return codexAppServerReduction{}
|
|
case appServerNotifyWarning:
|
|
message := asStringRaw(params["message"])
|
|
// The app-server currently sends this untyped advisory after a successful
|
|
// compaction. Suppress only the exact duplicate while this turn owns a
|
|
// compaction notice; keep every other warning visible.
|
|
if normalizer.HasCompactionNotice() && message == appServerCompactionAdvisoryMessage {
|
|
return emit(nil)
|
|
}
|
|
return emit([]activityshared.Event{appServerSystemNoticeEvent(session, turnID, "warning", "", message)})
|
|
case appServerNotifyDeprecation:
|
|
return emit([]activityshared.Event{appServerSystemNoticeEvent(session, turnID, "warning",
|
|
asString(params["summary"]), asString(params["details"]))})
|
|
case appServerNotifyModelRerouted:
|
|
title := fmt.Sprintf("Codex rerouted the model from %s to %s.",
|
|
asString(params["fromModel"]), asString(params["toModel"]))
|
|
return emit([]activityshared.Event{appServerSystemNoticeEvent(session, turnID, "system_notice", title, asString(params["reason"]))})
|
|
case appServerNotifyThreadCompacted:
|
|
if normalizer == nil {
|
|
return emit([]activityshared.Event{appServerSystemNoticeEvent(session, turnID, "system_notice", appServerContextCompactedTitle, "")})
|
|
}
|
|
messageID, shouldEmit := normalizer.CompleteCompactionNotice("compaction:" + turnID)
|
|
if !shouldEmit {
|
|
return codexAppServerReduction{}
|
|
}
|
|
return emit([]activityshared.Event{appServerCompactionNoticeEvent(session, turnID, messageID, "completed")})
|
|
case appServerNotifyServerRequestResolved:
|
|
a.resolvePendingRequestFromProvider(session.AgentSessionID, params)
|
|
return codexAppServerReduction{}
|
|
case appServerNotifyThreadGoalUpdated:
|
|
// Goal updates are session-scoped metadata: emit through the session
|
|
// sink so the GUI banner refreshes even while no turn context exists
|
|
// (returned reduction events are dropped without an active turn).
|
|
goal := payloadObject(params["goal"])
|
|
a.observeGoalTurnGeneration(session, strings.TrimSpace(asString(params["turnId"])), goal)
|
|
if a.providerGoalUpdateSuperseded(session.AgentSessionID, goal) {
|
|
return codexAppServerReduction{}
|
|
}
|
|
_, newStatus, statusChanged := a.applyGoalUpdate(session.AgentSessionID, goal)
|
|
a.scheduleProviderGoalAdoption(session, goal)
|
|
goalEvents := []activityshared.Event{}
|
|
if event, ok := normalizedGoalUpdatedEvent(session, "thread_goal_update"); ok {
|
|
goalEvents = append(goalEvents, event)
|
|
}
|
|
if statusChanged {
|
|
if noticeTurnID := firstNonEmpty(turnID, a.sessionMarkerTurnID(session.AgentSessionID)); noticeTurnID != "" {
|
|
if notice := appServerGoalStatusNoticeEvent(session, noticeTurnID, newStatus); notice != nil {
|
|
goalEvents = append(goalEvents, *notice)
|
|
}
|
|
}
|
|
}
|
|
a.emitSessionEvents(session.AgentSessionID, goalEvents)
|
|
return codexAppServerReduction{}
|
|
case appServerNotifyThreadGoalCleared:
|
|
a.applyGoalClear(session.AgentSessionID)
|
|
if event, ok := normalizedGoalUpdatedEvent(session, "thread_goal_cleared"); ok {
|
|
a.emitSessionEvents(session.AgentSessionID, []activityshared.Event{event})
|
|
}
|
|
return codexAppServerReduction{}
|
|
case appServerNotifyMCPServerStartupStatusUpdated:
|
|
status := appServerMCPServerStartupStatusPayload(params)
|
|
if len(status) == 0 {
|
|
return emit(nil)
|
|
}
|
|
slog.Info("agent session Codex MCP server startup status updated",
|
|
"event", "agent_session.codex.mcp.startup_status",
|
|
"agent_session_id", session.AgentSessionID,
|
|
"server_name", asString(status["name"]),
|
|
"status", asString(status["status"]),
|
|
"failure_reason", asString(status["failureReason"]),
|
|
)
|
|
events := []activityshared.Event{
|
|
newSessionActivityEvent(session, EventSessionUpdated, SessionStatusReady, map[string]any{
|
|
"mcpServerStartupStatus": status,
|
|
}),
|
|
}
|
|
if strings.EqualFold(asString(status["status"]), "failed") {
|
|
client.observeMCPStartupStatus(status)
|
|
events = append(events, codexMCPServerStartupWarningEvent(client, session, turnID, status))
|
|
}
|
|
a.emitSessionEvents(session.AgentSessionID, events)
|
|
return emit(nil)
|
|
case appServerNotifyMCPToolCallProgress:
|
|
slog.Debug("agent session Codex MCP tool call progress",
|
|
"event", "agent_session.codex.mcp.tool_progress",
|
|
"agent_session_id", session.AgentSessionID,
|
|
"item_id", asString(params["itemId"]),
|
|
"message", asString(params["message"]),
|
|
)
|
|
return emit(nil)
|
|
case appServerNotifyMCPOAuthLoginCompleted:
|
|
a.emitSessionEvents(session.AgentSessionID, []activityshared.Event{
|
|
newSessionActivityEvent(session, EventSessionUpdated, SessionStatusReady, map[string]any{
|
|
"mcpServerOAuthLogin": clonePayload(params),
|
|
}),
|
|
})
|
|
return emit(nil)
|
|
case appServerNotifyThreadStarted:
|
|
client.completeThreadLifecycleFromNotification(payloadObject(params["thread"]))
|
|
return codexAppServerReduction{}
|
|
default:
|
|
_ = emitCommands
|
|
return codexAppServerReduction{}
|
|
}
|
|
}
|