Files
tutti/packages/agent/daemon/runtime/codex_appserver_client.go
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

859 lines
22 KiB
Go

package agentruntime
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"sync"
"time"
"github.com/tutti-os/tutti/packages/agent/daemon/runtime/codexproto"
)
type codexAppServerClient struct {
raw *acpClient
// messageRouter, when present, is the connection-wide dispatcher for
// clients that own more than one app-server thread (for example a parent
// plus ephemeral Side threads). It deliberately overrides per-RPC
// handlers so notifications from another thread cannot be misattributed
// merely because one thread has an RPC in flight.
messageRouterMu sync.RWMutex
messageRouter acpMessageHandler
// Close remains idempotent after success but retries a failed physical
// transport close so lifecycle cleanup never loses process ownership.
closeMu sync.Mutex
closed bool
// parsedNotificationMethods tracks notification methods already run
// through the typed schema parse (telemetry only).
parsedNotificationMethods sync.Map
mcpFailureMu sync.Mutex
mcpFailureObserved bool
}
type codexAppServerCaller struct {
raw *acpClient
timeout time.Duration
handler acpMessageHandler
noHandler bool
rawResult json.RawMessage
}
type providerProgressWaiter interface {
WaitForProviderProgress(context.Context, time.Duration) error
}
func newCodexAppServerClient(conn ProcessConnection) *codexAppServerClient {
return &codexAppServerClient{raw: newAppServerJSONRPCClient(conn)}
}
func (c *codexAppServerClient) SetMessageHandler(handler acpMessageHandler) {
if c == nil || c.raw == nil {
return
}
c.raw.SetMessageHandler(func(ctx context.Context, message acpMessage) error {
c.parseInboundMessage(message)
if router := c.getMessageRouter(); router != nil {
return router(ctx, message)
}
if handler == nil {
return nil
}
return handler(ctx, message)
})
}
func (c *codexAppServerClient) SetMessageRouter(router acpMessageHandler) {
if c == nil {
return
}
c.messageRouterMu.Lock()
c.messageRouter = router
c.messageRouterMu.Unlock()
}
func (c *codexAppServerClient) getMessageRouter() acpMessageHandler {
if c == nil {
return nil
}
c.messageRouterMu.RLock()
defer c.messageRouterMu.RUnlock()
return c.messageRouter
}
func (c *codexAppServerClient) SetStderrSink(sink func([]byte)) {
if c == nil || c.raw == nil {
return
}
c.raw.SetStderrSink(func(chunk []byte) {
if sink != nil {
sink(chunk)
}
c.observeMCPStderr(chunk)
})
}
func (c *codexAppServerClient) observeMCPStderr(chunk []byte) {
if c == nil || len(chunk) == 0 {
return
}
diagnostics := c.raw.Diagnostics()
detail := diagnostics.StderrTail
failure := codexMCPServerStartupFailureFromStderr(detail)
c.observeMCPFailure(failure, "stderr")
}
func (c *codexAppServerClient) observeMCPStartupStatus(status map[string]any) {
c.observeMCPFailure(codexMCPServerStartupFailureFromStatus(status), "notification")
}
func (c *codexAppServerClient) completeThreadLifecycleFromNotification(thread map[string]any) {
if c == nil || c.raw == nil || len(thread) == 0 {
return
}
threadID := strings.TrimSpace(asString(thread["id"]))
if threadID == "" {
return
}
result, err := json.Marshal(map[string]any{"thread": clonePayload(thread)})
if err != nil {
return
}
c.raw.completeActiveHandler(appServerMethodThreadStart, result)
c.raw.completeActiveHandler(appServerMethodThreadResume, result)
}
// observeMCPFailure records provider-owned MCP state without changing the
// lifecycle result of an unrelated app-server RPC. Codex treats MCP servers
// as optional unless its own lifecycle response says otherwise; stderr and
// startup-status notifications do not carry enough authority to close the
// Codex session.
func (c *codexAppServerClient) observeMCPFailure(failure error, source string) {
if c == nil || failure == nil {
return
}
c.mcpFailureMu.Lock()
if c.mcpFailureObserved {
c.mcpFailureMu.Unlock()
return
}
c.mcpFailureObserved = true
c.mcpFailureMu.Unlock()
slog.Warn("agent session Codex MCP server startup failed",
"event", "agent_session.codex.mcp.startup_failed",
"source", source,
"error", failure.Error(),
)
}
func (c *codexAppServerClient) Close() error {
if c == nil || c.raw == nil {
return nil
}
c.closeMu.Lock()
defer c.closeMu.Unlock()
if c.closed {
return nil
}
err := c.raw.Close()
if err == nil {
c.closed = true
}
return err
}
func (c *codexAppServerClient) Done() <-chan struct{} {
if c == nil || c.raw == nil {
done := make(chan struct{})
close(done)
return done
}
return c.raw.Done()
}
func (c *codexAppServerClient) waitForProviderProgress(
ctx context.Context,
duration time.Duration,
) error {
if c == nil || c.raw == nil || c.raw.conn == nil {
return ErrSessionDisconnected
}
if waiter, ok := c.raw.conn.(providerProgressWaiter); ok {
return waiter.WaitForProviderProgress(ctx, duration)
}
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-c.Done():
return ErrSessionDisconnected
case <-timer.C:
return nil
}
}
func (c *codexAppServerClient) Err() error {
if c == nil || c.raw == nil {
return ErrSessionDisconnected
}
return c.raw.Err()
}
func (c *codexAppServerClient) Diagnostics() acpClientDiagnostics {
if c == nil || c.raw == nil {
return acpClientDiagnostics{}
}
return c.raw.Diagnostics()
}
func (c *codexAppServerClient) Respond(ctx context.Context, id json.RawMessage, result any, responseErr *acpError) error {
if c == nil || c.raw == nil {
return errors.New("app-server client is nil")
}
return c.raw.Respond(ctx, id, result, responseErr)
}
func (c *codexAppServerClient) Initialized(ctx context.Context) error {
if c == nil || c.raw == nil {
return errors.New("app-server client is nil")
}
return c.raw.Notify(ctx, appServerMethodInitialized, nil)
}
func (c *codexAppServerClient) typed(timeout time.Duration, handler acpMessageHandler, noHandler bool) (*codexproto.Client, *codexAppServerCaller) {
caller := &codexAppServerCaller{
raw: c.raw,
timeout: timeout,
handler: c.wrapHandler(handler),
noHandler: noHandler,
}
return codexproto.NewClient(caller), caller
}
func (c *codexAppServerClient) wrapHandler(handler acpMessageHandler) acpMessageHandler {
if handler == nil {
return nil
}
return func(ctx context.Context, message acpMessage) error {
c.parseInboundMessage(message)
if router := c.getMessageRouter(); router != nil {
return router(ctx, message)
}
return handler(ctx, message)
}
}
func (c *codexAppServerClient) parseInboundMessage(message acpMessage) {
method := strings.TrimSpace(message.Method)
if method == "" {
return
}
if len(message.ID) > 0 {
_, err := codexproto.ParseServerRequest(method, message.Params)
if err != nil {
slog.Warn("agent session app-server server request parse failed",
"event", "agent_session.app_server.server_request.parse_failed",
"method", method,
"error", err.Error(),
)
return
}
if !codexproto.IsKnownServerRequestMethod(method) {
slog.Warn("agent session app-server unknown server request",
"event", "agent_session.app_server.server_request.unknown",
"method", method,
)
}
return
}
// Notifications are the hot path (token-delta traffic) and the reducer
// re-decodes params into map[string]any anyway (D7): the typed parse here
// is schema-drift telemetry only, so run it once per method per client
// instead of paying a second full decode on every frame.
if _, seen := c.parsedNotificationMethods.LoadOrStore(method, struct{}{}); seen {
return
}
_, err := codexproto.ParseServerNotification(method, message.Params)
if err != nil {
slog.Warn("agent session app-server notification parse failed",
"event", "agent_session.app_server.notification.parse_failed",
"method", method,
"error", err.Error(),
)
return
}
if !codexproto.IsKnownServerNotificationMethod(method) {
slog.Warn("agent session app-server unknown notification",
"event", "agent_session.app_server.notification.unknown",
"method", method,
)
}
}
func (c *codexAppServerCaller) Call(ctx context.Context, method string, params any, result any) error {
if c.raw == nil {
return errors.New("app-server client is nil")
}
var raw json.RawMessage
var err error
if c.noHandler {
raw, err = c.raw.CallNoHandlerWithTimeout(ctx, c.timeout, method, params)
} else {
raw, err = c.raw.CallWithTimeout(ctx, c.timeout, method, params, c.handler)
}
if err != nil {
return err
}
c.rawResult = append(c.rawResult[:0], raw...)
if result == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, result); err != nil {
slog.Warn("agent session app-server typed response decode failed",
"event", "agent_session.app_server.typed_response.decode_failed",
"method", method,
"error", err.Error(),
)
}
return nil
}
func codexProtoParams[T any](params map[string]any) (T, error) {
var out T
if params == nil {
return out, nil
}
data, err := json.Marshal(params)
if err != nil {
return out, err
}
if err := json.Unmarshal(data, &out); err != nil {
return out, err
}
return out, nil
}
func (c *codexAppServerClient) Initialize(
ctx context.Context,
timeout time.Duration,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.InitializeParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, handler, false)
_, err = client.Initialize(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) AccountRead(
ctx context.Context,
timeout time.Duration,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.GetAccountParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, handler, false)
_, err = client.AccountRead(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ModelList(
ctx context.Context,
timeout time.Duration,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ModelListParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, handler, false)
_, err = client.ModelList(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ModelListNoHandler(
ctx context.Context,
timeout time.Duration,
params map[string]any,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ModelListParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, nil, true)
_, err = client.ModelList(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) AccountRateLimitsRead(
ctx context.Context,
timeout time.Duration,
handler acpMessageHandler,
) (json.RawMessage, error) {
client, caller := c.typed(timeout, handler, false)
_, err := client.AccountRateLimitsRead(ctx)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) AccountRateLimitsReadNoHandler(
ctx context.Context,
timeout time.Duration,
) (json.RawMessage, error) {
client, caller := c.typed(timeout, nil, true)
_, err := client.AccountRateLimitsRead(ctx)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) CollaborationModeList(
ctx context.Context,
timeout time.Duration,
handler acpMessageHandler,
) (json.RawMessage, error) {
client, caller := c.typed(timeout, handler, false)
_, err := client.CollaborationModeList(ctx, codexproto.CollaborationModeListParams{})
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) SkillsExtraRootsSet(
ctx context.Context,
timeout time.Duration,
extraRoots []string,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.SkillsExtraRootsSetParams](map[string]any{
"extraRoots": append([]string(nil), extraRoots...),
})
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, handler, false)
_, err = client.SkillsExtraRootsSet(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ThreadStart(
ctx context.Context,
timeout time.Duration,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadStartParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, handler, false)
_, err = client.ThreadStart(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ThreadResume(
ctx context.Context,
timeout time.Duration,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadResumeParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, handler, false)
_, err = client.ThreadResume(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ThreadFork(
ctx context.Context,
timeout time.Duration,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadForkParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, handler, false)
_, err = client.ThreadFork(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
type codexSideThreadForkParams struct {
ThreadID string `json:"threadId"`
Ephemeral *bool `json:"ephemeral,omitempty"`
ExcludeTurns *bool `json:"excludeTurns,omitempty"`
DeveloperInstructions *string `json:"developerInstructions,omitempty"`
}
func (c *codexAppServerClient) ThreadForkSide(
ctx context.Context,
timeout time.Duration,
params map[string]any,
handler acpMessageHandler,
lateResult func(json.RawMessage),
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexSideThreadForkParams](params)
if err != nil {
return nil, err
}
_ = handler
return c.raw.CallNoHandlerWithLateResult(
ctx,
timeout,
appServerMethodThreadFork,
typedParams,
lateResult,
)
}
func (c *codexAppServerClient) ThreadInjectItems(
ctx context.Context,
timeout time.Duration,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadInjectItemsParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, handler, false)
_, err = client.ThreadInjectItems(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ThreadUnsubscribeNoHandler(
ctx context.Context,
timeout time.Duration,
threadID string,
) error {
if c == nil || c.raw == nil {
return errors.New("app-server client is nil")
}
_, err := c.raw.CallNoHandlerWithTimeout(
ctx,
timeout,
appServerMethodThreadUnsubscribe,
codexproto.ThreadUnsubscribeParams{
ThreadID: strings.TrimSpace(threadID),
},
)
return err
}
func (c *codexAppServerClient) TurnStart(
ctx context.Context,
timeout time.Duration,
params map[string]any,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.TurnStartParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, nil, false)
_, err = client.TurnStart(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) TurnSteerNoHandler(
ctx context.Context,
timeout time.Duration,
params map[string]any,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.TurnSteerParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, nil, true)
_, err = client.TurnSteer(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ThreadCompactStart(
ctx context.Context,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadCompactStartParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(0, handler, false)
_, err = client.ThreadCompactStart(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ThreadGoalSet(
ctx context.Context,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadGoalSetParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(0, handler, false)
_, err = client.ThreadGoalSet(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ThreadGoalGet(
ctx context.Context,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadGoalGetParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(0, handler, false)
_, err = client.ThreadGoalGet(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ThreadGoalClear(
ctx context.Context,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadGoalClearParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(0, handler, false)
_, err = client.ThreadGoalClear(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
// The NoHandler goal variants are for background or mid-turn goal RPCs: a
// handler-carrying call claims the single active message handler slot and
// serializes behind other calls, so concurrent turn notifications would be
// swallowed (or the call would block) while the RPC is in flight.
func (c *codexAppServerClient) ThreadGoalSetNoHandler(
ctx context.Context,
params map[string]any,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadGoalSetParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(0, nil, true)
_, err = client.ThreadGoalSet(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ThreadGoalGetNoHandler(
ctx context.Context,
params map[string]any,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadGoalGetParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(0, nil, true)
_, err = client.ThreadGoalGet(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ThreadGoalClearNoHandler(
ctx context.Context,
params map[string]any,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadGoalClearParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(0, nil, true)
_, err = client.ThreadGoalClear(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
// callGoalNoHandler mirrors callGoal for the NoHandler variants.
func (s *codexAppServerSession) callGoalNoHandler(
ctx context.Context,
method string,
params map[string]any,
) (json.RawMessage, error) {
if s == nil || s.client == nil {
return nil, ErrSessionDisconnected
}
switch method {
case appServerMethodThreadGoalClear:
return s.client.ThreadGoalClearNoHandler(ctx, params)
case appServerMethodThreadGoalGet:
return s.client.ThreadGoalGetNoHandler(ctx, params)
case appServerMethodThreadGoalSet:
return s.client.ThreadGoalSetNoHandler(ctx, params)
default:
return nil, errors.New("unsupported app-server goal method")
}
}
func (s *codexAppServerSession) callGoal(
ctx context.Context,
method string,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
if s == nil || s.client == nil {
return nil, ErrSessionDisconnected
}
switch method {
case appServerMethodThreadGoalClear:
return s.client.ThreadGoalClear(ctx, params, handler)
case appServerMethodThreadGoalGet:
return s.client.ThreadGoalGet(ctx, params, handler)
case appServerMethodThreadGoalSet:
return s.client.ThreadGoalSet(ctx, params, handler)
default:
return nil, errors.New("unsupported app-server goal method")
}
}
func (c *codexAppServerClient) ThreadRollback(
ctx context.Context,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadRollbackParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(0, handler, false)
_, err = client.ThreadRollback(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ThreadRollbackNoHandler(
ctx context.Context,
timeout time.Duration,
params map[string]any,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadRollbackParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, nil, true)
_, err = client.ThreadRollback(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ReviewStart(
ctx context.Context,
params map[string]any,
handler acpMessageHandler,
) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ReviewStartParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(0, handler, false)
_, err = client.ReviewStart(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) ThreadReadNoHandler(ctx context.Context, timeout time.Duration, params map[string]any) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.ThreadReadParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, nil, true)
_, err = client.ThreadRead(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}
func (c *codexAppServerClient) TurnInterruptNoHandler(ctx context.Context, timeout time.Duration, params map[string]any) (json.RawMessage, error) {
typedParams, err := codexProtoParams[codexproto.TurnInterruptParams](params)
if err != nil {
return nil, err
}
client, caller := c.typed(timeout, nil, true)
_, err = client.TurnInterrupt(ctx, typedParams)
if err != nil {
return nil, err
}
return caller.rawResult, nil
}