Files
tutti/packages/agent/daemon/runtime/acp_scripted_transport_test.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

717 lines
20 KiB
Go

package agentruntime
import (
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
func readSessionTestdataJSON(t *testing.T, name string) map[string]any {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
path := filepath.Clean(filepath.Join(filepath.Dir(file), "testdata", name))
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read session testdata %s: %v", path, err)
}
var body map[string]any
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal session testdata %s: %v", path, err)
}
return body
}
func testSession() Session {
return Session{
RoomID: "room-1",
AgentSessionID: "agent-session-1",
Provider: ProviderNexight,
ProviderSessionID: "agent-session-1",
CWD: "/workspace/room-1",
Status: SessionStatusReady,
}
}
type scriptedACPTransport struct {
mu sync.Mutex
specs []ProcessSpec
conn *scriptedACPConnection
}
func newScriptedACPTransport() *scriptedACPTransport {
return &scriptedACPTransport{conn: &scriptedACPConnection{
recv: make(chan ProcessFrame, 32),
supportsSessionRestore: true,
supportsHTTPMCP: true,
respondSetMode: true,
}}
}
func (t *scriptedACPTransport) Start(_ context.Context, spec ProcessSpec) (ProcessConnection, error) {
t.mu.Lock()
t.specs = append(t.specs, spec)
t.mu.Unlock()
return t.conn, nil
}
type scriptedACPConnection struct {
mu sync.Mutex
sent [][]byte
setConfigOptionSnapshots []map[string]any
configOptions []map[string]any
recv chan ProcessFrame
supportsSessionRestore bool
supportsHTTPMCP bool
respondSetMode bool
authRequiredOnNewSession bool
commandUpdateOnNewSession bool
commandUpdateOnLoadSession bool
loadSessionError *acpError
promptPermission bool
promptKind string
pauseBeforePromptResult chan struct{}
promptFinalContent string
pendingPermissionPromptID json.RawMessage
selectedPermissionOption string
selectedInteractiveResult map[string]any
appServerTurnStatus string
}
func (c *scriptedACPConnection) Send(data []byte) error {
c.mu.Lock()
c.sent = append(c.sent, append([]byte(nil), data...))
c.mu.Unlock()
for _, line := range acpScanLines(data) {
var message struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Result json.RawMessage `json:"result"`
}
_ = json.Unmarshal([]byte(line), &message)
if c.handleAppServerMessage(line, message.ID, message.Method) {
continue
}
switch message.Method {
case acpMethodInitialize:
result := map[string]any{
"protocolVersion": acpProtocolVersion,
"agentInfo": map[string]any{
"name": "codex-acp",
"title": "Codex",
},
}
if c.supportsSessionRestore || c.supportsHTTPMCP {
capabilities := map[string]any{}
if c.supportsSessionRestore {
capabilities["loadSession"] = true
}
if c.supportsHTTPMCP {
capabilities["mcpCapabilities"] = map[string]any{"http": true}
}
result["agentCapabilities"] = capabilities
}
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"id": message.ID,
"result": result,
})
case acpMethodNewSession:
if c.authRequiredOnNewSession {
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"id": message.ID,
"error": map[string]any{
"code": -32001,
"message": "auth required",
"data": map[string]any{
"authRequired": true,
},
},
})
return nil
}
if c.commandUpdateOnNewSession {
c.sendAvailableCommandsUpdate()
}
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"id": message.ID,
"result": map[string]any{
"sessionId": "codex-acp-session-1",
"configOptions": c.defaultConfigOptions(),
},
})
case acpMethodLoadSession, acpMethodResume:
if c.commandUpdateOnLoadSession {
c.sendAvailableCommandsUpdate()
}
if c.loadSessionError != nil {
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"id": message.ID,
"error": c.loadSessionError,
})
return nil
}
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"id": message.ID,
"result": map[string]any{
"configOptions": c.defaultConfigOptions(),
},
})
case acpMethodSetMode:
if c.respondSetMode {
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"id": message.ID,
"result": map[string]any{},
})
}
case acpMethodSetConfigOption:
var request struct {
Params map[string]any `json:"params"`
}
_ = json.Unmarshal([]byte(line), &request)
c.mu.Lock()
c.setConfigOptionSnapshots = append(c.setConfigOptionSnapshots, clonePayload(request.Params))
c.mu.Unlock()
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"id": message.ID,
"result": map[string]any{},
})
case acpMethodPrompt:
if c.promptPermission || c.promptKind != "" {
c.mu.Lock()
c.pendingPermissionPromptID = append(json.RawMessage(nil), message.ID...)
c.mu.Unlock()
toolCall, options := c.promptRequest()
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"id": "permission-1",
"method": acpMethodPermission,
"params": map[string]any{
"toolCall": toolCall,
"options": options,
},
})
return nil
}
if c.pauseBeforePromptResult != nil {
<-c.pauseBeforePromptResult
}
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"method": acpMethodUpdate,
"params": map[string]any{
"sessionId": "codex-acp-session-1",
"update": map[string]any{
"sessionUpdate": "tool_call",
"toolCallId": "tool-1",
"title": "Reading files",
"kind": "read",
"status": "in_progress",
},
},
})
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"method": acpMethodUpdate,
"params": map[string]any{
"sessionId": "codex-acp-session-1",
"update": map[string]any{
"sessionUpdate": "session_info_update",
"title": "Inspect repository structure",
},
},
})
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"method": acpMethodUpdate,
"params": map[string]any{
"sessionId": "codex-acp-session-1",
"update": map[string]any{
"sessionUpdate": "agent_thought_chunk",
"content": map[string]any{
"type": "text",
"text": "Need ",
},
},
},
})
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"method": acpMethodUpdate,
"params": map[string]any{
"sessionId": "codex-acp-session-1",
"update": map[string]any{
"sessionUpdate": "agent_thought_chunk",
"content": map[string]any{
"type": "text",
"text": "context.",
},
},
},
})
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"method": acpMethodUpdate,
"params": map[string]any{
"sessionId": "codex-acp-session-1",
"update": map[string]any{
"sessionUpdate": "agent_message_chunk",
"content": map[string]any{
"type": "text",
"text": "I'll ",
},
},
},
})
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"method": acpMethodUpdate,
"params": map[string]any{
"sessionId": "codex-acp-session-1",
"update": map[string]any{
"sessionUpdate": "agent_message_chunk",
"content": map[string]any{
"type": "text",
"text": "check ",
},
},
},
})
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"method": acpMethodUpdate,
"params": map[string]any{
"sessionId": "codex-acp-session-1",
"update": map[string]any{
"sessionUpdate": "agent_message_chunk",
"content": map[string]any{
"type": "text",
"text": "the repo.",
},
},
},
})
result := map[string]any{
"stopReason": "end_turn",
}
if strings.TrimSpace(c.promptFinalContent) != "" {
result["content"] = []map[string]any{{
"type": "text",
"text": c.promptFinalContent,
}}
}
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"id": message.ID,
"result": result,
})
default:
if (c.promptPermission || c.promptKind != "") && acpRequestID(message.ID) == "permission-1" {
var response struct {
Result struct {
Outcome struct {
OptionID string `json:"optionId"`
Outcome string `json:"outcome"`
Payload map[string]any `json:"payload"`
} `json:"outcome"`
} `json:"result"`
}
_ = json.Unmarshal([]byte(line), &response)
c.mu.Lock()
c.selectedPermissionOption = response.Result.Outcome.OptionID
c.selectedInteractiveResult = map[string]any{
"outcome": response.Result.Outcome.Outcome,
"optionId": response.Result.Outcome.OptionID,
"payload": response.Result.Outcome.Payload,
}
promptID := append(json.RawMessage(nil), c.pendingPermissionPromptID...)
c.mu.Unlock()
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"id": promptID,
"result": map[string]any{
"stopReason": "end_turn",
},
})
continue
}
if strings.TrimSpace(message.Method) != "" && len(message.ID) > 0 {
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"id": message.ID,
"error": map[string]any{
"code": -32601,
"message": "method not found: " + message.Method,
},
})
}
}
}
return nil
}
func TestScriptedACPConnectionRejectsUnknownRequests(t *testing.T) {
t.Parallel()
conn := newScriptedACPTransport().conn
if err := conn.Send([]byte("{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"future/capability\"}\n")); err != nil {
t.Fatalf("Send: %v", err)
}
frame, err := conn.Recv()
if err != nil {
t.Fatalf("Recv: %v", err)
}
var response struct {
ID int `json:"id"`
Error *acpError `json:"error"`
}
if err := json.Unmarshal(frame.Stdout, &response); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if response.ID != 99 || response.Error == nil || response.Error.Code != -32601 {
t.Fatalf("response = %#v, want method-not-found for id 99", response)
}
}
// handleAppServerMessage lets the shared scripted connection answer the codex
// app-server protocol next to ACP, so controller tests can exercise the
// app-server-backed codex adapter with the same fake. Returns true when the
// message was consumed.
func (c *scriptedACPConnection) handleAppServerMessage(line string, id json.RawMessage, method string) bool {
switch method {
case appServerMethodInitialized:
return true
case appServerMethodAccountRead:
requiresAuth := c.authRequiredOnNewSession
result := map[string]any{
"requiresOpenaiAuth": requiresAuth,
}
if !requiresAuth {
result["account"] = map[string]any{"type": "chatgpt", "planType": "pro"}
}
c.sendJSON(map[string]any{"id": id, "result": result})
return true
case appServerMethodModelList:
c.sendJSON(map[string]any{"id": id, "result": map[string]any{"data": []any{}}})
return true
case appServerMethodCollaborationModeList:
c.sendJSON(map[string]any{"id": id, "result": map[string]any{"data": []any{}}})
return true
case appServerMethodRateLimitsRead:
c.sendJSON(map[string]any{"id": id, "result": map[string]any{"rateLimits": map[string]any{}}})
return true
case appServerMethodThreadStart:
c.sendJSON(map[string]any{
"id": id,
"result": map[string]any{
"thread": map[string]any{"id": "codex-thread-1"},
},
})
return true
case appServerMethodThreadResume:
var request struct {
Params map[string]any `json:"params"`
}
_ = json.Unmarshal([]byte(line), &request)
c.sendJSON(map[string]any{
"id": id,
"result": map[string]any{
"thread": map[string]any{"id": asString(request.Params["threadId"])},
},
})
return true
case appServerMethodTurnStart:
c.mu.Lock()
c.appServerTurnStatus = "completed"
c.mu.Unlock()
// Mirror the real app-server: respond immediately with the
// inProgress turn; output streams as notifications afterwards.
c.sendJSON(map[string]any{
"id": id,
"result": map[string]any{
"turn": map[string]any{"id": "turn-1", "status": "inProgress", "items": []any{}},
},
})
c.sendJSON(map[string]any{
"method": appServerNotifyTurnStarted,
"params": map[string]any{
"threadId": "codex-thread-1",
"turn": map[string]any{"id": "turn-1", "status": "inProgress", "items": []any{}},
},
})
if c.promptPermission || c.promptKind != "" {
c.sendJSON(map[string]any{
"id": "permission-1",
"method": appServerMethodCommandApproval,
"params": map[string]any{
"threadId": "codex-thread-1",
"turnId": "turn-1",
"itemId": "item-cmd",
"command": "make test",
"cwd": "/workspace",
"startedAtMs": 1750000000000,
},
})
return true
}
if c.pauseBeforePromptResult != nil {
<-c.pauseBeforePromptResult
}
for _, delta := range []string{"Need ", "context."} {
c.sendJSON(map[string]any{
"method": appServerNotifyReasoningDelta,
"params": map[string]any{
"threadId": "codex-thread-1", "turnId": "turn-1",
"itemId": "item-think", "contentIndex": 0, "delta": delta,
},
})
}
for _, delta := range []string{"I'll ", "check ", "the repo."} {
c.sendJSON(map[string]any{
"method": appServerNotifyAgentMessageDelta,
"params": map[string]any{
"threadId": "codex-thread-1", "turnId": "turn-1",
"itemId": "item-msg", "delta": delta,
},
})
}
c.sendJSON(map[string]any{
"method": appServerNotifyItemStarted,
"params": map[string]any{
"threadId": "codex-thread-1", "turnId": "turn-1", "startedAtMs": 1750000000000,
"item": map[string]any{
"type": "commandExecution", "id": "item-cmd",
"command": "ls -la", "cwd": "/workspace", "status": "inProgress",
},
},
})
c.sendJSON(map[string]any{
"method": appServerNotifyThreadNameUpdated,
"params": map[string]any{
"threadId": "codex-thread-1",
"threadName": "Inspect repository structure",
},
})
c.completeAppServerTurn()
return true
case appServerMethodThreadGoalGet:
c.sendJSON(map[string]any{
"id": id,
"result": map[string]any{"goal": nil},
})
return true
case appServerMethodTurnInterrupt:
c.mu.Lock()
c.appServerTurnStatus = "interrupted"
c.mu.Unlock()
c.sendJSON(map[string]any{"id": id, "result": map[string]any{}})
c.completeAppServerTurn()
return true
case appServerMethodTurnSteer:
c.sendJSON(map[string]any{"id": id, "result": map[string]any{"turnId": "turn-1"}})
return true
case appServerMethodThreadCompact:
c.sendJSON(map[string]any{"id": id, "result": map[string]any{}})
return true
case appServerMethodThreadRollback:
c.sendJSON(map[string]any{
"id": id,
"result": map[string]any{"thread": map[string]any{"id": "codex-thread-1"}},
})
return true
case appServerMethodReviewStart:
c.sendJSON(map[string]any{
"id": id,
"result": map[string]any{
"reviewThreadId": "codex-thread-1",
"turn": map[string]any{
"id": "turn-review", "status": "completed",
"items": []any{map[string]any{"type": "agentMessage", "id": "item-review", "text": "Review finished."}},
},
},
})
return true
case "":
if acpRequestID(id) != "permission-1" {
return false
}
var response struct {
Result struct {
Decision string `json:"decision"`
} `json:"result"`
Error json.RawMessage `json:"error"`
}
_ = json.Unmarshal([]byte(line), &response)
if response.Result.Decision == "" {
if len(response.Error) > 0 {
// App-server approval rejected (for example on cancel); the
// turn finishes through turn/interrupt instead.
return true
}
return false
}
optionID := map[string]string{
"accept": "allow_once",
"acceptForSession": "allow_always",
"decline": "reject_once",
"cancel": "reject_always",
}[response.Result.Decision]
c.mu.Lock()
c.selectedPermissionOption = optionID
c.mu.Unlock()
c.completeAppServerTurn()
return true
default:
return false
}
}
// completeAppServerTurn finishes the in-flight app-server turn the way the
// real server does: with a turn/completed notification (the turn/start RPC
// already responded immediately).
func (c *scriptedACPConnection) completeAppServerTurn() {
c.mu.Lock()
status := firstNonEmpty(c.appServerTurnStatus, "completed")
finalContent := firstNonEmpty(strings.TrimSpace(c.promptFinalContent), "I'll check the repo.")
c.mu.Unlock()
c.sendJSON(map[string]any{
"method": appServerNotifyItemCompleted,
"params": map[string]any{
"threadId": "codex-thread-1", "turnId": "turn-1", "completedAtMs": 1750000001000,
"item": map[string]any{
"type": "commandExecution", "id": "item-cmd",
"command": "ls -la", "cwd": "/workspace", "status": "completed",
"aggregatedOutput": "README.md\n", "exitCode": 0,
},
},
})
c.sendJSON(map[string]any{
"method": appServerNotifyTurnCompleted,
"params": map[string]any{
"threadId": "codex-thread-1",
"turn": map[string]any{
"id": "turn-1",
"status": status,
"items": []any{
map[string]any{"type": "agentMessage", "id": "item-msg", "text": finalContent},
},
},
},
})
}
func (c *scriptedACPConnection) Recv() (ProcessFrame, error) {
frame, ok := <-c.recv
if !ok {
return ProcessFrame{}, io.EOF
}
return frame, nil
}
func (c *scriptedACPConnection) Close() error {
close(c.recv)
return nil
}
func (c *scriptedACPConnection) sendAvailableCommandsUpdate() {
c.sendJSON(map[string]any{
"jsonrpc": "2.0",
"method": acpMethodUpdate,
"params": map[string]any{
"sessionId": "codex-acp-session-1",
"update": map[string]any{
"sessionUpdate": "available_commands_update",
"availableCommands": []any{
map[string]any{
"name": "web",
"description": "Search the web",
"input": map[string]any{
"hint": "query",
},
},
},
},
},
})
}
func (c *scriptedACPConnection) sendJSON(value any) {
raw, _ := json.Marshal(value)
raw = append(raw, '\n')
c.recv <- ProcessFrame{Stdout: raw}
}
func (c *scriptedACPConnection) permissionOptionID() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.selectedPermissionOption
}
func (c *scriptedACPConnection) defaultConfigOptions() []map[string]any {
c.mu.Lock()
defer c.mu.Unlock()
return cloneConfigOptionDescriptors(c.configOptions)
}
func (c *scriptedACPConnection) promptRequest() (map[string]any, []map[string]any) {
switch c.promptKind {
case "ask-user":
return map[string]any{
"toolCallId": "interactive-ask-1",
"title": "AskUserQuestion",
"input": map[string]any{
"questions": []map[string]any{{
"id": "render-path",
"header": "Renderer",
"question": "Which renderer should we use?",
"options": []map[string]any{
{"label": "Renderer A", "description": "Shared transcript renderer"},
{"label": "Renderer B", "description": "Legacy room renderer"},
},
}},
},
}, nil
case "exit-plan":
return map[string]any{
"toolCallId": "interactive-plan-1",
"title": "ExitPlanMode",
"input": map[string]any{
"plan": "Implement the shared renderer",
},
}, nil
default:
return map[string]any{
"toolCallId": "approval-1",
"title": "Run command",
}, []map[string]any{{
"optionId": "allow_once",
"label": "Allow once",
"kind": "allow_once",
}, {
"optionId": "reject",
"label": "No, continue without running",
"kind": "reject_once",
}, {
"optionId": "abort",
"label": "No, and tell Codex what to do differently",
"kind": "reject_once",
}}
}
}