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
273 lines
7.1 KiB
Go
273 lines
7.1 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
type codexPendingSideMessage struct {
|
|
threadID string
|
|
message acpMessage
|
|
}
|
|
|
|
type codexPendingSideRoute struct {
|
|
sourceThreadID string
|
|
sideThreadID string
|
|
sideAgentSessionID string
|
|
messages []codexPendingSideMessage
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) beginPendingSideRoute(
|
|
client *codexAppServerClient,
|
|
sourceThreadID string,
|
|
) error {
|
|
if a == nil || client == nil {
|
|
return ErrSideConversationExpired
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.pendingSideRoutes == nil {
|
|
a.pendingSideRoutes =
|
|
make(map[*codexAppServerClient]*codexPendingSideRoute)
|
|
}
|
|
if a.pendingSideRoutes[client] != nil {
|
|
return ErrSideConversationConflict
|
|
}
|
|
a.pendingSideRoutes[client] = &codexPendingSideRoute{
|
|
sourceThreadID: strings.TrimSpace(sourceThreadID),
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) discardPendingSideRoute(
|
|
client *codexAppServerClient,
|
|
) {
|
|
if a == nil || client == nil {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
delete(a.pendingSideRoutes, client)
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) commitPendingSideRoute(
|
|
client *codexAppServerClient,
|
|
sideAgentSessionID string,
|
|
sideThreadID string,
|
|
session *codexAppServerSession,
|
|
) error {
|
|
if a == nil || client == nil || session == nil {
|
|
return ErrSideConversationExpired
|
|
}
|
|
sideAgentSessionID = strings.TrimSpace(sideAgentSessionID)
|
|
sideThreadID = strings.TrimSpace(sideThreadID)
|
|
a.mu.Lock()
|
|
pending := a.pendingSideRoutes[client]
|
|
if pending == nil || sideAgentSessionID == "" || sideThreadID == "" {
|
|
a.mu.Unlock()
|
|
return ErrSideConversationExpired
|
|
}
|
|
if a.sessions[sideAgentSessionID] != nil {
|
|
a.mu.Unlock()
|
|
return ErrSideConversationConflict
|
|
}
|
|
session.ensureInitialized()
|
|
if session.serverInfo == nil {
|
|
session.serverInfo = map[string]any{}
|
|
}
|
|
if session.pendingRequests == nil {
|
|
session.pendingRequests = make(map[string]*pendingInteractiveRequest)
|
|
}
|
|
a.sessions[sideAgentSessionID] = session
|
|
pending.sideAgentSessionID = sideAgentSessionID
|
|
pending.sideThreadID = sideThreadID
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) drainPendingSideMessages(
|
|
client *codexAppServerClient,
|
|
) (messages []codexPendingSideMessage, complete bool) {
|
|
if a == nil || client == nil {
|
|
return nil, true
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
pending := a.pendingSideRoutes[client]
|
|
if pending == nil {
|
|
return nil, true
|
|
}
|
|
if len(pending.messages) == 0 {
|
|
delete(a.pendingSideRoutes, client)
|
|
return nil, true
|
|
}
|
|
messages = append(
|
|
[]codexPendingSideMessage(nil),
|
|
pending.messages...,
|
|
)
|
|
pending.messages = nil
|
|
return messages, false
|
|
}
|
|
|
|
// installSharedAppServerRouter upgrades a source-owned connection from the
|
|
// legacy one-session callback to connection-scoped thread routing. Codex Side
|
|
// must fork and remain on this exact connection so an in-progress source Turn
|
|
// is part of the provider snapshot.
|
|
func (a *CodexAppServerAdapter) installSharedAppServerRouter(
|
|
client *codexAppServerClient,
|
|
fallback Session,
|
|
) {
|
|
if a == nil || client == nil {
|
|
return
|
|
}
|
|
client.SetMessageRouter(func(ctx context.Context, message acpMessage) error {
|
|
return a.routeSharedAppServerMessage(ctx, client, fallback, message)
|
|
})
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) routeSharedAppServerMessage(
|
|
ctx context.Context,
|
|
client *codexAppServerClient,
|
|
fallback Session,
|
|
message acpMessage,
|
|
) error {
|
|
return a.routeSharedAppServerMessageWithPending(
|
|
ctx,
|
|
client,
|
|
fallback,
|
|
message,
|
|
true,
|
|
)
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) routeSharedAppServerMessageWithPending(
|
|
ctx context.Context,
|
|
client *codexAppServerClient,
|
|
fallback Session,
|
|
message acpMessage,
|
|
allowPending bool,
|
|
) error {
|
|
var params map[string]any
|
|
_ = json.Unmarshal(message.Params, ¶ms)
|
|
threadID := appServerMessageThreadID(params)
|
|
|
|
session := fallback
|
|
agentSessionID := strings.TrimSpace(fallback.AgentSessionID)
|
|
matchedThread := false
|
|
a.mu.Lock()
|
|
if threadID != "" {
|
|
// An exact runtime session owns its provider thread. Parent child-thread
|
|
// indexes are only a fallback: map iteration order must never let a
|
|
// stale/overlapping child index steal a committed Side thread.
|
|
for candidateID, candidate := range a.sessions {
|
|
if candidate == nil || candidate.client != client {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(candidate.threadID) == threadID {
|
|
agentSessionID = candidateID
|
|
session = candidate.runtimeSession
|
|
matchedThread = true
|
|
break
|
|
}
|
|
}
|
|
if !matchedThread {
|
|
for candidateID, candidate := range a.sessions {
|
|
if candidate == nil || candidate.client != client {
|
|
continue
|
|
}
|
|
if _, knownChild := candidate.childThreads[threadID]; knownChild {
|
|
agentSessionID = candidateID
|
|
session = candidate.runtimeSession
|
|
matchedThread = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if allowPending && threadID != "" {
|
|
if pending := a.pendingSideRoutes[client]; pending != nil &&
|
|
threadID != pending.sourceThreadID &&
|
|
(!matchedThread || threadID == pending.sideThreadID) {
|
|
if len(message.ID) > 0 {
|
|
a.mu.Unlock()
|
|
err := errors.New(
|
|
"codex sent a server request for an uncommitted Side thread",
|
|
)
|
|
_ = client.Respond(
|
|
ctx,
|
|
message.ID,
|
|
nil,
|
|
&acpError{Code: -32000, Message: err.Error()},
|
|
)
|
|
return err
|
|
}
|
|
pending.messages = append(pending.messages, codexPendingSideMessage{
|
|
threadID: threadID,
|
|
message: message,
|
|
})
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
}
|
|
if strings.TrimSpace(session.AgentSessionID) == "" {
|
|
if candidate := a.sessions[agentSessionID]; candidate != nil {
|
|
session = candidate.runtimeSession
|
|
}
|
|
}
|
|
a.mu.Unlock()
|
|
|
|
// Unknown provider child threads remain owned by the source fallback; the
|
|
// existing child-thread router will either resolve or deliberately drop
|
|
// them. A registered Side thread always matches above and never falls
|
|
// through to the parent.
|
|
if strings.TrimSpace(session.AgentSessionID) == "" {
|
|
session = fallback
|
|
}
|
|
if threadID != "" && strings.TrimSpace(session.ProviderSessionID) == "" {
|
|
session.ProviderSessionID = threadID
|
|
}
|
|
|
|
turnID := ""
|
|
var normalizer *acpTurnNormalizer
|
|
var emit func([]activityshared.Event)
|
|
var emitCommands CommandSnapshotSink
|
|
if activeTurn := a.sessionActiveTurn(agentSessionID); activeTurn != nil {
|
|
session = activeTurn.session
|
|
turnID = activeTurn.turnID
|
|
normalizer = activeTurn.normalizer
|
|
emit = activeTurn.emit
|
|
emitCommands = activeTurn.emitCommands
|
|
}
|
|
events, err := a.handleAppServerMessage(
|
|
ctx,
|
|
client,
|
|
session,
|
|
turnID,
|
|
message,
|
|
normalizer,
|
|
emit,
|
|
emitCommands,
|
|
)
|
|
if emit != nil {
|
|
emit(events)
|
|
} else {
|
|
// Idle notifications can arrive before OpenSide returns (for example,
|
|
// thread/name/updated emitted by thread/fork). Once replayed against the
|
|
// committed child they must still enter the Side-only observer path.
|
|
a.emitSessionEvents(agentSessionID, events)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func appServerMessageThreadID(params map[string]any) string {
|
|
return strings.TrimSpace(firstNonEmpty(
|
|
asString(params["threadId"]),
|
|
asString(params["conversationId"]),
|
|
asString(payloadObject(params["thread"])["id"]),
|
|
))
|
|
}
|