From 16cbce13d4bb18732aed5c477354ce0db6cb71a6 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 30 Sep 2026 09:14:57 +0000 Subject: [PATCH 1/2] refactor(daemon): declare supported harness runtimes in adapters --- CONTRIBUTING.md | 2 - apps/daemon/internal/agent/binpath/binpath.go | 29 +- .../internal/agent/binpath/binpath_test.go | 3 - apps/daemon/internal/agent/claudecode/ask.go | 239 ------ .../internal/agent/claudecode/ask_test.go | 408 ---------- .../internal/agent/claudecode/export_test.go | 90 --- .../agent/claudecode/message_input.go | 41 - .../agent/claudecode/message_input_test.go | 40 - .../internal/agent/claudecode/options.go | 284 ------- .../internal/agent/claudecode/options_test.go | 285 ------- .../internal/agent/claudecode/parser.go | 437 ----------- .../claudecode/parser_partial_block_test.go | 56 -- .../agent/claudecode/parser_result.go | 127 ---- .../agent/claudecode/parser_result_test.go | 172 ----- .../internal/agent/claudecode/parser_test.go | 399 ---------- .../internal/agent/claudecode/permission.go | 100 --- .../agent/claudecode/permission_test.go | 71 -- .../agent/claudecode/plugins_install.go | 153 ---- .../internal/agent/claudecode/session.go | 705 ------------------ .../agent/claudecode/session_export_test.go | 38 - .../claudecode/session_knowledge_test.go | 30 - .../internal/agent/claudecode/session_test.go | 622 --------------- .../internal/agent/claudecode/version.go | 32 - .../internal/agent/claudecode/version_test.go | 17 - .../internal/agent/claudesdk/declaration.go | 154 ++++ .../agent/claudesdk/declaration_test.go | 136 ++++ apps/daemon/internal/agent/claudesdk/usage.go | 2 +- .../internal/agent/codex/declaration.go | 80 ++ .../internal/agent/codex/declaration_test.go | 55 ++ .../daemon/internal/agent/codex/mcp_config.go | 2 +- apps/daemon/internal/agent/codex/options.go | 7 +- .../internal/agent/codex/options_test.go | 2 +- apps/daemon/internal/agent/codex/protocol.go | 2 +- apps/daemon/internal/agent/codex/rpc.go | 2 +- apps/daemon/internal/agent/codex/session.go | 2 +- apps/daemon/internal/agent/codex/skills.go | 4 +- apps/daemon/internal/agent/codex/version.go | 2 +- apps/daemon/internal/agent/harness.go | 57 +- .../plugins.go => managedskills/archive.go} | 152 +--- .../archive_test.go} | 324 ++------ .../concurrency_test.go} | 32 +- .../{claudecode => managedskills}/skills.go | 51 +- .../skills_test.go | 16 +- .../internal/agent/mcode/declaration.go | 88 +++ .../internal/agent/mcode/declaration_test.go | 53 ++ .../mcode/discovery_workspace.go} | 39 +- apps/daemon/internal/agent/mcode/options.go | 4 +- .../internal/agent/opencode/export_test.go | 36 - .../daemon/internal/agent/opencode/options.go | 312 -------- .../internal/agent/opencode/options_test.go | 161 ---- apps/daemon/internal/agent/opencode/parser.go | 296 -------- .../internal/agent/opencode/parser_test.go | 169 ----- .../agent/opencode/parser_tools_test.go | 96 --- .../daemon/internal/agent/opencode/session.go | 221 ------ .../agent/opencode/session_model_test.go | 57 -- .../internal/agent/opencode/session_test.go | 406 ---------- apps/daemon/internal/agent/opencode/skills.go | 134 ---- .../internal/agent/opencode/skills_test.go | 38 - .../daemon/internal/agent/opencode/version.go | 35 - .../internal/agent/opencode/version_test.go | 18 - apps/daemon/internal/agent/pi/export_test.go | 36 - apps/daemon/internal/agent/pi/options.go | 217 ------ apps/daemon/internal/agent/pi/options_test.go | 240 ------ apps/daemon/internal/agent/pi/parser.go | 337 --------- apps/daemon/internal/agent/pi/parser_test.go | 259 ------- .../internal/agent/pi/provider_config.go | 211 ------ .../internal/agent/pi/provider_config_test.go | 308 -------- apps/daemon/internal/agent/pi/session.go | 215 ------ .../agent/pi/session_provider_test.go | 75 -- .../internal/agent/pi/session_skills_test.go | 86 --- apps/daemon/internal/agent/pi/session_test.go | 331 -------- apps/daemon/internal/agent/pi/skills.go | 393 ---------- .../agent/pi/skills_concurrency_test.go | 78 -- .../internal/agent/pi/skills_install.go | 134 ---- apps/daemon/internal/agent/pi/skills_test.go | 318 -------- apps/daemon/internal/agent/pi/version.go | 36 - apps/daemon/internal/agent/pi/version_test.go | 30 - apps/daemon/internal/agent/registry_test.go | 28 +- .../internal/agent/runtime_paths_test.go | 4 +- apps/daemon/internal/cli/agent_discovery.go | 295 +------- .../daemon/internal/cli/agent_registration.go | 60 +- .../internal/cli/capability_downloads_test.go | 4 +- apps/daemon/internal/cli/claude_sdk.go | 165 ---- .../cli/claude_sdk_live_linux_test.go | 8 +- apps/daemon/internal/cli/claude_sdk_test.go | 155 ---- apps/daemon/internal/cli/connect.go | 4 - apps/daemon/internal/cli/connect_test.go | 214 ------ apps/daemon/internal/cli/mcode.go | 71 -- .../internal/cli/mcode_execution_test.go | 26 - apps/daemon/internal/cli/mcp_test.go | 49 -- .../internal/cli/native_discovery_test.go | 90 ++- apps/daemon/internal/cli/preparation_test.go | 11 +- .../internal/dispatch/cancellation_test.go | 2 +- .../internal/dispatch/environment_test.go | 4 +- apps/daemon/internal/dispatch/router_test.go | 42 +- .../daemon/internal/dispatch/steering_test.go | 4 +- contracts/agents-api/harness-onboarding.md | 12 +- internal/agentdaemon/gateway/session_test.go | 22 +- scripts/name-allowlist.json | 15 - .../internal/api/environment_creation_test.go | 2 +- .../internal/api/environment_files_test.go | 2 +- .../core/internal/api/environments_test.go | 2 +- services/core/internal/api/vaults_test.go | 2 +- .../store/session_initial_public_test.go | 2 +- services/core/internal/store/sessions_test.go | 4 +- 105 files changed, 897 insertions(+), 11331 deletions(-) delete mode 100644 apps/daemon/internal/agent/claudecode/ask.go delete mode 100644 apps/daemon/internal/agent/claudecode/ask_test.go delete mode 100644 apps/daemon/internal/agent/claudecode/export_test.go delete mode 100644 apps/daemon/internal/agent/claudecode/message_input.go delete mode 100644 apps/daemon/internal/agent/claudecode/message_input_test.go delete mode 100644 apps/daemon/internal/agent/claudecode/options.go delete mode 100644 apps/daemon/internal/agent/claudecode/options_test.go delete mode 100644 apps/daemon/internal/agent/claudecode/parser.go delete mode 100644 apps/daemon/internal/agent/claudecode/parser_partial_block_test.go delete mode 100644 apps/daemon/internal/agent/claudecode/parser_result.go delete mode 100644 apps/daemon/internal/agent/claudecode/parser_result_test.go delete mode 100644 apps/daemon/internal/agent/claudecode/parser_test.go delete mode 100644 apps/daemon/internal/agent/claudecode/permission.go delete mode 100644 apps/daemon/internal/agent/claudecode/permission_test.go delete mode 100644 apps/daemon/internal/agent/claudecode/plugins_install.go delete mode 100644 apps/daemon/internal/agent/claudecode/session.go delete mode 100644 apps/daemon/internal/agent/claudecode/session_export_test.go delete mode 100644 apps/daemon/internal/agent/claudecode/session_knowledge_test.go delete mode 100644 apps/daemon/internal/agent/claudecode/session_test.go delete mode 100644 apps/daemon/internal/agent/claudecode/version.go delete mode 100644 apps/daemon/internal/agent/claudecode/version_test.go create mode 100644 apps/daemon/internal/agent/claudesdk/declaration.go create mode 100644 apps/daemon/internal/agent/claudesdk/declaration_test.go create mode 100644 apps/daemon/internal/agent/codex/declaration.go create mode 100644 apps/daemon/internal/agent/codex/declaration_test.go rename apps/daemon/internal/agent/{claudecode/plugins.go => managedskills/archive.go} (66%) rename apps/daemon/internal/agent/{claudecode/plugins_test.go => managedskills/archive_test.go} (57%) rename apps/daemon/internal/agent/{claudecode/install_concurrency_test.go => managedskills/concurrency_test.go} (78%) rename apps/daemon/internal/agent/{claudecode => managedskills}/skills.go (80%) rename apps/daemon/internal/agent/{claudecode => managedskills}/skills_test.go (87%) create mode 100644 apps/daemon/internal/agent/mcode/declaration.go create mode 100644 apps/daemon/internal/agent/mcode/declaration_test.go rename apps/daemon/internal/{cli/mcode_workspace.go => agent/mcode/discovery_workspace.go} (65%) delete mode 100644 apps/daemon/internal/agent/opencode/export_test.go delete mode 100644 apps/daemon/internal/agent/opencode/options.go delete mode 100644 apps/daemon/internal/agent/opencode/options_test.go delete mode 100644 apps/daemon/internal/agent/opencode/parser.go delete mode 100644 apps/daemon/internal/agent/opencode/parser_test.go delete mode 100644 apps/daemon/internal/agent/opencode/parser_tools_test.go delete mode 100644 apps/daemon/internal/agent/opencode/session.go delete mode 100644 apps/daemon/internal/agent/opencode/session_model_test.go delete mode 100644 apps/daemon/internal/agent/opencode/session_test.go delete mode 100644 apps/daemon/internal/agent/opencode/skills.go delete mode 100644 apps/daemon/internal/agent/opencode/skills_test.go delete mode 100644 apps/daemon/internal/agent/opencode/version.go delete mode 100644 apps/daemon/internal/agent/opencode/version_test.go delete mode 100644 apps/daemon/internal/agent/pi/export_test.go delete mode 100644 apps/daemon/internal/agent/pi/options.go delete mode 100644 apps/daemon/internal/agent/pi/options_test.go delete mode 100644 apps/daemon/internal/agent/pi/parser.go delete mode 100644 apps/daemon/internal/agent/pi/parser_test.go delete mode 100644 apps/daemon/internal/agent/pi/provider_config.go delete mode 100644 apps/daemon/internal/agent/pi/provider_config_test.go delete mode 100644 apps/daemon/internal/agent/pi/session.go delete mode 100644 apps/daemon/internal/agent/pi/session_provider_test.go delete mode 100644 apps/daemon/internal/agent/pi/session_skills_test.go delete mode 100644 apps/daemon/internal/agent/pi/session_test.go delete mode 100644 apps/daemon/internal/agent/pi/skills.go delete mode 100644 apps/daemon/internal/agent/pi/skills_concurrency_test.go delete mode 100644 apps/daemon/internal/agent/pi/skills_install.go delete mode 100644 apps/daemon/internal/agent/pi/skills_test.go delete mode 100644 apps/daemon/internal/agent/pi/version.go delete mode 100644 apps/daemon/internal/agent/pi/version_test.go delete mode 100644 apps/daemon/internal/cli/claude_sdk.go delete mode 100644 apps/daemon/internal/cli/claude_sdk_test.go delete mode 100644 apps/daemon/internal/cli/mcode.go delete mode 100644 apps/daemon/internal/cli/mcode_execution_test.go delete mode 100644 apps/daemon/internal/cli/mcp_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39e3287ff..70167c981 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -154,8 +154,6 @@ Provider bootstrap, Runtime images and Harness adapters must agree on these name Historical Runtime and project-version upgrades are not supported. Do not ship retired installer conversion implementations; preserve rejection guards under the [installer lifecycle contract](deploy/install/README.md#versions-and-the-lock). Preserve older installations, Runtime files, provider resources and Session history; install the current release separately. Startup never verifies and rebinds historical allocations or accepts node deployments without a valid specification. Keep the original Core responsible for unresolved resources; see the [installation version policy](docs/getting-started/operations.md#installation-version-policy). Use this release's template builder for new E2B templates. Ordinary current-version database initialization uses the migration runner. -The dormant Pi adapter keeps its `parsar` provider slug because the separate Parsar product pins model selections to that identity. This is a product boundary exception for the name guard, like the skill-upload integration. - Build the MiniMax companion from this revision's pinned patched native sources. ## Branding diff --git a/apps/daemon/internal/agent/binpath/binpath.go b/apps/daemon/internal/agent/binpath/binpath.go index 79aec277d..792640034 100644 --- a/apps/daemon/internal/agent/binpath/binpath.go +++ b/apps/daemon/internal/agent/binpath/binpath.go @@ -1,7 +1,7 @@ // Package binpath resolves which executable each agent adapter should // probe and spawn. // -// By default every engine is looked up by bare name on PATH ("claude", +// By default every engine is looked up by bare name on PATH ("codex", // "codex", ...). That breaks in images where PATH is not under our // control: e2b's base image, for instance, ships its own // /usr/local/bin entries that can shadow the ones we install, and a @@ -13,8 +13,8 @@ // The env overrides below let an image or operator pin an absolute path // instead. They are read in ONE place so the version probe // (CheckCLIAvailable) and the run-time spawn (sessionConfig) can never -// disagree: a probe that succeeds against /custom/claude while the run -// spawns PATH's `claude` would advertise a capability the daemon cannot +// disagree: a probe that succeeds against /custom/codex while the run +// spawns PATH's `codex` would advertise a capability the daemon cannot // actually honour. package binpath @@ -26,20 +26,14 @@ import ( // Env var names for the per-engine executable overrides. Empty or unset // means "look up the default name on PATH". const ( - EnvClaudeCode = "OAC_RUNTIME_CLAUDE_BIN" - EnvCodex = "OAC_RUNTIME_CODEX_BIN" - EnvPi = "OAC_RUNTIME_PI_BIN" - EnvOpenCode = "OAC_RUNTIME_OPENCODE_BIN" - EnvMCode = "OAC_RUNTIME_MCODE_BIN" + EnvCodex = "OAC_RUNTIME_CODEX_BIN" + EnvMCode = "OAC_RUNTIME_MCODE_BIN" ) // Default executable names, used when the matching env var is unset. const ( - DefaultClaudeCode = "claude" - DefaultCodex = "codex" - DefaultPi = "pi" - DefaultOpenCode = "opencode" - DefaultMCode = "mcode" + DefaultCodex = "codex" + DefaultMCode = "mcode" ) // resolve returns the trimmed env override when set, else fallback. @@ -50,17 +44,8 @@ func resolve(envVar, fallback string) string { return fallback } -// ClaudeCode returns the claude executable to probe and spawn. -func ClaudeCode() string { return resolve(EnvClaudeCode, DefaultClaudeCode) } - // Codex returns the codex executable to probe and spawn. func Codex() string { return resolve(EnvCodex, DefaultCodex) } -// Pi returns the pi executable to probe and spawn. -func Pi() string { return resolve(EnvPi, DefaultPi) } - -// OpenCode returns the opencode executable to probe and spawn. -func OpenCode() string { return resolve(EnvOpenCode, DefaultOpenCode) } - // MCode returns the mcode executable to probe and spawn. func MCode() string { return resolve(EnvMCode, DefaultMCode) } diff --git a/apps/daemon/internal/agent/binpath/binpath_test.go b/apps/daemon/internal/agent/binpath/binpath_test.go index 079f35f18..be72eaef4 100644 --- a/apps/daemon/internal/agent/binpath/binpath_test.go +++ b/apps/daemon/internal/agent/binpath/binpath_test.go @@ -11,11 +11,8 @@ func TestResolvers(t *testing.T) { override string wantPinned string }{ - {name: "claude", envVar: EnvClaudeCode, fallback: DefaultClaudeCode, resolve: ClaudeCode, override: " /opt/agents/claude ", wantPinned: "/opt/agents/claude"}, {name: "codex", envVar: EnvCodex, fallback: DefaultCodex, resolve: Codex, override: " /opt/agents/codex ", wantPinned: "/opt/agents/codex"}, {name: "mcode", envVar: EnvMCode, fallback: DefaultMCode, resolve: MCode, override: " /opt/agents/mcode ", wantPinned: "/opt/agents/mcode"}, - {name: "pi", envVar: EnvPi, fallback: DefaultPi, resolve: Pi, override: " /opt/agents/pi ", wantPinned: "/opt/agents/pi"}, - {name: "opencode", envVar: EnvOpenCode, fallback: DefaultOpenCode, resolve: OpenCode, override: " /opt/agents/opencode ", wantPinned: "/opt/agents/opencode"}, } for _, tt := range tests { diff --git a/apps/daemon/internal/agent/claudecode/ask.go b/apps/daemon/internal/agent/claudecode/ask.go deleted file mode 100644 index 13e38415a..000000000 --- a/apps/daemon/internal/agent/claudecode/ask.go +++ /dev/null @@ -1,239 +0,0 @@ -package claudecode - -import ( - "encoding/json" - "fmt" - "strings" - "sync" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// askUserQuestionToolName matches Claude Code's built-in tool name. -const askUserQuestionToolName = "AskUserQuestion" - -// pendingAskTable maps daemon-minted ask IDs to control requests and their -// question snapshots. Take lets only one answer or timeout consume an ask. -type pendingAskTable struct { - mu sync.Mutex - byAskID map[string]pendingAskEntry -} - -type pendingAskEntry struct { - CCRequestID string - Questions []proto.PromptForUserChoiceQuestion -} - -func newPendingAskTable() *pendingAskTable { - return &pendingAskTable{byAskID: make(map[string]pendingAskEntry)} -} - -// RecordControl links a freshly minted ask id to the originating CC -// request_id (control_request path). Used when claude-code wraps -// AskUserQuestion as a can_use_tool permission check. -func (p *pendingAskTable) RecordControl(askID, ccRequestID string, questions []proto.PromptForUserChoiceQuestion) { - if askID == "" || ccRequestID == "" { - return - } - p.mu.Lock() - defer p.mu.Unlock() - p.byAskID[askID] = pendingAskEntry{CCRequestID: ccRequestID, Questions: questions} -} - -// Take atomically reads + deletes the entry recorded for askID. Used by -// SubmitPromptForUserChoice so two near-simultaneous callers (timer- -// fired cancel racing a server-delivered answer) can't both pass and -// each write a control_response. The loser sees ok=false and returns -// ErrUnknownAsk instead. -// -// Trade-off vs Resolve+Delete: if the subsequent stdin write fails, the -// entry is already gone — a retry surfaces ErrUnknownAsk rather than -// re-doing the write. The double-fire risk is the bigger hazard here -// (timer + server can both reach Submit; stdin flakes are rare and the -// session is going to die anyway when stdin errors), so we accept it. -func (p *pendingAskTable) Take(askID string, decision proto.PromptForUserChoiceDecisionPayload) (pendingAskEntry, bool, error) { - if askID == "" { - return pendingAskEntry{}, false, nil - } - p.mu.Lock() - defer p.mu.Unlock() - e, ok := p.byAskID[askID] - if !ok { - return pendingAskEntry{}, false, nil - } - if _, err := decision.AnswersFor(askQuestionIDs(e)); err != nil { - return pendingAskEntry{}, false, err - } - delete(p.byAskID, askID) - return e, true, nil -} - -// interceptAskUserQuestionFromControlRequest handles the can_use_tool check -// emitted under --permission-prompt-tool stdio. The assistant tool_use copy -// remains a normal tool event; only this request creates an ask. -func (t *translator) interceptAskUserQuestionFromControlRequest(ccRequestID string, input map[string]any) (proto.Envelope, bool) { - if t.askPending == nil || t.askMint == nil { - return proto.Envelope{}, false - } - if ccRequestID == "" { - return proto.Envelope{}, false - } - questions, ok := parseAskUserQuestionInput(input) - if !ok { - return proto.Envelope{}, false - } - - askID := t.askMint() - t.askPending.RecordControl(askID, ccRequestID, questions) - - env, err := proto.NewEnvelope(proto.TypePromptForUserChoice, t.runID, proto.PromptForUserChoicePayload{ - AskID: askID, - Questions: questions, - }) - if err != nil { - return proto.Envelope{}, false - } - return env, true -} - -// parseAskUserQuestionInput pulls the AskUserQuestion fields out of -// the raw tool input. The schema mirrors Claude Code's built-in: -// -// { -// "questions": [{ -// "header": "...", -// "question": "...", -// "multiSelect": false, -// "options": [{"label": "...", "description": "..."}, ...] -// }, ...] -// } -// -// Returns ok=false (fall through to a normal TypeToolCall) when the -// shape doesn't fit. Any single question with an empty question text -// or zero options invalidates the whole call — we'd rather let claude -// see the raw tool_use and re-emit than render a half-broken card. -func parseAskUserQuestionInput(input map[string]any) ([]proto.PromptForUserChoiceQuestion, bool) { - rawQuestions, exists := input["questions"] - if !exists { - return nil, false - } - list, isList := rawQuestions.([]any) - if !isList || len(list) == 0 { - return nil, false - } - - out := make([]proto.PromptForUserChoiceQuestion, 0, len(list)) - for index, rawEntry := range list { - q, isMap := rawEntry.(map[string]any) - if !isMap { - return nil, false - } - question, _ := q["question"].(string) - header, _ := q["header"].(string) - multiSelect, _ := q["multiSelect"].(bool) - - rawOptions, _ := q["options"].([]any) - options := make([]proto.PromptForUserChoiceOption, 0, len(rawOptions)) - for _, raw := range rawOptions { - om, isOptMap := raw.(map[string]any) - if !isOptMap { - continue - } - label, _ := om["label"].(string) - if label == "" { - continue - } - description, _ := om["description"].(string) - options = append(options, proto.PromptForUserChoiceOption{ - Label: label, - Description: description, - }) - } - if question == "" || len(options) == 0 { - return nil, false - } - out = append(out, proto.PromptForUserChoiceQuestion{ - ID: fmt.Sprintf("q%d", index), - Header: header, - Question: question, - MultiSelect: multiSelect, - // Claude Code's AskUserQuestion always permits the built-in - // "Other" free-text answer in addition to declared options. - IsOther: true, - Options: options, - }) - } - return out, true -} - -// buildAskUserControlResponse answers the native permission check. Denying -// execution skips Claude's local AskUserQuestion handler; the SDK supplies the -// message to the model as the tool result, including cancellation instructions. -func buildAskUserControlResponse(entry pendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) ([]byte, error) { - answers, err := decision.AnswersFor(askQuestionIDs(entry)) - if err != nil { - return nil, err - } - text := formatAskUserResultText(entry, decision, answers) - body, err := json.Marshal(map[string]any{ - "type": "control_response", - "response": map[string]any{ - "subtype": "success", - "request_id": entry.CCRequestID, - "response": map[string]any{ - "behavior": "deny", - "message": text, - }, - }, - }) - if err != nil { - return nil, err - } - return append(body, '\n'), nil -} - -// formatAskUserResultText formats answers or cancellation instructions for Claude. -func formatAskUserResultText(entry pendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload, answers map[string][]string) string { - if decision.Cancelled { - reason := strings.TrimSpace(decision.Reason) - switch reason { - case "timeout": - return "The user did not make a selection within 10 minutes. Stop the current operation, report the timeout to the user and ask about follow-up intent; do not retry this tool." - case "cancelled": - return "The user cancelled this operation. Stop follow-up actions." - default: - return "The user did not give a selection (" + reason + "). Stop follow-up actions and wait for further instructions from the user." - } - } - - out := make([]map[string]any, 0, len(entry.Questions)) - anyAnswer := false - for _, q := range entry.Questions { - answer := strings.Join(answers[q.ID], "、") - if answer != "" { - anyAnswer = true - } - out = append(out, map[string]any{ - "header": q.Header, - "answer": answer, - }) - } - if !anyAnswer { - // Treat as cancel; the operator effectively chose nothing. - return "The user did not choose any option. Stop follow-up actions and wait for further instructions from the user." - } - return mustMarshalAskQuestions(out) -} - -func mustMarshalAskQuestions(qs []map[string]any) string { - payload, _ := json.Marshal(map[string]any{"questions": qs}) - return string(payload) -} - -func askQuestionIDs(entry pendingAskEntry) []string { - ids := make([]string, len(entry.Questions)) - for i, question := range entry.Questions { - ids[i] = question.ID - } - return ids -} diff --git a/apps/daemon/internal/agent/claudecode/ask_test.go b/apps/daemon/internal/agent/claudecode/ask_test.go deleted file mode 100644 index fa4fc8b3e..000000000 --- a/apps/daemon/internal/agent/claudecode/ask_test.go +++ /dev/null @@ -1,408 +0,0 @@ -package claudecode_test - -import ( - "encoding/json" - "fmt" - "strings" - "sync" - "sync/atomic" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// askCounterMinter emits ask_001, ask_002, ... for deterministic -// envelope IDs in tests. -func askCounterMinter() func() string { - var ( - mu sync.Mutex - n int - ) - return func() string { - mu.Lock() - defer mu.Unlock() - n++ - return fmt.Sprintf("ask_%03d", n) - } -} - -// TestTranslateAskUserQuestionToolUsePathFallsThrough locks in the -// dedupe behaviour: claude-code under --permission-prompt-tool stdio -// emits the same AskUserQuestion as BOTH a tool_use frame AND a -// control_request "can_use_tool" frame. We intercept only the -// control_request path (translateControlRequest); the tool_use frame -// passes through as a regular TypeToolCall so the run timeline still -// shows the call but we don't double-render the card. -func TestTranslateAskUserQuestionToolUsePathFallsThrough(t *testing.T) { - pending := claudecode.NewPendingTableForTest() - askPending := claudecode.NewPendingAskTableForTest() - tr := claudecode.NewTranslatorWithAskForTest("run_a", pending, askPending, counterMinter(), askCounterMinter()) - - line := []byte(`{"type":"assistant","message":{"content":[ - {"type":"tool_use","id":"toolu_abc","name":"AskUserQuestion","input":{ - "questions":[{ - "header":"Confirm delete", - "question":"Delete /tmp directory?", - "multiSelect":false, - "options":[ - {"label":"Confirm delete","description":"Run rm -rf /tmp"}, - {"label":"Cancel","description":"Do not run"} - ] - }] - }} - ]}}`) - - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 { - t.Fatalf("want 1 envelope, got %d", len(out.Envelopes)) - } - env := out.Envelopes[0] - if env.Type != proto.TypeToolCall { - t.Fatalf("env.Type = %q, want TypeToolCall (tool_use path no longer intercepts AskUserQuestion)", env.Type) - } - if askPending.Len() != 0 { - t.Errorf("askPending should be empty (tool_use path is a pass-through), got %d entries", askPending.Len()) - } -} - -// TestTranslateAskUserQuestionMultiQuestionIntercepted locks in the -// new "questions length > 1 still goes through the ask flow" path — -// daemon now renders a single multi-question card instead of falling -// through to a plain tool_call frame. -func TestTranslateAskUserQuestionMultiQuestionIntercepted(t *testing.T) { - pending := claudecode.NewPendingTableForTest() - askPending := claudecode.NewPendingAskTableForTest() - tr := claudecode.NewTranslatorWithAskForTest("run_m", pending, askPending, counterMinter(), askCounterMinter()) - - line := []byte(`{"type":"control_request","request_id":"cc_multi","request":{ - "subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{ - "questions":[ - {"header":"q1","question":"pick A or B","options":[{"label":"A"},{"label":"B"}]}, - {"header":"q2","question":"pick X or Y","options":[{"label":"X"},{"label":"Y"}]} - ] - } - }}`) - - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 { - t.Fatalf("want 1 envelope, got %d", len(out.Envelopes)) - } - if out.Envelopes[0].Type != proto.TypePromptForUserChoice { - t.Errorf("want TypePromptForUserChoice, got %q", out.Envelopes[0].Type) - } - var payload proto.PromptForUserChoicePayload - if err := json.Unmarshal(out.Envelopes[0].Payload, &payload); err != nil { - t.Fatalf("decode payload: %v", err) - } - if len(payload.Questions) != 2 { - t.Fatalf("Questions len = %d, want 2", len(payload.Questions)) - } - if payload.Questions[0].Header != "q1" || payload.Questions[1].Header != "q2" { - t.Errorf("headers mismatch: %+v", payload.Questions) - } - if askPending.Len() != 1 { - t.Errorf("askPending should have one entry, got %d", askPending.Len()) - } -} - -// TestPendingAskTableTakeIsAtomic locks in the Take contract: -// concurrent Take(askID) callers must see exactly one ok=true. This is -// the contract SubmitPromptForUserChoice relies on to make sure a -// timer-fired cancel and a server-delivered answer can't both write a -// control_response back into claude's stdin. -func TestPendingAskTableTakeIsAtomic(t *testing.T) { - tbl := claudecode.NewPendingAskTableForTest() - headerQs := []proto.PromptForUserChoiceQuestion{{Header: "header text", Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "a"}}}} - emptyQs := []proto.PromptForUserChoiceQuestion{{Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "a"}}}} - tbl.RecordControl("ask_1", "cc_aaa", headerQs) - tbl.RecordControl("ask_2", "cc_bbb", emptyQs) - - if tbl.Len() != 2 { - t.Fatalf("Len = %d, want 2", tbl.Len()) - } - - // First Take consumes the entry. - e, ok := tbl.Take("ask_1") - if !ok { - t.Fatalf("Take(ask_1) ok=false") - } - if e.CCRequestID != "cc_aaa" || len(e.Questions) != 1 || e.Questions[0].Header != "header text" { - t.Errorf("entry mismatch: %+v", e) - } - if _, ok := tbl.Take("ask_1"); ok { - t.Errorf("Take(ask_1) twice both ok=true; want second take to lose") - } - if _, ok := tbl.Peek("ask_1"); ok { - t.Errorf("Peek(ask_1) after Take still ok") - } - if tbl.Len() != 1 { - t.Errorf("Len = %d, want 1", tbl.Len()) - } -} - -// TestPendingAskTableTakeRace ensures two goroutines calling Take on -// the same askID see exactly one winner — the real-world race we care -// about is the AskTimeout watchdog firing the same instant the server -// delivers the human's answer. -func TestPendingAskTableTakeRace(t *testing.T) { - qs := []proto.PromptForUserChoiceQuestion{{Header: "h", Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "a"}}}} - for i := range 200 { - tbl := claudecode.NewPendingAskTableForTest() - tbl.RecordControl("ask_x", "cc_x", qs) - - var wg sync.WaitGroup - var winners int32 - start := make(chan struct{}) - wg.Add(2) - for range 2 { - go func() { - defer wg.Done() - <-start - if _, ok := tbl.Take("ask_x"); ok { - atomic.AddInt32(&winners, 1) - } - }() - } - close(start) - wg.Wait() - if winners != 1 { - t.Fatalf("iter %d: winners = %d, want 1", i, winners) - } - } -} - -// TestBuildAskUserControlResponseMultiSelect ensures multiple answers join -// into a single human-friendly answer string. -func TestBuildAskUserControlResponseMultiSelect(t *testing.T) { - body, err := claudecode.BuildAskUserControlResponseForTest( - claudecode.PendingAskEntry{CCRequestID: "cc_m", Questions: []proto.PromptForUserChoiceQuestion{{ID: "q0", Header: "Pick lens"}}}, - proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"Safety", "Performance"}}}}, - ) - if err != nil { - t.Fatalf("buildAskUserControlResponse: %v", err) - } - v := decodeAskControlResponse(t, body) - if !strings.Contains(v.Response.Response.Message, `"answer":"Safety、Performance"`) { - t.Errorf("multi-select join failed: %s", v.Response.Response.Message) - } -} - -// Duplicate display headers cannot change the identity or ordering of answers. -func TestBuildAskUserControlResponseDuplicateHeaders(t *testing.T) { - // Two questions with IDENTICAL headers — the realistic shape when the - // model omits header entirely and both fall back to "". - body, err := claudecode.BuildAskUserControlResponseForTest( - claudecode.PendingAskEntry{ - CCRequestID: "cc_pos", - Questions: []proto.PromptForUserChoiceQuestion{ - {ID: "first", Header: "", Question: "q1"}, - {ID: "second", Header: "", Question: "q2"}, - }, - }, - proto.PromptForUserChoiceDecisionPayload{ - QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{ - {QuestionID: "second", Answers: []string{"B1"}}, - {QuestionID: "first", Answers: []string{"A1"}}, - }, - }, - ) - if err != nil { - t.Fatalf("buildAskUserControlResponse: %v", err) - } - v := decodeAskControlResponse(t, body) - text := v.Response.Response.Message - // Both answers must round-trip; the bug we're locking in against was - // "both questions end up with B1" because map["":B1] overwrote "":A1. - if !strings.Contains(text, `"answer":"A1"`) { - t.Errorf("question 0 answer (A1) missing: %s", text) - } - if !strings.Contains(text, `"answer":"B1"`) { - t.Errorf("question 1 answer (B1) missing: %s", text) - } - // Lock in the order — A1 must come first. - if strings.Index(text, `"answer":"A1"`) > strings.Index(text, `"answer":"B1"`) { - t.Errorf("answers out of order (A1 must precede B1): %s", text) - } -} - -func TestBuildAskUserControlResponseMatchesStableQuestionIDs(t *testing.T) { - body, err := claudecode.BuildAskUserControlResponseForTest( - claudecode.PendingAskEntry{ - CCRequestID: "cc_ids", - Questions: []proto.PromptForUserChoiceQuestion{ - {ID: "environment", Header: "Environment"}, - {ID: "checks", Header: "Checks"}, - }, - }, - proto.PromptForUserChoiceDecisionPayload{ - QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{ - {QuestionID: "checks", Answers: []string{"Unit", "Integration"}}, - {QuestionID: "environment", Answers: []string{"Staging"}}, - }, - }, - ) - if err != nil { - t.Fatalf("buildAskUserControlResponse: %v", err) - } - v := decodeAskControlResponse(t, body) - text := v.Response.Response.Message - if !strings.Contains(text, `"answer":"Staging","header":"Environment"`) { - t.Fatalf("stable environment answer missing: %s", text) - } - if !strings.Contains(text, `"answer":"Unit、Integration","header":"Checks"`) { - t.Fatalf("stable multi-select answer missing: %s", text) - } -} - -// askControlResponse is the JSON shape we expect from the control_request -// writeback path. The SDK reads back {response.subtype, response.request_id, -// response.response.behavior, response.response.message}. -type askControlResponse struct { - Type string `json:"type"` - Response struct { - Subtype string `json:"subtype"` - RequestID string `json:"request_id"` - Response struct { - Behavior string `json:"behavior"` - Message string `json:"message"` - } `json:"response"` - } `json:"response"` -} - -func decodeAskControlResponse(t *testing.T, raw []byte) askControlResponse { - t.Helper() - raw = []byte(strings.TrimSpace(string(raw))) - var v askControlResponse - if err := json.Unmarshal(raw, &v); err != nil { - t.Fatalf("decode control_response: %v\nraw=%s", err, raw) - } - if v.Type != "control_response" { - t.Errorf("Type = %q, want control_response", v.Type) - } - if v.Response.Subtype != "success" { - t.Errorf("Response.Subtype = %q, want success", v.Response.Subtype) - } - return v -} - -// TestTranslateControlRequestAskUserQuestionIntercepted locks in the -// control_request path: when claude-code runs with -// --permission-prompt-tool stdio it wraps AskUserQuestion as a -// can_use_tool permission check rather than a normal tool_use frame. -// The daemon must surface a TypePromptForUserChoice envelope (not a -// generic TypePermissionRequest) and record the CC request_id so the -// writeback can hit the control_response channel. -func TestTranslateControlRequestAskUserQuestionIntercepted(t *testing.T) { - pending := claudecode.NewPendingTableForTest() - askPending := claudecode.NewPendingAskTableForTest() - tr := claudecode.NewTranslatorWithAskForTest("run_c", pending, askPending, counterMinter(), askCounterMinter()) - - line := []byte(`{"type":"control_request","request_id":"cc_req_xyz","request":{ - "subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{ - "questions":[{ - "header":"Confirm delete", - "question":"Delete /tmp directory?", - "multiSelect":false, - "options":[{"label":"Confirm"},{"label":"Cancel"}] - }] - } - }}`) - - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 { - t.Fatalf("want 1 envelope, got %d", len(out.Envelopes)) - } - env := out.Envelopes[0] - if env.Type != proto.TypePromptForUserChoice { - t.Fatalf("env.Type = %q, want %q", env.Type, proto.TypePromptForUserChoice) - } - if env.ID != "run_c" { - t.Errorf("env.ID = %q, want run_c (envelope id is run id)", env.ID) - } - - var payload proto.PromptForUserChoicePayload - if err := json.Unmarshal(env.Payload, &payload); err != nil { - t.Fatalf("decode payload: %v", err) - } - if payload.AskID != "ask_001" { - t.Errorf("payload.AskID = %q, want ask_001", payload.AskID) - } - - entry, ok := askPending.Peek("ask_001") - if !ok { - t.Fatalf("ask_001 not recorded") - } - if entry.CCRequestID != "cc_req_xyz" { - t.Errorf("entry.CCRequestID = %q, want cc_req_xyz", entry.CCRequestID) - } - // And: a normal control_request (non-AskUserQuestion) still falls - // through to the legacy permission path. - tr2 := claudecode.NewTranslatorWithAskForTest("run_c2", claudecode.NewPendingTableForTest(), claudecode.NewPendingAskTableForTest(), counterMinter(), askCounterMinter()) - bashLine := []byte(`{"type":"control_request","request_id":"cc_bash_1","request":{ - "subtype":"can_use_tool","tool_name":"Bash","input":{"command":"ls"} - }}`) - out2, err := tr2.Translate(bashLine) - if err != nil { - t.Fatalf("Translate Bash: %v", err) - } - if len(out2.Envelopes) != 1 || out2.Envelopes[0].Type != proto.TypePermissionRequest { - t.Fatalf("Bash control_request must still produce TypePermissionRequest, got %#v", out2.Envelopes) - } -} - -// TestBuildAskUserControlResponseSingleSelect locks in the wire shape -// of the control_response writeback: behavior=deny + message carries -// the JSON answer payload. deny is intentional — claude shouldn't try -// to actually invoke its own AskUserQuestion local handler; the -// message is what the SDK surfaces to the model as the tool_result. -func TestBuildAskUserControlResponseSingleSelect(t *testing.T) { - body, err := claudecode.BuildAskUserControlResponseForTest( - claudecode.PendingAskEntry{CCRequestID: "cc_req_xyz", Questions: []proto.PromptForUserChoiceQuestion{{ID: "q0", Header: "Confirm delete"}}}, - proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"Confirm"}}}}, - ) - if err != nil { - t.Fatalf("buildAskUserControlResponse: %v", err) - } - v := decodeAskControlResponse(t, body) - if v.Response.RequestID != "cc_req_xyz" { - t.Errorf("RequestID = %q, want cc_req_xyz", v.Response.RequestID) - } - if v.Response.Response.Behavior != "deny" { - t.Errorf("Behavior = %q, want deny", v.Response.Response.Behavior) - } - if !strings.Contains(v.Response.Response.Message, `"answer":"Confirm"`) { - t.Errorf("answer not encoded in message: %s", v.Response.Response.Message) - } - if !strings.Contains(v.Response.Response.Message, `"header":"Confirm delete"`) { - t.Errorf("header not echoed: %s", v.Response.Response.Message) - } -} - -// TestBuildAskUserControlResponseTimeout: timeout must still produce -// behavior=deny (no retry) + the canned timeout sentence in message. -func TestBuildAskUserControlResponseTimeout(t *testing.T) { - body, err := claudecode.BuildAskUserControlResponseForTest( - claudecode.PendingAskEntry{CCRequestID: "cc_req_to", Questions: []proto.PromptForUserChoiceQuestion{{Header: "?"}}}, - proto.PromptForUserChoiceDecisionPayload{Cancelled: true, Reason: "timeout"}, - ) - if err != nil { - t.Fatalf("buildAskUserControlResponse: %v", err) - } - v := decodeAskControlResponse(t, body) - if v.Response.Response.Behavior != "deny" { - t.Errorf("Behavior = %q, want deny", v.Response.Response.Behavior) - } - if !strings.Contains(v.Response.Response.Message, "10 minutes") { - t.Errorf("timeout text missing window: %s", v.Response.Response.Message) - } -} diff --git a/apps/daemon/internal/agent/claudecode/export_test.go b/apps/daemon/internal/agent/claudecode/export_test.go deleted file mode 100644 index 7422b5cc5..000000000 --- a/apps/daemon/internal/agent/claudecode/export_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package claudecode - -// This file uses _test.go so it only compiles into the test binary, -// but lives in the production package — re-exports internal symbols -// for the external claudecode_test package without polluting the -// public surface. - -import "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - -func NewPendingTableForTest() *PendingTable { return (*PendingTable)(newPendingTable()) } - -// PendingTable is the test-visible alias for pendingTable. -type PendingTable pendingTable - -type PendingEntry = pendingEntry - -func (p *PendingTable) Record(permID, ccRequestID string, input map[string]any) { - (*pendingTable)(p).Record(permID, ccRequestID, input) -} -func (p *PendingTable) Resolve(permID string) (PendingEntry, bool) { - return (*pendingTable)(p).Resolve(permID) -} -func (p *PendingTable) LookupByCC(ccReq string) (string, bool) { - return (*pendingTable)(p).LookupByCC(ccReq) -} -func (p *PendingTable) Delete(permID string) { (*pendingTable)(p).Delete(permID) } -func (p *PendingTable) Len() int { return (*pendingTable)(p).Len() } - -// PendingAskTable is the test-visible alias for pendingAskTable. -type PendingAskTable pendingAskTable - -type PendingAskEntry = pendingAskEntry - -func NewPendingAskTableForTest() *PendingAskTable { - return (*PendingAskTable)(newPendingAskTable()) -} - -func (p *PendingAskTable) RecordControl(askID, ccRequestID string, questions []proto.PromptForUserChoiceQuestion) { - (*pendingAskTable)(p).RecordControl(askID, ccRequestID, questions) -} -func (p *PendingAskTable) Take(askID string) (PendingAskEntry, bool) { - entry, ok, _ := (*pendingAskTable)(p).Take(askID, proto.PromptForUserChoiceDecisionPayload{Cancelled: true}) - return entry, ok -} -func (p *PendingAskTable) Peek(askID string) (PendingAskEntry, bool) { - p.mu.Lock() - defer p.mu.Unlock() - entry, ok := p.byAskID[askID] - return entry, ok -} -func (p *PendingAskTable) Len() int { - p.mu.Lock() - defer p.mu.Unlock() - return len(p.byAskID) -} - -// NewTranslatorForTest constructs a translator with a deterministic -// perm-id minter. askPending and askMint default to nil — covers the -// legacy permission-only callers; pass via NewTranslatorWithAskForTest -// when exercising the AskUserQuestion interception path. -func NewTranslatorForTest(runID string, pending *PendingTable, mint func() string) *Translator { - t := newTranslator(runID, (*pendingTable)(pending), nil, permIDMinter(mint), nil) - return (*Translator)(t) -} - -// NewTranslatorWithAskForTest is the ask-aware variant. -func NewTranslatorWithAskForTest(runID string, pending *PendingTable, askPending *PendingAskTable, mint func() string, askMint func() string) *Translator { - t := newTranslator(runID, (*pendingTable)(pending), (*pendingAskTable)(askPending), permIDMinter(mint), askIDMinter(askMint)) - return (*Translator)(t) -} - -type Translator translator - -type Translation = translation - -func (t *Translator) Translate(line []byte) (Translation, error) { - return (*translator)(t).Translate(line) -} - -// Re-export proto types for the external test package. -type ( - Envelope = proto.Envelope -) - -// BuildAskUserControlResponseForTest exposes the control_request-path -// writeback builder so ask_test.go can pin the control_response shape -// claude's stdin expects under --permission-prompt-tool stdio. -func BuildAskUserControlResponseForTest(entry PendingAskEntry, decision proto.PromptForUserChoiceDecisionPayload) ([]byte, error) { - return buildAskUserControlResponse(entry, decision) -} diff --git a/apps/daemon/internal/agent/claudecode/message_input.go b/apps/daemon/internal/agent/claudecode/message_input.go deleted file mode 100644 index ce8a62cc0..000000000 --- a/apps/daemon/internal/agent/claudecode/message_input.go +++ /dev/null @@ -1,41 +0,0 @@ -package claudecode - -import ( - "bytes" - "encoding/json" - "fmt" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "strings" -) - -func buildOrderedUserMessages(input proto.MessageInput) ([]byte, error) { - if err := input.Validate(); err != nil { - return nil, err - } - var output bytes.Buffer - encoder := json.NewEncoder(&output) - encoder.SetEscapeHTML(false) - for _, message := range input { - var blocks []userContentBlock - for _, part := range message.Content { - if part.Type == "input_text" { - blocks = append(blocks, userContentBlock{Type: "text", Text: *part.Text}) - continue - } - header, data, ok := strings.Cut(*part.ImageURL, ",") - if !ok || (header != "data:image/png;base64" && header != "data:image/jpeg;base64") { - return nil, fmt.Errorf("claudecode: unsupported image reference") - } - mime := strings.TrimSuffix(strings.TrimPrefix(header, "data:"), ";base64") - blocks = append(blocks, userContentBlock{Type: "image", Source: &userContentSource{Type: "base64", MediaType: mime, Data: data}}) - } - var content any = blocks - if len(blocks) == 1 && blocks[0].Type == "text" { - content = blocks[0].Text - } - if err := encoder.Encode(userMessage{Type: "user", Message: userMessageContent{Role: "user", Content: content}}); err != nil { - return nil, err - } - } - return output.Bytes(), nil -} diff --git a/apps/daemon/internal/agent/claudecode/message_input_test.go b/apps/daemon/internal/agent/claudecode/message_input_test.go deleted file mode 100644 index b3f15e150..000000000 --- a/apps/daemon/internal/agent/claudecode/message_input_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package claudecode - -import ( - "bytes" - "encoding/json" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "testing" -) - -func TestOrderedMessageInputNativeConversion(t *testing.T) { - image := "data:image/png;base64,aW1hZ2U=" - input := proto.TextInput(" before ") - input[0].Content = append(input[0].Content, proto.InputContent{Type: "input_image", ImageURL: &image}, proto.TextInput(" after ")[0].Content[0]) - input = append(input, proto.TextInput("next")...) - raw, err := buildOrderedUserMessages(input) - if err != nil { - t.Fatal(err) - } - lines := bytes.Split(bytes.TrimSpace(raw), []byte("\n")) - if len(lines) != 2 { - t.Fatalf("message boundary lost: %s", raw) - } - var first struct { - Message struct{ Content []userContentBlock } - } - if err := json.Unmarshal(lines[0], &first); err != nil { - t.Fatal(err) - } - content := first.Message.Content - if len(content) != 3 || content[0].Text != " before " || content[1].Source == nil || content[1].Source.Data != "aW1hZ2U=" || content[2].Text != " after " { - t.Fatalf("native order changed: %s", raw) - } - image = "https://unsupported.example/image.png" - if _, err := buildOrderedUserMessages(input); err == nil { - t.Fatal("unsupported image reference accepted") - } - if _, err := buildOrderedUserMessages(proto.TextInput("")); err == nil { - t.Fatal("empty message accepted") - } -} diff --git a/apps/daemon/internal/agent/claudecode/options.go b/apps/daemon/internal/agent/claudecode/options.go deleted file mode 100644 index 65e07d324..000000000 --- a/apps/daemon/internal/agent/claudecode/options.go +++ /dev/null @@ -1,284 +0,0 @@ -// Package claudecode is the agent_kind=claude_code implementation. It -// wraps the `claude` CLI in stream-json mode as a subprocess: the -// daemon writes user messages (and control responses for permission -// decisions) to stdin and translates the NDJSON event stream coming -// out of stdout into proto.Envelope frames for the dispatch router. -package claudecode - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" -) - -// BuildResult is the output of BuildArgs. Cleanup is always non-nil -// (no-op when nothing was written) so callers can `defer res.Cleanup()` -// blindly. -type BuildResult struct { - Args []string - Env []string - Cleanup func() -} - -// BuildArgs translates an AgentOptions map into the `claude` CLI argv. -func BuildArgs(opts map[string]any, resumeSessionID string) (BuildResult, error) { - args := []string{ - "--output-format", "stream-json", - "--input-format", "stream-json", - "--include-partial-messages", - "--verbose", - "--permission-prompt-tool", "stdio", - } - var cleanups []func() - cleanup := func() { - for _, c := range cleanups { - c() - } - } - env := []string{ - "DISABLE_TELEMETRY=1", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", - // IS_SANDBOX=1 tells Claude Code to skip the "cannot be used - // with root/sudo privileges" guard. envd's RunCommand only - // passes only selected Runtime settings into oac-daemon, so the - // sandbox image's own IS_SANDBOX=1 does NOT propagate down to - // claude. Re-asserting it here is the actually-honored opt-out - // (--allow-dangerously-skip-permissions alone does NOT satisfy - // the check on 2.1.169). - "IS_SANDBOX=1", - // CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 strips opt-in beta - // fields (e.g. context_management.clear_thinking_20251015) - // from every /v1/messages body. Internal Anthropic-compatible - // gateways (vela-proxy) reject unknown fields with HTTP 400. - // The desktop app ships ~/.claude/settings.json preconfigured; - // the sandbox image doesn't, so the daemon sets it explicitly. - "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", - } - result := BuildResult{Cleanup: cleanup} - - if v, ok := opts["model"]; ok { - s, ok := v.(string) - if !ok { - return result, fmt.Errorf("claudecode.BuildArgs: model must be string, got %T", v) - } - if s != "" { - args = append(args, "--model", s) - } - } - - // bypassPermissions also appends --allow-dangerously-skip-permissions: - // Claude Code 2.1.x refuses bypass while running as root without this - // opt-in flag. Sandbox pods run as root, so omitting it would fail - // every cloud-mode prompt at subprocess start. No-op for non-root. - if v, ok := opts["mode"]; ok { - s, ok := v.(string) - if !ok { - return result, fmt.Errorf("claudecode.BuildArgs: mode must be string, got %T", v) - } - if s != "" { - args = append(args, "--permission-mode", s) - if s == "bypassPermissions" { - args = append(args, "--allow-dangerously-skip-permissions") - } - } - } - - // allowed_tools: --allowedTools a,b,c - if v, ok := opts["allowed_tools"]; ok { - tools, err := stringSlice(v) - if err != nil { - return result, fmt.Errorf("claudecode.BuildArgs: allowed_tools: %w", err) - } - if len(tools) > 0 { - args = append(args, "--allowedTools", strings.Join(tools, ",")) - } - } - - // system_prompt vs override_system_prompt are mutually exclusive on - // the CLI. If both are supplied, override wins and we strip the - // append we already added. - hasAppend := false - hasOverride := false - if v, ok := opts["system_prompt"]; ok { - s, ok := v.(string) - if !ok { - return result, fmt.Errorf("claudecode.BuildArgs: system_prompt must be string, got %T", v) - } - if s != "" { - hasAppend = true - args = append(args, "--append-system-prompt", s) - } - } - if v, ok := opts["override_system_prompt"]; ok { - s, ok := v.(string) - if !ok { - return result, fmt.Errorf("claudecode.BuildArgs: override_system_prompt must be string, got %T", v) - } - if s != "" { - if hasAppend { - // Strip the append we just added; the pair is the last - // two elements. - args = args[:len(args)-2] - hasAppend = false - } - hasOverride = true - args = append(args, "--system-prompt", s) - } - } - _ = hasOverride - - // mcp_servers: serialize the map to a 0o600 tempfile and pass - // --mcp-config . Tempfile is deleted in Cleanup. - if v, ok := opts["mcp_servers"]; ok { - mcp, ok := v.(map[string]any) - if !ok { - return result, fmt.Errorf("claudecode.BuildArgs: mcp_servers must be object, got %T", v) - } - if len(mcp) > 0 { - path, err := writeMCPTempfile(mcp) - if err != nil { - return result, err - } - cleanups = append(cleanups, func() { _ = os.Remove(path) }) - result.Cleanup = func() { - for _, c := range cleanups { - c() - } - } - args = append(args, "--mcp-config", path) - } - } - - // plugin_dirs: --plugin-dir x --plugin-dir y ... - if v, ok := opts["plugin_dirs"]; ok { - dirs, err := stringSlice(v) - if err != nil { - return result, fmt.Errorf("claudecode.BuildArgs: plugin_dirs: %w", err) - } - for _, d := range dirs { - args = append(args, "--plugin-dir", d) - } - } - - if resumeSessionID != "" { - args = append(args, "--resume", resumeSessionID) - } - - // env: passthrough KEY=value pairs. - if v, ok := opts["env"]; ok { - envMap, ok := v.(map[string]any) - if !ok { - return result, fmt.Errorf("claudecode.BuildArgs: env must be object, got %T", v) - } - // Sort keys so the produced env slice is deterministic. - keys := make([]string, 0, len(envMap)) - for k := range envMap { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - s, ok := envMap[k].(string) - if !ok { - return result, fmt.Errorf("claudecode.BuildArgs: env[%q] must be string, got %T", k, envMap[k]) - } - env = append(env, k+"="+s) - } - } - - result.Args = args - result.Env = env - return result, nil -} - -// stringSlice coerces a value to []string, accepting either a typed -// []string or []any with all-string elements (which is what -// json.Unmarshal produces for a JSON array into map[string]any). -func stringSlice(v any) ([]string, error) { - switch x := v.(type) { - case []string: - return x, nil - case []any: - out := make([]string, 0, len(x)) - for i, el := range x { - s, ok := el.(string) - if !ok { - return nil, fmt.Errorf("element %d must be string, got %T", i, el) - } - out = append(out, s) - } - return out, nil - case nil: - return nil, nil - default: - return nil, fmt.Errorf("must be array of strings, got %T", v) - } -} - -// writeMCPTempfile serialises the mcp_servers map to JSON and writes -// it to a 0o600 file in os.TempDir. Returns the absolute path. -func writeMCPTempfile(mcp map[string]any) (string, error) { - body, err := json.MarshalIndent(map[string]any{"mcpServers": mcp}, "", " ") - if err != nil { - return "", fmt.Errorf("claudecode: marshal mcp_servers: %w", err) - } - f, err := os.CreateTemp("", "oac-daemon-mcp-*.json") - if err != nil { - return "", fmt.Errorf("claudecode: create mcp tempfile: %w", err) - } - if err := f.Chmod(0o600); err != nil { - _ = f.Close() - _ = os.Remove(f.Name()) - return "", fmt.Errorf("claudecode: chmod mcp tempfile: %w", err) - } - if _, err := f.Write(body); err != nil { - _ = f.Close() - _ = os.Remove(f.Name()) - return "", fmt.Errorf("claudecode: write mcp tempfile: %w", err) - } - if err := f.Close(); err != nil { - _ = os.Remove(f.Name()) - return "", fmt.Errorf("claudecode: close mcp tempfile: %w", err) - } - // Resolve to absolute — tests sometimes change cwd which would - // make a relative path useless for the subprocess. - abs, err := filepath.Abs(f.Name()) - if err != nil { - _ = os.Remove(f.Name()) - return "", fmt.Errorf("claudecode: resolve mcp tempfile path: %w", err) - } - return abs, nil -} - -// userMessage is the JSON shape we write to claude stdin to deliver -// the prompt. -type userMessage struct { - Type string `json:"type"` - Message userMessageContent `json:"message"` -} - -type userMessageContent struct { - Role string `json:"role"` - // Content is either a bare string (text-only path) or a - // []userContentBlock when attachments are present. Both shapes - // are accepted by claude's stdin loop; the bare-string path keeps - // log greps for prompt content working in the common case. - Content any `json:"content"` -} - -// userContentBlock is one entry of Claude Code's array-of-blocks user -// message shape. JSON tags match Anthropic's content-block schema -// verbatim so the CLI forwards them to the model without translation. -type userContentBlock struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - Source *userContentSource `json:"source,omitempty"` -} - -type userContentSource struct { - Type string `json:"type"` - MediaType string `json:"media_type"` - Data string `json:"data"` -} diff --git a/apps/daemon/internal/agent/claudecode/options_test.go b/apps/daemon/internal/agent/claudecode/options_test.go deleted file mode 100644 index f0584eff1..000000000 --- a/apps/daemon/internal/agent/claudecode/options_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package claudecode_test - -import ( - "encoding/json" - "os" - "slices" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" -) - -func TestBuildArgsBaseHasStreamFlags(t *testing.T) { - res, err := claudecode.BuildArgs(nil, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - - wantContains := [][2]string{ - {"--output-format", "stream-json"}, - {"--input-format", "stream-json"}, - {"--permission-prompt-tool", "stdio"}, - } - for _, w := range wantContains { - if !containsPair(res.Args, w[0], w[1]) { - t.Errorf("missing flag pair %s=%s in %v", w[0], w[1], res.Args) - } - } - if !slices.Contains(res.Args, "--verbose") { - t.Errorf("missing --verbose in %v", res.Args) - } - if !slices.Contains(res.Args, "--include-partial-messages") { - t.Errorf("missing --include-partial-messages in %v", res.Args) - } -} - -// IS_SANDBOX=1 must be in every env passthrough. Without it Claude -// Code 2.1.x's root-guard kills the subprocess before the first user -// message. -func TestBuildArgsAlwaysExportsIsSandbox(t *testing.T) { - res, err := claudecode.BuildArgs(nil, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !slices.Contains(res.Env, "IS_SANDBOX=1") { - t.Errorf("IS_SANDBOX=1 missing from env, got %v", res.Env) - } -} - -// CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 strips opt-in beta fields -// (e.g. context_management.clear_thinking_*) from /v1/messages bodies; -// internal Anthropic-compatible gateways reject unknown fields with 400. -func TestBuildArgsAlwaysDisablesExperimentalBetas(t *testing.T) { - res, err := claudecode.BuildArgs(nil, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !slices.Contains(res.Env, "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1") { - t.Errorf("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 missing from env, got %v", res.Env) - } -} - -func TestBuildArgsHonoursPrimaryFlags(t *testing.T) { - res, err := claudecode.BuildArgs(map[string]any{ - "model": "sonnet", - "mode": "acceptEdits", - "allowed_tools": []any{"Bash", "Read", "Write"}, - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--model", "sonnet") { - t.Errorf("--model sonnet not in %v", res.Args) - } - if !containsPair(res.Args, "--permission-mode", "acceptEdits") { - t.Errorf("--permission-mode acceptEdits not in %v", res.Args) - } - if !containsPair(res.Args, "--allowedTools", "Bash,Read,Write") { - t.Errorf("--allowedTools join not in %v", res.Args) - } -} - -func TestBuildArgsResumeUsesExplicitSessionID(t *testing.T) { - res, err := claudecode.BuildArgs(map[string]any{ - "resume_session_id": "from-map", - }, "from-arg") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--resume", "from-arg") { - t.Errorf("explicit resume id not preferred, args=%v", res.Args) - } - if slices.Contains(res.Args, "from-map") { - t.Errorf("map key leaked into args=%v", res.Args) - } -} - -func TestBuildArgsIgnoresResumeSessionIDOption(t *testing.T) { - res, _ := claudecode.BuildArgs(map[string]any{"resume_session_id": "session_xyz"}, "") - defer res.Cleanup() - if slices.Contains(res.Args, "--resume") || slices.Contains(res.Args, "session_xyz") { - t.Errorf("resume_session_id option must be ignored, args=%v", res.Args) - } -} - -func TestBuildArgsAppendSystemPromptAlone(t *testing.T) { - res, _ := claudecode.BuildArgs(map[string]any{"system_prompt": "be terse"}, "") - defer res.Cleanup() - if !containsPair(res.Args, "--append-system-prompt", "be terse") { - t.Errorf("--append-system-prompt missing, args=%v", res.Args) - } -} - -func TestBuildArgsBypassPermissionsAddsAllowDangerouslyFlag(t *testing.T) { - // Sandbox containers run as root; without - // --allow-dangerously-skip-permissions Claude Code refuses to - // bypass permissions for root callers. - res, err := claudecode.BuildArgs(map[string]any{ - "mode": "bypassPermissions", - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--permission-mode", "bypassPermissions") { - t.Errorf("--permission-mode bypassPermissions not in %v", res.Args) - } - if !slices.Contains(res.Args, "--allow-dangerously-skip-permissions") { - t.Errorf("--allow-dangerously-skip-permissions missing for bypass mode, args=%v", res.Args) - } -} - -func TestBuildArgsNonBypassModeOmitsAllowDangerouslyFlag(t *testing.T) { - for _, mode := range []string{"acceptEdits", "default", "plan"} { - res, err := claudecode.BuildArgs(map[string]any{"mode": mode}, "") - if err != nil { - t.Fatalf("BuildArgs(mode=%s): %v", mode, err) - } - defer res.Cleanup() - if slices.Contains(res.Args, "--allow-dangerously-skip-permissions") { - t.Errorf("mode=%s should not enable allow-dangerously flag, args=%v", mode, res.Args) - } - } -} - -func TestBuildArgsOverrideSystemPromptStripAppend(t *testing.T) { - res, _ := claudecode.BuildArgs(map[string]any{ - "system_prompt": "be terse", - "override_system_prompt": "you are pirate", - }, "") - defer res.Cleanup() - if slices.Contains(res.Args, "--append-system-prompt") { - t.Errorf("override should have stripped append, args=%v", res.Args) - } - if !containsPair(res.Args, "--system-prompt", "you are pirate") { - t.Errorf("--system-prompt missing, args=%v", res.Args) - } -} - -func TestBuildArgsPluginDirsRepeated(t *testing.T) { - res, _ := claudecode.BuildArgs(map[string]any{ - "plugin_dirs": []any{"/a", "/b", "/c"}, - }, "") - defer res.Cleanup() - got := 0 - for i, a := range res.Args { - if a == "--plugin-dir" { - got++ - if i+1 >= len(res.Args) { - t.Fatalf("trailing --plugin-dir without value: %v", res.Args) - } - } - } - if got != 3 { - t.Errorf("expected 3 --plugin-dir flags, got %d in %v", got, res.Args) - } -} - -func TestBuildArgsMCPServersWritesTempfile(t *testing.T) { - res, err := claudecode.BuildArgs(map[string]any{ - "mcp_servers": map[string]any{ - "github": map[string]any{"command": "/usr/local/bin/mcp-github"}, - }, - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - - // Find the --mcp-config path - path := "" - for i, a := range res.Args { - if a == "--mcp-config" { - if i+1 < len(res.Args) { - path = res.Args[i+1] - } - } - } - if path == "" { - t.Fatalf("--mcp-config path missing, args=%v", res.Args) - } - if !strings.Contains(path, "oac-daemon-mcp-") { - t.Errorf("mcp tempfile name unexpected: %q", path) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("stat mcp tempfile: %v", err) - } - if perm := info.Mode().Perm(); perm != 0o600 { - t.Errorf("mcp tempfile perm = %o, want 0600", perm) - } - - body, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read mcp tempfile: %v", err) - } - var parsed map[string]any - if err := json.Unmarshal(body, &parsed); err != nil { - t.Fatalf("mcp tempfile not valid json: %v", err) - } - if _, ok := parsed["mcpServers"]; !ok { - t.Errorf("mcp tempfile missing mcpServers wrapper: %s", body) - } - - // Cleanup should remove the file. - res.Cleanup() - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Errorf("mcp tempfile still exists after Cleanup: stat err=%v", err) - } -} - -func TestBuildArgsEnvPassthroughIsSorted(t *testing.T) { - res, _ := claudecode.BuildArgs(map[string]any{ - "env": map[string]any{ - "ZED": "1", - "ANTHROPIC_KEY": "secret", - "OTHER_FLAG": "x", - }, - }, "") - defer res.Cleanup() - want := []string{"ANTHROPIC_KEY=secret", "OTHER_FLAG=x", "ZED=1"} - // res.Env always starts with the four baseline values; pop them off - // before checking the sort order of the user-supplied tail. - tail := res.Env[4:] - if !slices.Equal(tail, want) { - t.Errorf("env tail = %v, want sorted %v", tail, want) - } -} - -func TestBuildArgsRejectsWrongShapes(t *testing.T) { - cases := []struct { - name string - opts map[string]any - }{ - {"model not string", map[string]any{"model": 7}}, - {"mode not string", map[string]any{"mode": true}}, - {"allowed_tools not array", map[string]any{"allowed_tools": "Bash"}}, - {"plugin_dirs element not string", map[string]any{"plugin_dirs": []any{"/a", 7}}}, - {"mcp_servers not object", map[string]any{"mcp_servers": "json string"}}, - {"env value not string", map[string]any{"env": map[string]any{"K": 1}}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, err := claudecode.BuildArgs(tc.opts, "") - if err == nil { - t.Fatal("BuildArgs accepted bad shape") - } - }) - } -} - -func containsPair(args []string, flag, value string) bool { - for i, a := range args { - if a == flag && i+1 < len(args) && args[i+1] == value { - return true - } - } - return false -} diff --git a/apps/daemon/internal/agent/claudecode/parser.go b/apps/daemon/internal/agent/claudecode/parser.go deleted file mode 100644 index 8dac63456..000000000 --- a/apps/daemon/internal/agent/claudecode/parser.go +++ /dev/null @@ -1,437 +0,0 @@ -package claudecode - -import ( - "bytes" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "sync/atomic" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// pendingRecorder is the slice of pendingTable the parser needs. -// Interface form lets parser_test.go substitute a fake. -type pendingRecorder interface { - Record(permID, ccRequestID string, input map[string]any) - LookupByCC(ccRequestID string) (string, bool) -} - -// askRecorder is the slice of pendingAskTable the parser needs. -type askRecorder interface { - RecordControl(askID, ccRequestID string, questions []proto.PromptForUserChoiceQuestion) -} - -// permIDMinter generates the daemon-side permission id (perm_<8hex>). -// Replaceable in tests so envelope IDs are deterministic. -type permIDMinter func() string - -// askIDMinter generates the daemon-side ask id (ask_<8hex>). -// Replaceable in tests so envelope IDs are deterministic. -type askIDMinter func() string - -func defaultPermIDMinter() string { - var b [4]byte - // crypto/rand.Read failure would silently collide perm ids across - // pending requests, causing an approve-on-wrong-tool bug — panic - // loud so operators see it. - if _, err := rand.Read(b[:]); err != nil { - panic(fmt.Sprintf("claudecode: rand.Read for perm id failed: %v", err)) - } - return "perm_" + hex.EncodeToString(b[:]) -} - -func defaultAskIDMinter() string { - var b [4]byte - if _, err := rand.Read(b[:]); err != nil { - panic(fmt.Sprintf("claudecode: rand.Read for ask id failed: %v", err)) - } - return "ask_" + hex.EncodeToString(b[:]) -} - -// translator converts one NDJSON line from claude stdout into zero or -// more proto.Envelope frames. One translator lives per session. -type translator struct { - runID string - pending pendingRecorder - askPending askRecorder - seq atomic.Uint64 - mint permIDMinter - askMint askIDMinter - partialBlocks map[int]string -} - -func newTranslator(runID string, pending pendingRecorder, askPending askRecorder, mint permIDMinter, askMint askIDMinter) *translator { - if mint == nil { - mint = defaultPermIDMinter - } - if askMint == nil { - askMint = defaultAskIDMinter - } - return &translator{runID: runID, pending: pending, askPending: askPending, mint: mint, askMint: askMint} -} - -// translation is the per-line parser output. -type translation struct { - Envelopes []proto.Envelope - // Terminal is true when this line was a `result` frame — the - // session should stop reading stdout after consuming it. - Terminal bool - // SessionID is the upstream Claude session id surfaced on a system init. - // line (and again on the result frame). session.go writes this - // into binding metadata for --resume. - SessionID string -} - -// rawEnvelope is the minimal shared head every claude stream-json line -// has. -type rawEnvelope struct { - Type string `json:"type"` - Subtype string `json:"subtype,omitempty"` -} - -// Translate parses one NDJSON line. Unknown types return an empty -// translation with no error to stay forward-compatible with new claude -// stream variants. Errors are reserved for malformed JSON on frames we -// claim to understand; the session pump treats them as drop-and-log so -// one bad line doesn't kill an otherwise fine run. -func (t *translator) Translate(line []byte) (translation, error) { - line = bytes.TrimSpace(line) - if len(line) == 0 { - return translation{}, nil - } - - var head rawEnvelope - if err := json.Unmarshal(line, &head); err != nil { - return translation{}, fmt.Errorf("claudecode: parse stream-json head: %w", err) - } - - switch head.Type { - case "system": - return t.translateSystem(line) - case "assistant": - return t.translateAssistant(line) - case "stream_event": - return t.translateStreamEvent(line) - case "user": - return t.translateUser(line) - case "control_request": - return t.translateControlRequest(line) - case "control_cancel_request": - return t.translateControlCancel(line) - case "result": - return t.translateResult(line, head.Subtype) - default: - return translation{}, nil - } -} - -// translateStreamEvent handles the raw Anthropic events emitted by Claude -// Code with --include-partial-messages. Claude still emits a complete -// assistant frame after these events, so translateAssistant suppresses its -// text/thinking copies once a corresponding partial delta has been observed. -func (t *translator) translateStreamEvent(line []byte) (translation, error) { - var msg struct { - Event struct { - Type string `json:"type"` - Index int `json:"index"` - Delta struct { - Type string `json:"type"` - Text string `json:"text"` - Thinking string `json:"thinking"` - } `json:"delta"` - } `json:"event"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("claudecode: parse stream_event frame: %w", err) - } - if msg.Event.Type != "content_block_delta" { - return translation{}, nil - } - - switch msg.Event.Delta.Type { - case "text_delta": - if msg.Event.Delta.Text == "" { - return translation{}, nil - } - if t.partialBlocks == nil { - t.partialBlocks = make(map[int]string) - } - t.partialBlocks[msg.Event.Index] = "text" - env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ - Delta: msg.Event.Delta.Text, - Sequence: t.seq.Add(1), - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil - case "thinking_delta": - if msg.Event.Delta.Thinking == "" { - return translation{}, nil - } - if t.partialBlocks == nil { - t.partialBlocks = make(map[int]string) - } - t.partialBlocks[msg.Event.Index] = "thinking" - env, err := proto.NewEnvelope(proto.TypeThinking, t.runID, proto.ThinkingPayload{ - Text: msg.Event.Delta.Thinking, - Sequence: t.seq.Add(1), - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil - default: - return translation{}, nil - } -} - -func (t *translator) translateSystem(line []byte) (translation, error) { - var msg struct { - SessionID string `json:"session_id"` - } - // System lines have variable shape (init / compact / etc); missing - // session_id is a no-op, not an error. - _ = json.Unmarshal(line, &msg) - return translation{SessionID: msg.SessionID}, nil -} - -func (t *translator) translateAssistant(line []byte) (translation, error) { - var msg struct { - Message struct { - Content []json.RawMessage `json:"content"` - } `json:"message"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("claudecode: parse assistant frame: %w", err) - } - - var envs []proto.Envelope - for index, raw := range msg.Message.Content { - partialIndex := index - // Claude's per-block assistant frames restart content indexes at zero. - if len(msg.Message.Content) == 1 && len(t.partialBlocks) == 1 { - for streamedIndex := range t.partialBlocks { - partialIndex = streamedIndex - } - } - var head struct { - Type string `json:"type"` - } - if err := json.Unmarshal(raw, &head); err != nil { - continue - } - switch head.Type { - case "text": - if t.partialBlocks[partialIndex] == "text" { - continue - } - var item struct { - Text string `json:"text"` - } - if err := json.Unmarshal(raw, &item); err != nil || item.Text == "" { - continue - } - env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ - Delta: item.Text, - Sequence: t.seq.Add(1), - }) - if err != nil { - return translation{}, err - } - envs = append(envs, env) - case "thinking": - if t.partialBlocks[partialIndex] == "thinking" { - continue - } - var item struct { - Thinking string `json:"thinking"` - } - if err := json.Unmarshal(raw, &item); err != nil || item.Thinking == "" { - continue - } - env, err := proto.NewEnvelope(proto.TypeThinking, t.runID, proto.ThinkingPayload{ - Text: item.Thinking, - Sequence: t.seq.Add(1), - }) - if err != nil { - return translation{}, err - } - envs = append(envs, env) - case "tool_use": - var item struct { - ID string `json:"id"` - Name string `json:"name"` - Input map[string]any `json:"input"` - } - if err := json.Unmarshal(raw, &item); err != nil { - continue - } - // Note: AskUserQuestion intentionally NOT intercepted on this - // path. claude-code under --permission-prompt-tool stdio (our - // fixed mode) emits the SAME AskUserQuestion call twice — once - // here as a tool_use, then a few ms later as a control_request - // "can_use_tool" check. Intercepting both would produce two - // PromptForUserChoice envelopes / two cards. The control_request - // path is the one we control end-to-end (claude waits for a - // matching control_response), so the tool_use copy goes - // through as a regular TypeToolCall — the UI logs the call, - // the user still only sees one card from the control_request - // path. See translateControlRequest below. - env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ - ID: item.ID, - Name: item.Name, - Stage: "before", - Args: item.Input, - }) - if err != nil { - return translation{}, err - } - envs = append(envs, env) - } - } - // Partial flags apply only to the complete assistant frame that follows - // those stream_event deltas. Reset them so a later assistant turn that is - // delivered without partial frames is not accidentally suppressed. - clear(t.partialBlocks) - return translation{Envelopes: envs}, nil -} - -func (t *translator) translateUser(line []byte) (translation, error) { - var msg struct { - Message struct { - Content []json.RawMessage `json:"content"` - } `json:"message"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("claudecode: parse user frame: %w", err) - } - - var envs []proto.Envelope - for _, raw := range msg.Message.Content { - var head struct { - Type string `json:"type"` - } - if err := json.Unmarshal(raw, &head); err != nil { - continue - } - if head.Type != "tool_result" { - continue - } - var item struct { - ToolUseID string `json:"tool_use_id"` - Content json.RawMessage `json:"content"` - IsError bool `json:"is_error"` - } - if err := json.Unmarshal(raw, &item); err != nil { - continue - } - env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ - ID: item.ToolUseID, - Stage: "after", - Result: map[string]any{ - "content": decodeToolResultContent(item.Content), - "is_error": item.IsError, - }, - }) - if err != nil { - return translation{}, err - } - envs = append(envs, env) - } - return translation{Envelopes: envs}, nil -} - -// decodeToolResultContent best-effort decodes claude's tool_result -// content field, declared as `string | ContentBlock[]`. Returned as -// the natural Go shape so downstream consumers don't have to re-parse. -func decodeToolResultContent(raw json.RawMessage) any { - if len(raw) == 0 { - return nil - } - var v any - if err := json.Unmarshal(raw, &v); err != nil { - return string(raw) - } - return v -} - -func (t *translator) translateControlRequest(line []byte) (translation, error) { - var msg struct { - RequestID string `json:"request_id"` - Request struct { - Subtype string `json:"subtype"` - ToolName string `json:"tool_name"` - Input map[string]any `json:"input"` - } `json:"request"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("claudecode: parse control_request: %w", err) - } - if msg.RequestID == "" { - return translation{}, fmt.Errorf("claudecode: control_request missing request_id") - } - - // AskUserQuestion comes through here too when claude-code runs with - // --permission-prompt-tool stdio. The SDK wraps it as a "can_use_tool" - // permission check rather than emitting a normal tool_use frame, so - // the tool_use-branch interception in translateAssistant never sees - // it. We re-route it into the ask flow here. The CC request_id is - // stashed under askID inside ccByAsk so SubmitPromptForUserChoice can - // write a matching control_response back. - if msg.Request.ToolName == askUserQuestionToolName { - if askEnv, ok := t.interceptAskUserQuestionFromControlRequest(msg.RequestID, msg.Request.Input); ok { - return translation{Envelopes: []proto.Envelope{askEnv}}, nil - } - // Fall through to the permission path when interception can't - // build a valid payload (e.g. questions array missing). claude - // will at least see SOME response on the control_request channel - // rather than blocking; the user will get a permission card they - // can deny. - } - - permID := t.mint() - if t.pending != nil { - t.pending.Record(permID, msg.RequestID, msg.Request.Input) - } - - title := msg.Request.ToolName - if title == "" { - title = "Permission request" - } - env, err := proto.NewEnvelope(proto.TypePermissionRequest, t.runID, proto.PermissionRequestPayload{ - RequestID: permID, - Tool: msg.Request.ToolName, - Title: title, - Payload: msg.Request.Input, - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil -} - -func (t *translator) translateControlCancel(line []byte) (translation, error) { - var msg struct { - RequestID string `json:"request_id"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("claudecode: parse control_cancel_request: %w", err) - } - if msg.RequestID == "" || t.pending == nil { - return translation{}, nil - } - permID, ok := t.pending.LookupByCC(msg.RequestID) - if !ok { - // Approval already came through and the entry was Delete'd — - // drop silently. - return translation{}, nil - } - env, err := proto.NewEnvelope(proto.TypePermissionCancel, permID, nil) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil -} diff --git a/apps/daemon/internal/agent/claudecode/parser_partial_block_test.go b/apps/daemon/internal/agent/claudecode/parser_partial_block_test.go deleted file mode 100644 index c3bbc11db..000000000 --- a/apps/daemon/internal/agent/claudecode/parser_partial_block_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package claudecode_test - -import ( - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestTranslatePerBlockAssistantPreservesStreamWithoutCopies(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_blocks", nil, counterMinter()) - frames := []string{ - `{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}}`, - `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Checking."}}}`, - `{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"Checking."}]}}`, - `{"type":"stream_event","event":{"type":"content_block_stop","index":0}}`, - `{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}}`, - `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"OAC"}}}`, - `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"-IM-OK"}}}`, - `{"type":"assistant","message":{"content":[{"type":"text","text":"OAC-IM-OK"}]}}`, - `{"type":"stream_event","event":{"type":"content_block_stop","index":1}}`, - `{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool_1","name":"Read","input":{"path":"policy.md"}}]}}`, - `{"type":"stream_event","event":{"type":"content_block_start","index":3,"content_block":{"type":"text","text":""}}}`, - `{"type":"stream_event","event":{"type":"content_block_delta","index":3,"delta":{"type":"text_delta","text":"OAC-IM-OK"}}}`, - `{"type":"assistant","message":{"content":[{"type":"text","text":"OAC-IM-OK"}]}}`, - `{"type":"stream_event","event":{"type":"content_block_stop","index":3}}`, - `{"type":"result","subtype":"success","result":"OAC-IM-OK"}`, - `{"type":"assistant","message":{"content":[{"type":"text","text":"complete-only"}]}}`, - } - var text, thinking string - var tools, done int - for _, frame := range frames { - out, err := tr.Translate([]byte(frame)) - if err != nil { - t.Fatal(err) - } - for _, env := range out.Envelopes { - switch env.Type { - case proto.TypeDelta: - text += mustDecode[proto.DeltaPayload](t, env.Payload).Delta - case proto.TypeThinking: - thinking += mustDecode[proto.ThinkingPayload](t, env.Payload).Text - case proto.TypeToolCall: - tools++ - case proto.TypeDone: - done++ - if got := mustDecode[proto.DonePayload](t, env.Payload).Content; got != "OAC-IM-OK" { - t.Fatalf("final content = %q", got) - } - } - } - } - if text != "OAC-IM-OKOAC-IM-OKcomplete-only" || thinking != "Checking." || tools != 1 || done != 1 { - t.Fatalf("text=%q thinking=%q tools=%d done=%d", text, thinking, tools, done) - } -} diff --git a/apps/daemon/internal/agent/claudecode/parser_result.go b/apps/daemon/internal/agent/claudecode/parser_result.go deleted file mode 100644 index db6305727..000000000 --- a/apps/daemon/internal/agent/claudecode/parser_result.go +++ /dev/null @@ -1,127 +0,0 @@ -package claudecode - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// resultUsage is split out so we can decode usage even when the -// success/error branches differ. -type resultUsage struct { - InputTokens int32 `json:"input_tokens"` - OutputTokens int32 `json:"output_tokens"` - CacheCreationInputTokens int32 `json:"cache_creation_input_tokens,omitempty"` - CacheReadInputTokens int32 `json:"cache_read_input_tokens,omitempty"` -} - -func (t *translator) translateResult(line []byte, subtype string) (translation, error) { - defer clear(t.partialBlocks) - - var msg struct { - IsError bool `json:"is_error"` - Result string `json:"result"` - Error string `json:"error"` - Errors []string `json:"errors"` - SessionID string `json:"session_id"` - TotalCostUSD float64 `json:"total_cost_usd"` - Usage resultUsage `json:"usage"` - // ModelUsage's map KEY is the model slug — the result frame - // has no top-level `"model"` field. Single-turn chats have - // exactly one entry; multi-model orchestration would have - // more, and we take whatever the map iteration hands us first - // (the renderer's footer keys off a single model anyway). - ModelUsage map[string]json.RawMessage `json:"modelUsage"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("claudecode: parse result frame: %w", err) - } - - var envs []proto.Envelope - - // Pick the first model the CLI reports under modelUsage. Map - // iteration order is fine for the 1-model common case; multi-model - // runs land on whichever wins the iteration. - model := "" - for k := range msg.ModelUsage { - model = k - break - } - - usage := proto.Usage{ - Provider: "claude_code", - Model: model, - InputTokens: msg.Usage.InputTokens, - OutputTokens: msg.Usage.OutputTokens, - CostUSD: msg.TotalCostUSD, - } - if msg.Usage.CacheCreationInputTokens != 0 || msg.Usage.CacheReadInputTokens != 0 { - usage.Raw = map[string]any{ - "cache_creation_input_tokens": msg.Usage.CacheCreationInputTokens, - "cache_read_input_tokens": msg.Usage.CacheReadInputTokens, - } - } - if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.CostUSD != 0 || usage.Raw != nil { - usageEnv, err := proto.NewEnvelope(proto.TypeUsage, t.runID, proto.UsagePayload{Usage: usage}) - if err != nil { - return translation{}, err - } - envs = append(envs, usageEnv) - } - - // Subtype "success" is the only success-shaped result; everything - // else (error_during_execution, error_max_turns, ...) is a failure. - isError := msg.IsError || (subtype != "" && subtype != "success" && strings.HasPrefix(subtype, "error")) - if isError { - errMsg := strings.TrimSpace(msg.Error) - // Claude Code sometimes reports provider/API failures with - // subtype="success" and is_error=true, placing the useful error in - // result instead of error. Preserve that message rather than emitting - // the misleading fallback "claude_code: success". - if errMsg == "" && msg.IsError { - errMsg = strings.TrimSpace(msg.Result) - } - if errMsg == "" { - var details []string - for _, detail := range msg.Errors { - if detail = strings.TrimSpace(detail); detail != "" { - details = append(details, detail) - } - } - errMsg = strings.Join(details, "\n") - } - if errMsg == "" { - if subtype != "" { - errMsg = "claude_code: " + subtype - } else { - errMsg = "claude_code: unspecified error" - } - } - errEnv, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: errMsg}) - if err != nil { - return translation{}, err - } - envs = append(envs, errEnv) - } - - var doneMeta map[string]any - if strings.TrimSpace(msg.SessionID) != "" { - doneMeta = map[string]any{ - proto.DoneMetaAgentSessionID: msg.SessionID, - proto.DoneMetaAgentSessionType: "claude_session", - } - } - doneEnv, err := proto.NewEnvelope(proto.TypeDone, t.runID, proto.DonePayload{ - Content: msg.Result, - Usage: usage, - Metadata: doneMeta, - }) - if err != nil { - return translation{}, err - } - envs = append(envs, doneEnv) - - return translation{Envelopes: envs, Terminal: true, SessionID: msg.SessionID}, nil -} diff --git a/apps/daemon/internal/agent/claudecode/parser_result_test.go b/apps/daemon/internal/agent/claudecode/parser_result_test.go deleted file mode 100644 index edf3591a9..000000000 --- a/apps/daemon/internal/agent/claudecode/parser_result_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package claudecode_test - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestTranslateResultErrorDetails(t *testing.T) { - for _, tt := range []struct { - name, fields, want string - }{ - {"missing session", `"errors":["No conversation found with session ID: missing-session"]`, "No conversation found with session ID: missing-session"}, - {"multiple details", `"errors":[" first error ","", " ","second error"]`, "first error\nsecond error"}, - {"empty details", `"errors":["", " "]`, "claude_code: error_during_execution"}, - {"legacy error", `"error":" legacy error ","errors":["array detail"]`, "legacy error"}, - {"legacy result", `"result":" legacy result ","errors":["array detail"]`, "legacy result"}, - } { - t.Run(tt.name, func(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_details", nil, counterMinter()) - line := []byte(`{"type":"result","subtype":"error_during_execution","is_error":true,` + tt.fields + `}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatal(err) - } - if !out.Terminal || len(out.Envelopes) != 2 || out.Envelopes[0].Type != proto.TypeError || out.Envelopes[1].Type != proto.TypeDone { - t.Fatalf("want terminal error then done, got %+v", out) - } - var detail proto.ErrorPayload - if err := json.Unmarshal(out.Envelopes[0].Payload, &detail); err != nil { - t.Fatal(err) - } - if detail.Error != tt.want { - t.Fatalf("error = %q, want %q", detail.Error, tt.want) - } - }) - } -} - -func TestTranslateResultSuccessEmitsUsageThenDone(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) - line := []byte(`{ - "type":"result","subtype":"success","is_error":false, - "result":"final answer text","session_id":"sess_abc", - "total_cost_usd":0.01234, - "usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":7}, - "modelUsage":{"claude-opus-4-7-thinking-medium":{"inputTokens":100,"outputTokens":50,"contextWindow":200000,"costUSD":0.01234}} - }`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if !out.Terminal { - t.Error("result must be terminal") - } - if out.SessionID != "sess_abc" { - t.Errorf("SessionID = %q, want sess_abc", out.SessionID) - } - if len(out.Envelopes) != 2 { - t.Fatalf("want 2 envs (usage, done), got %d %#v", len(out.Envelopes), out.Envelopes) - } - if out.Envelopes[0].Type != "usage" || out.Envelopes[1].Type != "done" { - t.Errorf("env order wrong, got %s,%s", out.Envelopes[0].Type, out.Envelopes[1].Type) - } - usage := mustDecode[struct { - Provider string `json:"provider"` - Model string `json:"model"` - InputTokens int32 `json:"input_tokens"` - OutputTokens int32 `json:"output_tokens"` - CostUSD float64 `json:"cost_usd"` - Raw map[string]any `json:"raw"` - }](t, out.Envelopes[0].Payload) - if usage.Provider != "claude_code" { - t.Errorf("usage.Provider = %q", usage.Provider) - } - // Model flows through from modelUsage's map key — no top-level - // "model" field in the result frame. - if usage.Model != "claude-opus-4-7-thinking-medium" { - t.Errorf("usage.Model = %q, want claude-opus-4-7-thinking-medium", usage.Model) - } - if usage.InputTokens != 100 || usage.OutputTokens != 50 { - t.Errorf("usage tokens: %+v", usage) - } - if usage.CostUSD != 0.01234 { - t.Errorf("usage cost = %v", usage.CostUSD) - } - if _, ok := usage.Raw["cache_read_input_tokens"]; !ok { - t.Errorf("usage.Raw missing cache stats: %v", usage.Raw) - } - done := mustDecode[struct { - Content string `json:"content"` - Metadata map[string]any `json:"metadata"` - }](t, out.Envelopes[1].Payload) - if done.Content != "final answer text" { - t.Errorf("done.Content = %q", done.Content) - } - if done.Metadata == nil { - t.Fatalf("done.Metadata missing") - } - if got, _ := done.Metadata[proto.DoneMetaAgentSessionID].(string); got != "sess_abc" { - t.Errorf("done.Metadata.agent_session_id = %q, want sess_abc", got) - } - if got, _ := done.Metadata[proto.DoneMetaAgentSessionType].(string); got != "claude_session" { - t.Errorf("done.Metadata.agent_session_type = %q", got) - } -} - -func TestTranslateResultSuccessNoUsageOmitsUsage(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) - line := []byte(`{"type":"result","subtype":"success","result":"hi"}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "done" { - t.Errorf("want only done when no usage, got %#v", out.Envelopes) - } -} - -func TestTranslateResultErrorSubtypeEmitsError(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) - line := []byte(`{"type":"result","subtype":"error_during_execution","is_error":true,"error":"boom"}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if !out.Terminal { - t.Error("result error must be terminal") - } - if len(out.Envelopes) != 2 { - t.Fatalf("want error+done, got %#v", out.Envelopes) - } - if out.Envelopes[0].Type != "error" || out.Envelopes[1].Type != "done" { - t.Errorf("env order: %s,%s", out.Envelopes[0].Type, out.Envelopes[1].Type) - } - got := mustDecode[struct { - Error string `json:"error"` - }](t, out.Envelopes[0].Payload) - if got.Error != "boom" { - t.Errorf("error text = %q", got.Error) - } -} - -func TestTranslateResultErrorWithoutMessageFallsBackToSubtype(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) - line := []byte(`{"type":"result","subtype":"error_max_turns","is_error":true}`) - out, _ := tr.Translate(line) - got := mustDecode[struct { - Error string `json:"error"` - }](t, out.Envelopes[0].Payload) - if !strings.Contains(got.Error, "error_max_turns") { - t.Errorf("error fallback should mention subtype, got %q", got.Error) - } -} - -func TestTranslateResultIsErrorSuccessSubtypeUsesResultMessage(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) - line := []byte(`{"type":"result","subtype":"success","is_error":true,"result":"API Error: 400 content rejected"}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - got := mustDecode[struct { - Error string `json:"error"` - }](t, out.Envelopes[0].Payload) - if got.Error != "API Error: 400 content rejected" { - t.Errorf("error text = %q", got.Error) - } -} diff --git a/apps/daemon/internal/agent/claudecode/parser_test.go b/apps/daemon/internal/agent/claudecode/parser_test.go deleted file mode 100644 index 1832ba994..000000000 --- a/apps/daemon/internal/agent/claudecode/parser_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package claudecode_test - -import ( - "encoding/json" - "fmt" - "sync" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// counterMinter emits perm_001, perm_002, ... for deterministic -// envelope IDs in tests. -func counterMinter() func() string { - var ( - mu sync.Mutex - n int - ) - return func() string { - mu.Lock() - defer mu.Unlock() - n++ - return fmt.Sprintf("perm_%03d", n) - } -} - -func mustDecode[T any](t *testing.T, raw []byte) T { - t.Helper() - var v T - if err := json.Unmarshal(raw, &v); err != nil { - t.Fatalf("decode %T: %v\nraw=%s", v, err, raw) - } - return v -} - -func TestTranslateSystemInitSurfacesSessionID(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_1", nil, counterMinter()) - line := []byte(`{"type":"system","subtype":"init","session_id":"sess_abc","tools":["Bash"]}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if out.SessionID != "sess_abc" { - t.Errorf("SessionID = %q, want sess_abc", out.SessionID) - } - if len(out.Envelopes) != 0 { - t.Errorf("system init must not emit envelopes, got %d", len(out.Envelopes)) - } - if out.Terminal { - t.Errorf("system init is not terminal") - } -} - -func TestTranslateAssistantTextEmitsDelta(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_42", nil, counterMinter()) - line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ - {"type":"text","text":"hello "}, - {"type":"text","text":"world"} - ]}}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 2 { - t.Fatalf("want 2 envelopes, got %d", len(out.Envelopes)) - } - for i, env := range out.Envelopes { - if env.Type != "delta" { - t.Errorf("env[%d].Type = %q, want delta", i, env.Type) - } - if env.ID != "run_42" { - t.Errorf("env[%d].ID = %q, want run_42", i, env.ID) - } - } - d1 := mustDecode[struct { - Delta string `json:"delta"` - Sequence uint64 `json:"sequence"` - }](t, out.Envelopes[0].Payload) - d2 := mustDecode[struct { - Delta string `json:"delta"` - Sequence uint64 `json:"sequence"` - }](t, out.Envelopes[1].Payload) - if d1.Delta != "hello " || d2.Delta != "world" { - t.Errorf("delta texts: %q,%q", d1.Delta, d2.Delta) - } - if d1.Sequence == 0 || d2.Sequence <= d1.Sequence { - t.Errorf("sequence not monotonic: %d,%d", d1.Sequence, d2.Sequence) - } -} - -func TestTranslateAssistantThinkingEmitsThinking(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_x", nil, counterMinter()) - line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ - {"type":"thinking","thinking":"let me think...","signature":"sig"} - ]}}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "thinking" { - t.Fatalf("want one thinking env, got %#v", out.Envelopes) - } - got := mustDecode[struct { - Text string `json:"text"` - }](t, out.Envelopes[0].Payload) - if got.Text != "let me think..." { - t.Errorf("Text = %q", got.Text) - } -} - -func TestTranslatePartialMessagesStreamIncrementallyWithoutAssistantDuplicates(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_partial", nil, counterMinter()) - frames := [][]byte{ - []byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello "}}}`), - []byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}}`), - []byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"thinking_delta","thinking":"checking"}}}`), - } - - var got []proto.Envelope - for _, frame := range frames { - out, err := tr.Translate(frame) - if err != nil { - t.Fatalf("Translate partial frame: %v", err) - } - got = append(got, out.Envelopes...) - } - if len(got) != 3 { - t.Fatalf("want 3 partial envelopes, got %d", len(got)) - } - if got[0].Type != proto.TypeDelta || got[1].Type != proto.TypeDelta || got[2].Type != proto.TypeThinking { - t.Fatalf("partial envelope types = %q, %q, %q", got[0].Type, got[1].Type, got[2].Type) - } - - full, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello world"},{"type":"thinking","thinking":"checking"}]}}`)) - if err != nil { - t.Fatalf("Translate complete assistant frame: %v", err) - } - if len(full.Envelopes) != 0 { - t.Fatalf("complete assistant frame duplicated partial content: %#v", full.Envelopes) - } - - next, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"next turn without partial frames"}]}}`)) - if err != nil { - t.Fatalf("Translate next complete assistant frame: %v", err) - } - if len(next.Envelopes) != 1 || next.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("next assistant frame was suppressed by stale partial state: %#v", next.Envelopes) - } -} - -func TestTranslateStreamEventIgnoresNonTextDeltas(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_partial", nil, counterMinter()) - out, err := tr.Translate([]byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 0 { - t.Fatalf("input JSON delta should not emit an envelope: %#v", out.Envelopes) - } -} - -func TestTranslatePartialMessagesSuppressOnlyMatchingContentBlock(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_partial_blocks", nil, counterMinter()) - _, err := tr.Translate([]byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"streamed"}}}`)) - if err != nil { - t.Fatalf("Translate partial frame: %v", err) - } - - out, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"streamed"},{"type":"text","text":"complete-only"}]}}`)) - if err != nil { - t.Fatalf("Translate complete frame: %v", err) - } - if len(out.Envelopes) != 1 || out.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("complete-only block should be preserved: %#v", out.Envelopes) - } - got := mustDecode[struct { - Delta string `json:"delta"` - }](t, out.Envelopes[0].Payload) - if got.Delta != "complete-only" { - t.Fatalf("delta = %q, want complete-only", got.Delta) - } -} - -func TestTranslateResultClearsPartialBlocksBeforeNextTurn(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_partial_error", nil, counterMinter()) - _, err := tr.Translate([]byte(`{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial before failure"}}}`)) - if err != nil { - t.Fatalf("Translate partial frame: %v", err) - } - - _, err = tr.Translate([]byte(`{"type":"result","subtype":"error_during_execution","is_error":true,"error":"provider failed"}`)) - if err != nil { - t.Fatalf("Translate error result: %v", err) - } - - next, err := tr.Translate([]byte(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"next turn"}]}}`)) - if err != nil { - t.Fatalf("Translate next assistant frame: %v", err) - } - if len(next.Envelopes) != 1 || next.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("next assistant frame was suppressed by result state: %#v", next.Envelopes) - } -} - -func TestTranslateAssistantToolUseEmitsBeforeStage(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_t", nil, counterMinter()) - line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ - {"type":"tool_use","id":"toolu_99","name":"Bash","input":{"command":"ls"}} - ]}}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 { - t.Fatalf("want 1 env, got %d", len(out.Envelopes)) - } - got := mustDecode[struct { - ID string `json:"id"` - Name string `json:"name"` - Stage string `json:"stage"` - Args map[string]any `json:"args"` - }](t, out.Envelopes[0].Payload) - if got.ID != "toolu_99" || got.Name != "Bash" || got.Stage != "before" { - t.Errorf("payload mismatch: %+v", got) - } - if got.Args["command"] != "ls" { - t.Errorf("args missing command: %v", got.Args) - } -} - -func TestTranslateAssistantMixedContentMonotonicSequence(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_seq", nil, counterMinter()) - line := []byte(`{"type":"assistant","message":{"role":"assistant","content":[ - {"type":"text","text":"a"}, - {"type":"thinking","thinking":"b"}, - {"type":"text","text":"c"}, - {"type":"tool_use","id":"t","name":"Read","input":{}} - ]}}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 4 { - t.Fatalf("want 4 envs, got %d", len(out.Envelopes)) - } - var seqs []uint64 - for _, e := range out.Envelopes { - if e.Type == "delta" || e.Type == "thinking" { - d := mustDecode[struct { - Sequence uint64 `json:"sequence"` - }](t, e.Payload) - seqs = append(seqs, d.Sequence) - } - } - for i := 1; i < len(seqs); i++ { - if seqs[i] <= seqs[i-1] { - t.Errorf("sequence not strictly increasing: %v", seqs) - } - } -} - -func TestTranslateUserToolResultEmitsAfterStage(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_r", nil, counterMinter()) - line := []byte(`{"type":"user","message":{"role":"user","content":[ - {"type":"tool_result","tool_use_id":"toolu_99","content":"hello\nworld\n","is_error":false} - ]}}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "tool_call" { - t.Fatalf("want one tool_call after env, got %#v", out.Envelopes) - } - got := mustDecode[struct { - ID string `json:"id"` - Stage string `json:"stage"` - Result map[string]any `json:"result"` - }](t, out.Envelopes[0].Payload) - if got.ID != "toolu_99" || got.Stage != "after" { - t.Errorf("got %+v", got) - } - if got.Result["content"] != "hello\nworld\n" { - t.Errorf("content not preserved, got %v", got.Result["content"]) - } - if got.Result["is_error"] != false { - t.Errorf("is_error not preserved: %v", got.Result["is_error"]) - } -} - -func TestTranslateUserToolResultPreservesStructuredContent(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_r", nil, counterMinter()) - line := []byte(`{"type":"user","message":{"role":"user","content":[ - {"type":"tool_result","tool_use_id":"t","content":[{"type":"text","text":"x"}],"is_error":true} - ]}}`) - out, _ := tr.Translate(line) - got := mustDecode[struct { - Result map[string]any `json:"result"` - }](t, out.Envelopes[0].Payload) - if _, ok := got.Result["content"].([]any); !ok { - t.Errorf("expected []any content block array, got %T %v", got.Result["content"], got.Result["content"]) - } -} - -func TestTranslateControlRequestMintsPermIDAndRecords(t *testing.T) { - pending := claudecode.NewPendingTableForTest() - tr := claudecode.NewTranslatorForTest("run_z", pending, counterMinter()) - line := []byte(`{"type":"control_request","request_id":"req_001","request":{ - "subtype":"can_use_tool","tool_name":"Bash","input":{"command":"rm -rf /tmp/a"} - }}`) - out, err := tr.Translate(line) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "permission_request" { - t.Fatalf("want one permission_request env, got %#v", out.Envelopes) - } - env := out.Envelopes[0] - if env.ID != "run_z" { - t.Errorf("env.ID = %q, want run_z", env.ID) - } - pr := mustDecode[struct { - RequestID string `json:"request_id"` - Tool string `json:"tool"` - Title string `json:"title"` - Payload map[string]any `json:"payload"` - }](t, env.Payload) - if pr.RequestID != "perm_001" || pr.Tool != "Bash" || pr.Title != "Bash" { - t.Errorf("permission payload mismatch: %+v", pr) - } - if pr.Payload["command"] != "rm -rf /tmp/a" { - t.Errorf("payload not preserved: %v", pr.Payload) - } - entry, ok := pending.Resolve("perm_001") - if !ok || entry.CCRequestID != "req_001" { - t.Errorf("pending table not recorded: %+v ok=%v", entry, ok) - } -} - -func TestTranslateControlCancelLooksUpPerm(t *testing.T) { - pending := claudecode.NewPendingTableForTest() - tr := claudecode.NewTranslatorForTest("run_z", pending, counterMinter()) - // Seed by translating the original control_request. - _, _ = tr.Translate([]byte(`{"type":"control_request","request_id":"req_5","request":{"subtype":"can_use_tool","tool_name":"Write","input":{}}}`)) - out, err := tr.Translate([]byte(`{"type":"control_cancel_request","request_id":"req_5"}`)) - if err != nil { - t.Fatalf("Translate cancel: %v", err) - } - if len(out.Envelopes) != 1 || out.Envelopes[0].Type != "permission_cancel" { - t.Fatalf("want one permission_cancel env, got %#v", out.Envelopes) - } - if out.Envelopes[0].ID != "perm_001" { - t.Errorf("cancel env.ID = %q, want perm_001", out.Envelopes[0].ID) - } -} - -func TestTranslateControlCancelUnknownCCIDDropped(t *testing.T) { - pending := claudecode.NewPendingTableForTest() - tr := claudecode.NewTranslatorForTest("run_z", pending, counterMinter()) - out, err := tr.Translate([]byte(`{"type":"control_cancel_request","request_id":"req_nope"}`)) - if err != nil { - t.Fatalf("Translate cancel: %v", err) - } - if len(out.Envelopes) != 0 { - t.Errorf("unknown cancel should be dropped, got %#v", out.Envelopes) - } -} - -func TestTranslateUnknownTypeIsNoOp(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) - out, err := tr.Translate([]byte(`{"type":"some_future_thing","foo":1}`)) - if err != nil { - t.Fatalf("unknown type should not error: %v", err) - } - if len(out.Envelopes) != 0 || out.Terminal { - t.Errorf("unknown type emitted envelopes/terminal: %#v term=%v", out.Envelopes, out.Terminal) - } -} - -func TestTranslateBlankLineIsNoOp(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) - for _, s := range []string{"", " ", "\n", "\t \n"} { - out, err := tr.Translate([]byte(s)) - if err != nil { - t.Errorf("blank line %q errored: %v", s, err) - } - if len(out.Envelopes) != 0 { - t.Errorf("blank line %q emitted envs", s) - } - } -} - -func TestTranslateMalformedJSONReturnsError(t *testing.T) { - tr := claudecode.NewTranslatorForTest("run_99", nil, counterMinter()) - _, err := tr.Translate([]byte(`{"type":"assistant", broken`)) - if err == nil { - t.Error("expected error on malformed JSON") - } -} diff --git a/apps/daemon/internal/agent/claudecode/permission.go b/apps/daemon/internal/agent/claudecode/permission.go deleted file mode 100644 index 7a3ab946d..000000000 --- a/apps/daemon/internal/agent/claudecode/permission.go +++ /dev/null @@ -1,100 +0,0 @@ -package claudecode - -import "sync" - -// pendingTable maps daemon-minted perm_<8hex> ids to the originating -// Claude Code control_request.request_id. Both directions are needed: -// - SubmitPermission from the gateway looks up cc_request_id (and -// original input) so we can write a valid control_response. -// - A control_cancel_request from claude stdout needs the reverse -// translation so the gateway sees a matching permission_cancel. -type pendingTable struct { - mu sync.Mutex - byPerm map[string]pendingEntry - byCCReq map[string]string -} - -type pendingEntry struct { - CCRequestID string - Input map[string]any -} - -func newPendingTable() *pendingTable { - return &pendingTable{ - byPerm: make(map[string]pendingEntry), - byCCReq: make(map[string]string), - } -} - -// Record links a freshly minted perm_id to the originating -// cc_request_id and the tool-call input the human is being asked to -// approve. -func (p *pendingTable) Record(permID, ccRequestID string, input map[string]any) { - if permID == "" || ccRequestID == "" { - return - } - p.mu.Lock() - defer p.mu.Unlock() - p.byPerm[permID] = pendingEntry{CCRequestID: ccRequestID, Input: input} - p.byCCReq[ccRequestID] = permID -} - -// Resolve returns the entry recorded for permID. ok is false when -// permID is unknown or already Delete-d. -func (p *pendingTable) Resolve(permID string) (pendingEntry, bool) { - p.mu.Lock() - defer p.mu.Unlock() - e, ok := p.byPerm[permID] - return e, ok -} - -// Take atomically returns and removes one permission. Human submissions and -// the timeout watchdog race through this method so only one control_response -// can reach Claude Code. -func (p *pendingTable) Take(permID string) (pendingEntry, bool) { - if permID == "" { - return pendingEntry{}, false - } - p.mu.Lock() - defer p.mu.Unlock() - e, ok := p.byPerm[permID] - if !ok { - return pendingEntry{}, false - } - delete(p.byPerm, permID) - delete(p.byCCReq, e.CCRequestID) - return e, true -} - -// LookupByCC reverses the mapping for control_cancel_request handling. -func (p *pendingTable) LookupByCC(ccRequestID string) (string, bool) { - if ccRequestID == "" { - return "", false - } - p.mu.Lock() - defer p.mu.Unlock() - permID, ok := p.byCCReq[ccRequestID] - return permID, ok -} - -// Delete removes both directions for permID. -func (p *pendingTable) Delete(permID string) { - if permID == "" { - return - } - p.mu.Lock() - defer p.mu.Unlock() - e, ok := p.byPerm[permID] - if !ok { - return - } - delete(p.byPerm, permID) - delete(p.byCCReq, e.CCRequestID) -} - -// Len reports the number of outstanding permissions. -func (p *pendingTable) Len() int { - p.mu.Lock() - defer p.mu.Unlock() - return len(p.byPerm) -} diff --git a/apps/daemon/internal/agent/claudecode/permission_test.go b/apps/daemon/internal/agent/claudecode/permission_test.go deleted file mode 100644 index 2f4954424..000000000 --- a/apps/daemon/internal/agent/claudecode/permission_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package claudecode_test - -import ( - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" -) - -func TestPendingTableRoundTrip(t *testing.T) { - tbl := claudecode.NewPendingTableForTest() - tbl.Record("perm_aabbccdd", "req_42", map[string]any{"command": "ls -la"}) - - e, ok := tbl.Resolve("perm_aabbccdd") - if !ok { - t.Fatal("Resolve missed a recorded perm id") - } - if e.CCRequestID != "req_42" { - t.Errorf("CCRequestID = %q, want req_42", e.CCRequestID) - } - if e.Input["command"] != "ls -la" { - t.Errorf("input not preserved, got %v", e.Input) - } - - permID, ok := tbl.LookupByCC("req_42") - if !ok || permID != "perm_aabbccdd" { - t.Errorf("LookupByCC returned (%q,%v), want (perm_aabbccdd,true)", permID, ok) - } -} - -func TestPendingTableDeleteIsBidirectional(t *testing.T) { - tbl := claudecode.NewPendingTableForTest() - tbl.Record("perm_1", "req_a", nil) - tbl.Record("perm_2", "req_b", nil) - - tbl.Delete("perm_1") - if _, ok := tbl.Resolve("perm_1"); ok { - t.Error("Resolve still returns deleted perm") - } - if _, ok := tbl.LookupByCC("req_a"); ok { - t.Error("LookupByCC still returns deleted cc id") - } - if tbl.Len() != 1 { - t.Errorf("Len = %d, want 1", tbl.Len()) - } - if _, ok := tbl.Resolve("perm_2"); !ok { - t.Error("untouched perm_2 disappeared") - } -} - -func TestPendingTableUnknownLookupsAreFalse(t *testing.T) { - tbl := claudecode.NewPendingTableForTest() - if _, ok := tbl.Resolve("perm_nope"); ok { - t.Error("Resolve returned ok for unknown perm") - } - if _, ok := tbl.LookupByCC("req_nope"); ok { - t.Error("LookupByCC returned ok for unknown cc") - } - tbl.Delete("perm_nope") -} - -func TestPendingTableIgnoresEmptyIDs(t *testing.T) { - tbl := claudecode.NewPendingTableForTest() - tbl.Record("", "req_a", nil) - tbl.Record("perm_a", "", nil) - if tbl.Len() != 0 { - t.Errorf("Len = %d after empty-id records, want 0", tbl.Len()) - } - if _, ok := tbl.LookupByCC(""); ok { - t.Error("LookupByCC ok for empty cc id") - } -} diff --git a/apps/daemon/internal/agent/claudecode/plugins_install.go b/apps/daemon/internal/agent/claudecode/plugins_install.go deleted file mode 100644 index 5fe3dbb2d..000000000 --- a/apps/daemon/internal/agent/claudecode/plugins_install.go +++ /dev/null @@ -1,153 +0,0 @@ -package claudecode - -import ( - "context" - "errors" - "fmt" - "io" - "log/slog" - "os" - "path/filepath" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" - "github.com/google/uuid" -) - -// installPlugins materialises every plugin under -// /.claude/plugins// and returns the local paths. Per -// plugin: -// -// 1. Skip when /.cache-key matches name+sha256 (recurring prompts -// avoid the network round-trip). -// 2. Fetch the download URL into a temp file under .tmp/, capping at -// maxPluginZipBytes. -// 3. Verify SHA-256 against the descriptor before touching the -// extraction target — mismatch demotes to warning. -// 4. Extract to /.claude/plugins//, stripping a single -// wrapping directory and ignoring __MACOSX/. -// 5. Stamp .cache-key with "@". -// -// Errors during 2-4 demote the plugin to a warning and continue. -// Returning a hard error means we couldn't even create the parent -// directory. -func installPlugins( - ctx context.Context, - logger *slog.Logger, - workDir string, - plugins []pluginDescriptor, -) (PluginInstallResult, error) { - if logger == nil { - logger = obslog.Bg() - } - if len(plugins) == 0 { - return PluginInstallResult{}, nil - } - if strings.TrimSpace(workDir) == "" { - return PluginInstallResult{}, errors.New("claudecode plugins: workDir is required") - } - - root := filepath.Join(workDir, ".claude", "plugins") - unlock, err := installroot.Lock(ctx, root) - if err != nil { - return PluginInstallResult{}, err - } - defer unlock() - - result := PluginInstallResult{} - for _, p := range plugins { - if err := p.validate(); err != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("skip plugin (invalid descriptor): %v", err)) - logger.Warn("claudecode plugins: invalid descriptor", "err", err.Error()) - continue - } - - dir := filepath.Join(root, p.Name) - cacheKey := filepath.Join(dir, ".cache-key") - expectedKey := p.cacheKey() - - if existing, err := os.ReadFile(cacheKey); err == nil && string(existing) == expectedKey { - logger.Info("claudecode plugins: cache hit", - "name", p.Name, "version", p.Version, "dir", dir) - result.PluginDirs = append(result.PluginDirs, dir) - continue - } - - perCtx, cancel := context.WithTimeout(ctx, pluginInstallTimeout) - err := installOnePlugin(perCtx, logger, root, dir, cacheKey, expectedKey, p) - cancel() - if err != nil { - result.Warnings = append(result.Warnings, - fmt.Sprintf("plugin %s@%s: %v", p.Name, p.Version, err)) - logger.Warn("claudecode plugins: install failed", - "name", p.Name, "version", p.Version, "err", err.Error()) - continue - } - result.PluginDirs = append(result.PluginDirs, dir) - logger.Info("claudecode plugins: installed", - "name", p.Name, "version", p.Version, "dir", dir) - } - return result, nil -} - -// installOnePlugin: download → verify → extract → stamp cache key. -// On error, best-effort cleanup of any partial extraction. -func installOnePlugin( - ctx context.Context, - logger *slog.Logger, - root, dir, cacheKey, expectedKey string, - p pluginDescriptor, -) error { - tmpDir := filepath.Join(root, ".tmp") - if err := os.MkdirAll(tmpDir, 0o755); err != nil { - return fmt.Errorf("mkdir tmp: %w", err) - } - - // Per-call uuid in the temp path so two concurrent installs of the - // same (name, version) don't truncate each other's bytes, and so - // nothing on disk between verifyPluginSHA256 and extract can be a - // different file than the one we just hashed (TOCTOU). - zipPath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s-%s.zip", p.Name, p.Version, uuid.NewString())) - defer func() { - _ = os.Remove(zipPath) - }() - - fd, err := fetchPluginZip(ctx, p.DownloadURL, zipPath) - if err != nil { - return err - } - defer fd.Close() - - // Verify and extract BOTH read through the same FD (not the path). - // Unix file semantics pin the inode, so a swap on disk between - // hashing and extraction cannot change the bytes we're using. - if err := verifyPluginSHA256FromFD(fd, p.SHA256); err != nil { - return err - } - if _, err := fd.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("seek: %w", err) - } - fi, err := fd.Stat() - if err != nil { - return fmt.Errorf("stat: %w", err) - } - - if err := os.RemoveAll(dir); err != nil { - return fmt.Errorf("rm old dir: %w", err) - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir target: %w", err) - } - if err := extractPluginZipFromFD(fd, fi.Size(), dir); err != nil { - _ = os.RemoveAll(dir) - return err - } - - if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil { - // Cache miss next time is recoverable — don't fail the install. - logger.Warn("claudecode plugins: write cache key failed", - "path", cacheKey, "err", err.Error()) - } - return nil -} diff --git a/apps/daemon/internal/agent/claudecode/session.go b/apps/daemon/internal/agent/claudecode/session.go deleted file mode 100644 index 145725764..000000000 --- a/apps/daemon/internal/agent/claudecode/session.go +++ /dev/null @@ -1,705 +0,0 @@ -package claudecode - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" -) - -// sessionConfig customises Factory for tests (alternative binary path, -// alternative logger, shorter SIGTERM→SIGKILL escalation). -type sessionConfig struct { - // claudeBinary defaults to binpath.ClaudeCode(): the bare name - // "claude" for a PATH lookup, or the OAC_RUNTIME_CLAUDE_BIN override. - claudeBinary string - - // extraArgs are appended after BuildArgs' output. Tests use this - // for the os/exec helper-process pattern. - extraArgs []string - - // killTimeout is how long Cancel waits for SIGTERM to drain - // before SIGKILL. 3s in production; tests pin it short. - killTimeout time.Duration - - // askTimeout bounds how long the daemon waits for the human to answer a - // permission or prompt_for_user_choice (AskUserQuestion). 10 minutes in - // production; tests pin it short so timeout paths are exercisable. - // Zero disables the timer — leftover scaffolding for very early - // adapter wiring; production always picks defaultAskTimeout. - askTimeout time.Duration - - logger *slog.Logger -} - -const defaultAskTimeout = 10 * time.Minute - -func defaultConfig() sessionConfig { - return sessionConfig{ - claudeBinary: binpath.ClaudeCode(), - killTimeout: 3 * time.Second, - askTimeout: defaultAskTimeout, - logger: obslog.Bg(), - } -} - -// Factory implements agent.Factory for agent_kind="claude_code". -// Register during daemon startup: -// -// Register the factory with an explicit capability descriptor using Registry.RegisterKind. -func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return newSession(ctx, req, out, defaultConfig()) -} - -// Session wraps a single `claude` CLI subprocess. -type Session struct { - runID string - cfg sessionConfig - - proc *clirunner.Process - stdin io.WriteCloser - stdinMu sync.Mutex - - pending *pendingTable - askPending *pendingAskTable - translator *translator - - out chan<- proto.Envelope - closeOutOnce sync.Once - outMu sync.RWMutex - outClosed bool - - // cancelCtx is a child of parent ctx so Session.Cancel can signal - // everyone without racing router shutdown. - cancelCtx context.Context - - cancelOnce sync.Once - - // interactionTimersMu guards permission and AskUserQuestion watchdogs. - // Timeout callbacks and human responses race through atomic pending - // tables, so only one response can reach Claude Code. - interactionTimersMu sync.Mutex - interactionTimers map[string]*time.Timer - - // latestSessionID is the most recent upstream session id seen on a - // system-init / result frame. The cancel-path done envelope reads - // it back so the server can RememberSession even when claude was - // killed mid-prompt (without it, the next user message starts a - // brand-new chat with no --resume). - latestSessionIDMu sync.Mutex - latestSessionID string - - buildCleanup func() -} - -var _ agent.Session = (*Session)(nil) - -// newSession is the internal constructor; cfg lets tests inject a fake -// claude binary and a short kill timeout. -func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { - if out == nil { - return nil, errors.New("claudecode: nil out channel") - } - if err := req.Input.Validate(); err != nil { - return nil, err - } - if cfg.logger == nil { - cfg.logger = obslog.Bg() - } - if cfg.claudeBinary == "" { - cfg.claudeBinary = binpath.ClaudeCode() - } - if cfg.killTimeout <= 0 { - cfg.killTimeout = 3 * time.Second - } - - cfg.logger.Info("claudecode: newSession start", - "run_id", req.RunID, "agent_kind", req.AgentKind, - "prompt_len", len(req.Input), "work_dir", req.WorkDir, - "has_agent_options", req.AgentOptions != nil, - "agent_session_id", req.AgentSessionID, - "claude_binary", cfg.claudeBinary) - - // Install plugins BEFORE BuildArgs so the resolved local paths - // can be folded into opts["plugin_dirs"]. installPlugins demotes - // individual plugins to warnings; a hard error (e.g. mkdir fail) - // aborts the session. - // - // sessionWorkDir is reused for cmd.Dir below so plugins land at - // /.claude/plugins/ and the claude subprocess sees - // them at cwd-relative paths. - sessionWorkDir, err := resolveSessionWorkDir(req.WorkDir, req.ConversationID) - if err != nil { - cfg.logger.Error("claudecode: resolveSessionWorkDir failed", - "run_id", req.RunID, "err", err.Error()) - return nil, fmt.Errorf("claudecode: resolve session workDir: %w", err) - } - if sessionWorkDir != req.WorkDir { - cfg.logger.Info("claudecode: req.WorkDir empty, using resolved session dir", - "run_id", req.RunID, "session_dir", sessionWorkDir) - } - - pluginOpts := req.AgentOptions - if rawPlugins, ok := pluginOpts["plugins"]; ok { - descriptors, decodeWarns := decodePluginDescriptors(rawPlugins) - for _, w := range decodeWarns { - cfg.logger.Warn("claudecode: plugin descriptor decode warning", - "run_id", req.RunID, "msg", w) - } - installRes, err := installPlugins(parent, cfg.logger, sessionWorkDir, descriptors) - if err != nil { - cfg.logger.Error("claudecode: installPlugins failed", - "run_id", req.RunID, "err", err.Error()) - return nil, fmt.Errorf("claudecode: install plugins: %w", err) - } - for _, w := range installRes.Warnings { - cfg.logger.Warn("claudecode: plugin install warning", - "run_id", req.RunID, "msg", w) - } - if len(installRes.PluginDirs) > 0 { - // Defensive copy so we never mutate the caller's map. - // Existing plugin_dirs (hand-configured override) wins; - // capability-resolved dirs append. - pluginOpts = cloneAgentOptions(req.AgentOptions) - pluginOpts["plugin_dirs"] = mergePluginDirs(pluginOpts["plugin_dirs"], installRes.PluginDirs) - } - cfg.logger.Info("claudecode: plugins installed", - "run_id", req.RunID, - "plugin_count", len(descriptors), - "dir_count", len(installRes.PluginDirs)) - } - - // Skills install to /.claude/skills//, which - // Claude Code auto-scans at startup. No CLI flag, no opts mutation. - if rawSkills, ok := pluginOpts["skills"]; ok { - descriptors, decodeWarns := decodeSkillDescriptors(rawSkills) - for _, w := range decodeWarns { - cfg.logger.Warn("claudecode: skill descriptor decode warning", - "run_id", req.RunID, "msg", w) - } - installRes, err := installSkills(parent, cfg.logger, sessionWorkDir, descriptors) - if err != nil { - cfg.logger.Error("claudecode: installSkills failed", - "run_id", req.RunID, "err", err.Error()) - return nil, fmt.Errorf("claudecode: install skills: %w", err) - } - for _, w := range installRes.Warnings { - cfg.logger.Warn("claudecode: skill install warning", - "run_id", req.RunID, "msg", w) - } - cfg.logger.Info("claudecode: skills installed", - "run_id", req.RunID, - "skill_count", len(descriptors), - "warn_count", len(installRes.Warnings)) - } - - buildRes, err := BuildArgs(pluginOpts, req.AgentSessionID) - if err != nil { - cfg.logger.Error("claudecode: BuildArgs failed", "run_id", req.RunID, "err", err) - return nil, fmt.Errorf("claudecode: build args: %w", err) - } - cfg.logger.Info("claudecode: BuildArgs ok", - "run_id", req.RunID, "arg_count", len(buildRes.Args), "env_count", len(buildRes.Env)) - - args := append([]string{}, buildRes.Args...) - args = append(args, cfg.extraArgs...) - - cfg.logger.Info("claudecode: starting subprocess", - "run_id", req.RunID, "binary", cfg.claudeBinary, - "arg_count", len(args), "dir", sessionWorkDir) - proc, err := clirunner.Start(clirunner.StartOptions{ - Parent: parent, - Binary: cfg.claudeBinary, - Args: args, - Dir: sessionWorkDir, - Env: append(os.Environ(), buildRes.Env...), - NeedStdin: true, - KillTimeout: cfg.killTimeout, - }) - if err != nil { - cfg.logger.Error("claudecode: cmd.Start failed", - "run_id", req.RunID, "binary", cfg.claudeBinary, "err", err) - buildRes.Cleanup() - return nil, fmt.Errorf("claudecode: start %q: %w", cfg.claudeBinary, err) - } - cfg.logger.Info("claudecode: subprocess started", - "run_id", req.RunID, "pid", proc.Cmd.Process.Pid) - - pending := newPendingTable() - askPending := newPendingAskTable() - s := &Session{ - runID: req.RunID, - cfg: cfg, - proc: proc, - stdin: proc.Stdin, - pending: pending, - askPending: askPending, - translator: newTranslator(req.RunID, pending, askPending, defaultPermIDMinter, defaultAskIDMinter), - out: out, - cancelCtx: proc.Context(), - interactionTimers: make(map[string]*time.Timer), - buildCleanup: buildRes.Cleanup, - } - - // Write the initial user message before launching pumps so the - // first stdout line corresponds to the prompt we just sent. Best - // effort: write failure → pump sees EOF and synthesises - // error+done. - if msg, err := buildOrderedUserMessages(req.Input); err == nil { - cfg.logger.Info("claudecode: writing initial user message to stdin", - "run_id", req.RunID, "msg_bytes", len(msg)) - if _, werr := s.writeStdin(msg); werr != nil { - cfg.logger.Warn("claudecode: write initial user message", - "run_id", req.RunID, "err", werr) - } else { - cfg.logger.Info("claudecode: initial user message written ok", "run_id", req.RunID) - } - } else { - cfg.logger.Warn("claudecode: build user message", - "run_id", req.RunID, "err", err) - } - - cfg.logger.Info("claudecode: launching stdout/stderr pumps", "run_id", req.RunID) - go s.pumpStderr(proc.Stderr) - go s.run(proc.Stdout) - - return s, nil -} - -// writeStdin appends to claude's stdin under a mutex (claude only -// promises NDJSON framing; concurrent writes from SubmitPermission and -// the initial user-message write must not tear). -func (s *Session) writeStdin(b []byte) (int, error) { - s.stdinMu.Lock() - defer s.stdinMu.Unlock() - return s.stdin.Write(b) -} - -// Cancel asks the subprocess to stop. SIGTERM, escalating to SIGKILL -// after cfg.killTimeout. Idempotent; the actual teardown (out chan -// close) happens asynchronously via the pump. -func (s *Session) Cancel(_ context.Context) error { - s.cancelOnce.Do(func() { - s.stopAllInteractionTimers() - s.proc.Cancel() - }) - return nil -} - -// stopAllInteractionTimers cancels every outstanding human-response -// watchdog. Called on session Cancel/terminal so timers cannot fire into a -// closed stdin. -func (s *Session) stopAllInteractionTimers() { - s.interactionTimersMu.Lock() - timers := s.interactionTimers - s.interactionTimers = make(map[string]*time.Timer) - s.interactionTimersMu.Unlock() - for _, t := range timers { - t.Stop() - } -} - -// SubmitPermission writes a control_response back to claude for the -// given perm_id. Returns agent.ErrUnknownPermission when permID isn't -// in the pending table. -func (s *Session) SubmitPermission(_ context.Context, permID string, decision proto.PermissionDecisionPayload) error { - entry, ok := s.pending.Take(permID) - if !ok { - return agent.ErrUnknownPermission - } - s.stopInteractionTimer(permID) - - var inner map[string]any - if decision.Approved { - updatedInput := decision.UpdatedInput - if updatedInput == nil { - updatedInput = entry.Input - } - inner = map[string]any{ - "behavior": "allow", - "updatedInput": updatedInput, - } - } else { - msg := decision.Message - if msg == "" { - msg = "denied by operator" - } - inner = map[string]any{ - "behavior": "deny", - "message": msg, - } - } - - body, err := json.Marshal(map[string]any{ - "type": "control_response", - "response": map[string]any{ - "subtype": "success", - "request_id": entry.CCRequestID, - "response": inner, - }, - }) - if err != nil { - return fmt.Errorf("claudecode: marshal control_response: %w", err) - } - body = append(body, '\n') - - if _, err := s.writeStdin(body); err != nil { - // Restore the entry so a transient stdin write failure remains - // retryable and still has a bounded lifetime. - s.pending.Record(permID, entry.CCRequestID, entry.Input) - s.startPermissionTimer(permID) - return fmt.Errorf("claudecode: write control_response: %w", err) - } - return nil -} - -// SubmitPromptForUserChoice writes a tool_result back into claude's -// stdin for the AskUserQuestion call the daemon intercepted. Returns -// agent.ErrUnknownAsk when askID isn't in the pending ask table — -// usually a race with Cancel or a duplicate decision from the server. -// -// The reply is shaped as a normal Claude Code tool_result message; the -// model resumes its turn as if the local SDK had executed the tool -// and supplied the human's answer. -// -// Cancelled answers (timeout, operator /cancel) still write back -// is_error=false text — see plan: returning is_error=true would push -// the agent into a "retry the same tool" loop, which is worse UX than -// telling it "the user stopped, fold and report". -func (s *Session) SubmitPromptForUserChoice(_ context.Context, askID string, decision proto.PromptForUserChoiceDecisionPayload) error { - // Take is atomic read+delete; the timer-fired cancel and a server- - // delivered answer can both reach here, but only one wins. The - // loser sees ok=false and returns ErrUnknownAsk — the router logs - // and moves on. - entry, ok, err := s.askPending.Take(askID, decision) - if err != nil { - return err - } - if !ok { - return agent.ErrUnknownAsk - } - - // Drop the timer so its callback (if pending) finds the entry gone - // and returns silently. Already-fired callbacks lost the Take race - // above; stop is best-effort either way. - s.stopAskTimer(askID) - - body, buildEr := buildAskUserControlResponse(entry, decision) - if buildEr != nil { - return fmt.Errorf("claudecode: marshal ask reply: %w", buildEr) - } - if _, err := s.writeStdin(body); err != nil { - // Entry is already gone (Take consumed it). A retry will see - // ErrUnknownAsk; the session is going to die anyway when stdin - // errors, so we don't try to restore the entry. - return fmt.Errorf("claudecode: write ask reply: %w", err) - } - return nil -} - -// startAskTimer launches a single-shot watchdog that times the human -// out after cfg.askTimeout. On fire, the watchdog submits a Cancelled -// decision against itself so the agent's tool_result lands the same -// way as if the operator had clicked "stop" — no special "timeout" -// branch in SubmitPromptForUserChoice. -func (s *Session) startAskTimer(askID string) { - if s.cfg.askTimeout <= 0 { - return - } - timer := time.AfterFunc(s.cfg.askTimeout, func() { - _ = s.SubmitPromptForUserChoice(context.Background(), askID, proto.PromptForUserChoiceDecisionPayload{ - Cancelled: true, - Reason: "timeout", - }) - }) - s.interactionTimersMu.Lock() - if prev, ok := s.interactionTimers[askID]; ok { - // Same askID seen twice: cancel the old timer so we don't fire - // two decisions. Shouldn't happen on a clean stream, but two - // stdout-pump dispatches with the same env.ID would otherwise - // race. - prev.Stop() - } - s.interactionTimers[askID] = timer - s.interactionTimersMu.Unlock() -} - -func (s *Session) stopAskTimer(askID string) { - s.stopInteractionTimer(askID) -} - -// startPermissionTimer applies the same bounded human-response window to -// tool approvals. Expiry is an explicit deny, never an implicit allow. -func (s *Session) startPermissionTimer(permID string) { - if s.cfg.askTimeout <= 0 || permID == "" { - return - } - timer := time.AfterFunc(s.cfg.askTimeout, func() { - _ = s.SubmitPermission(context.Background(), permID, proto.PermissionDecisionPayload{ - Approved: false, - Message: "permission request timed out", - }) - }) - s.interactionTimersMu.Lock() - if prev, ok := s.interactionTimers[permID]; ok { - prev.Stop() - } - s.interactionTimers[permID] = timer - s.interactionTimersMu.Unlock() -} - -func (s *Session) stopInteractionTimer(id string) { - s.interactionTimersMu.Lock() - timer, ok := s.interactionTimers[id] - if ok { - delete(s.interactionTimers, id) - } - s.interactionTimersMu.Unlock() - if ok { - timer.Stop() - } -} - -// run is the stdout pump. Owns the out channel close. -func (s *Session) run(stdout io.Reader) { - s.cfg.logger.Info("claudecode: run() stdout pump started", "run_id", s.runID) - defer s.buildCleanup() - defer s.closeOut() - - sc := bufio.NewScanner(stdout) - // Claude can emit very large tool_result lines in one frame; 16MB - // is far above any practical single-tool output. - sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) - - lineCount := 0 - terminal := false - for sc.Scan() { - line := sc.Bytes() - lineCount++ - s.cfg.logger.Info("claudecode: stdout line", - "run_id", s.runID, "line_num", lineCount, "len", len(line), - "head", string(line[:min(len(line), 200)])) - tx, err := s.translator.Translate(line) - if err != nil { - s.cfg.logger.Warn("claudecode: translate line", - "run_id", s.runID, "err", err, "len", len(line)) - continue - } - if id := strings.TrimSpace(tx.SessionID); id != "" { - s.latestSessionIDMu.Lock() - s.latestSessionID = id - s.latestSessionIDMu.Unlock() - } - s.cfg.logger.Info("claudecode: translated", - "run_id", s.runID, "line_num", lineCount, - "envelope_count", len(tx.Envelopes), "terminal", tx.Terminal) - for _, env := range tx.Envelopes { - select { - case s.out <- env: - s.cfg.logger.Info("claudecode: envelope sent to out", - "run_id", s.runID, "type", env.Type, "env_id", env.ID) - if env.Type == proto.TypePromptForUserChoice { - // Start the human-answer watchdog AFTER the envelope - // has been handed off — counting the 10-minute window - // from "router has it" not "we're about to try". A - // slow consumer that blocked us on the send shouldn't - // also burn timeout budget the human never saw. - // - // Late-answer race: if the router somehow delivers a - // decision before we finish startAskTimer, the Take - // inside SubmitPromptForUserChoice still wins - // exclusively; the timer just becomes a no-op when it - // fires. - // - // Ask id lives on the payload now (env.ID is the run - // id so server-side dispatch can fan to the run's - // subscriber); decode just enough to seed the timer. - var p proto.PromptForUserChoicePayload - if err := env.DecodePayload(&p); err == nil && p.AskID != "" { - s.startAskTimer(p.AskID) - } - } else if env.Type == proto.TypePermissionRequest { - var p proto.PermissionRequestPayload - requestID := "" - if err := env.DecodePayload(&p); err == nil { - requestID = strings.TrimSpace(p.RequestID) - } - if requestID == "" { - requestID = strings.TrimSpace(env.ID) - } - if requestID != "" { - s.startPermissionTimer(requestID) - } - } - case <-s.cancelCtx.Done(): - s.cfg.logger.Info("claudecode: cancelled during out send", "run_id", s.runID) - _ = s.proc.Wait() - return - } - } - if tx.Terminal { - terminal = true - s.stopAllInteractionTimers() - s.closeOut() - break - } - } - if err := sc.Err(); err != nil && - !errors.Is(err, io.EOF) && - !errors.Is(err, context.Canceled) { - s.cfg.logger.Warn("claudecode: scan stdout", - "run_id", s.runID, "err", err) - } - s.cfg.logger.Info("claudecode: stdout pump exiting", - "run_id", s.runID, "lines_read", lineCount, "terminal", terminal) - - waitErr := s.proc.Wait() - s.cfg.logger.Info("claudecode: subprocess exited", - "run_id", s.runID, "wait_err", waitErr, - "exit_code", s.proc.Cmd.ProcessState.ExitCode()) - - if !terminal { - s.synthesizeTerminal(waitErr) - } -} - -// pumpStderr drains and logs stderr so the subprocess doesn't block -// on a full pipe. -func (s *Session) pumpStderr(stderr io.Reader) { - s.cfg.logger.Info("claudecode: pumpStderr started", "run_id", s.runID) - sc := bufio.NewScanner(stderr) - sc.Buffer(make([]byte, 0, 16*1024), 1<<20) - lineCount := 0 - for sc.Scan() { - lineCount++ - s.cfg.logger.Warn("claude stderr", - "run_id", s.runID, "line", sc.Text()) - } - s.cfg.logger.Info("claudecode: pumpStderr done", "run_id", s.runID, "lines", lineCount) -} - -// synthesizeTerminal emits error+done when the subprocess exited -// without a result frame. -func (s *Session) synthesizeTerminal(waitErr error) { - msg := "claude_code: subprocess exited without result" - if waitErr != nil { - msg = fmt.Sprintf("claude_code: subprocess exited: %v", waitErr) - } - if s.cancelCtx.Err() != nil { - msg = "claude_code: cancelled" - } - if errEnv, err := proto.NewEnvelope(proto.TypeError, s.runID, proto.ErrorPayload{Error: msg}); err == nil { - s.trySend(errEnv) - } - if doneEnv, err := proto.NewEnvelope(proto.TypeDone, s.runID, proto.DonePayload{Metadata: s.doneMetaForCancel()}); err == nil { - s.trySend(doneEnv) - } -} - -// doneMetaForCancel returns the metadata map attached to the cancel-path Done envelope. -func (s *Session) doneMetaForCancel() map[string]any { - s.latestSessionIDMu.Lock() - id := s.latestSessionID - s.latestSessionIDMu.Unlock() - if id == "" { - return nil - } - return map[string]any{ - proto.DoneMetaAgentSessionID: id, - proto.DoneMetaAgentSessionType: "claude_session", - } -} - -func (s *Session) trySend(env proto.Envelope) { - s.outMu.RLock() - defer s.outMu.RUnlock() - if s.outClosed { - return - } - select { - case s.out <- env: - case <-time.After(2 * time.Second): - s.cfg.logger.Warn("claudecode: terminal send timed out", - "type", env.Type, "run_id", s.runID) - } -} - -func (s *Session) closeOut() { - s.closeOutOnce.Do(func() { - s.outMu.Lock() - s.outClosed = true - close(s.out) - s.outMu.Unlock() - }) -} - -// resolveSessionWorkDir returns the directory that BOTH plugin installs -// AND the claude_code subprocess cwd share for this run. Keeping them -// on the same tree prevents the bug where the subprocess ran in one -// place (sandbox image WORKDIR) while plugins sat under ~/.oac/ -// — `--plugin-dir` still worked but the agent's own `ls .claude/ -// plugins/` self-check answered "no plugins here". -// -// Resolution order: -// -// 1. req.WorkDir wins. Local mode where the operator pinned a project -// root. Must be absolute or start with ~/ (we reject relative paths -// instead of resolving them against daemon cwd, since the daemon's cwd is -// not a meaningful anchor for user-facing config) and we mkdir -p -// so the user can name a path that doesn't exist yet. -// 2. conversationID present → per-conversation scratch dir under -// daemon HOME (~/.oac/runtime/claudecode/conv-). -// Consecutive turns reuse the same .cache-key files. Sandbox-mode -// default, also the local fallback when work_dir is unbound. -// 3. Both empty → daemon's own cwd (os.Getwd). Backstop matching -// pre-plugin behavior. -// -// Errors propagate so the eventual "could not extract zip" gets a -// clearer message. -func resolveSessionWorkDir(workDir, conversationID string) (string, error) { - if trimmed := strings.TrimSpace(workDir); trimmed != "" { - if strings.HasPrefix(trimmed, "~/") { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home dir: %w", err) - } - trimmed = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) - } else if !filepath.IsAbs(trimmed) { - return "", fmt.Errorf("work_dir must be an absolute path, got %q", trimmed) - } - if err := os.MkdirAll(trimmed, 0o755); err != nil { - return "", fmt.Errorf("mkdir %s: %w", trimmed, err) - } - return trimmed, nil - } - if convID := strings.TrimSpace(conversationID); convID != "" { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("os.UserHomeDir: %w", err) - } - dir := filepath.Join(home, ".oac", "runtime", "claudecode", "conv-"+convID) - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", fmt.Errorf("mkdir %s: %w", dir, err) - } - return dir, nil - } - cwd, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("os.Getwd: %w", err) - } - return cwd, nil -} diff --git a/apps/daemon/internal/agent/claudecode/session_export_test.go b/apps/daemon/internal/agent/claudecode/session_export_test.go deleted file mode 100644 index 6876188f0..000000000 --- a/apps/daemon/internal/agent/claudecode/session_export_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package claudecode - -// Parallel to export_test.go — exposes the subprocess session -// constructor + config knobs for session_test.go. - -import ( - "context" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type SessionConfigForTest struct { - ClaudeBinary string - ExtraArgs []string - KillTimeout time.Duration - AskTimeout time.Duration -} - -func NewSessionForTest(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg SessionConfigForTest) (*Session, error) { - return newSession(ctx, req, out, sessionConfig{ - claudeBinary: cfg.ClaudeBinary, - extraArgs: cfg.ExtraArgs, - killTimeout: cfg.KillTimeout, - askTimeout: cfg.AskTimeout, - }) -} - -// SubmitPromptForUserChoiceForTest exposes the ask-decision writer so -// session_test can drive the answer-resume path without a separate -// dispatch hop. -func (s *Session) SubmitPromptForUserChoiceForTest(askID string, decision proto.PromptForUserChoiceDecisionPayload) error { - return s.SubmitPromptForUserChoice(context.Background(), askID, decision) -} - -func (s *Session) ProcessDoneForTest() <-chan struct{} { - return s.proc.Done() -} diff --git a/apps/daemon/internal/agent/claudecode/session_knowledge_test.go b/apps/daemon/internal/agent/claudecode/session_knowledge_test.go deleted file mode 100644 index 3ca8200d4..000000000 --- a/apps/daemon/internal/agent/claudecode/session_knowledge_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package claudecode - -import ( - "bytes" - "log/slog" - "path/filepath" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestStartupLogsDoNotContainReferenceDocuments(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - var output bytes.Buffer - const privateDocument = "PRIVATE-REFERENCE-9481" - _, err := newSession(t.Context(), proto.PromptRequestPayload{ - RunID: "knowledge-log-check", WorkDir: t.TempDir(), Input: proto.TextInput("Answer from the reference."), - AgentOptions: map[string]any{"system_prompt": privateDocument}, - }, make(chan proto.Envelope, 8), sessionConfig{ - claudeBinary: filepath.Join(t.TempDir(), "missing-claude"), - logger: slog.New(slog.NewTextHandler(&output, nil)), - }) - if err == nil || !strings.Contains(output.String(), "starting subprocess") { - t.Fatalf("did not exercise subprocess startup: %v", err) - } - if strings.Contains(output.String(), privateDocument) { - t.Fatal("reference documents leaked into startup logs") - } -} diff --git a/apps/daemon/internal/agent/claudecode/session_test.go b/apps/daemon/internal/agent/claudecode/session_test.go deleted file mode 100644 index 266bcfc5d..000000000 --- a/apps/daemon/internal/agent/claudecode/session_test.go +++ /dev/null @@ -1,622 +0,0 @@ -package claudecode_test - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "os" - "slices" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// TestMain re-execs the test binary as a fake `claude` when -// CLAUDECODE_TESTHELPER_ROLE is set, bypassing m.Run so the test -// framework's PASS line never pollutes the fake stdout. -const helperEnvKey = "CLAUDECODE_TESTHELPER_ROLE" - -func TestMain(m *testing.M) { - if role := os.Getenv(helperEnvKey); role != "" { - runFakeClaude(role) - os.Exit(0) - } - os.Exit(m.Run()) -} - -// runFakeClaude pretends to be the `claude` CLI in stream-json mode. -func runFakeClaude(role string) { - enc := json.NewEncoder(os.Stdout) - enc.SetEscapeHTML(false) - stdin := bufio.NewScanner(os.Stdin) - stdin.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) - - // Wait for the daemon's initial user message so stream-json frame - // ordering is deterministic. - _ = stdin.Scan() - - switch role { - case "echo-success": - _ = enc.Encode(map[string]any{ - "type": "system", "subtype": "init", - "session_id": "sess_echo", - }) - _ = enc.Encode(map[string]any{ - "type": "assistant", - "message": map[string]any{ - "role": "assistant", - "content": []map[string]any{ - {"type": "text", "text": "hi there"}, - }, - }, - }) - _ = enc.Encode(map[string]any{ - "type": "result", "subtype": "success", - "result": "hi there", - "session_id": "sess_echo", - "usage": map[string]int{"input_tokens": 5, "output_tokens": 2}, - }) - - case "terminal-wait": - _ = enc.Encode(map[string]any{ - "type": "system", "subtype": "init", - "session_id": "sess_terminal_wait", - }) - _ = enc.Encode(map[string]any{ - "type": "result", "subtype": "success", - "result": "background work started", - "session_id": "sess_terminal_wait", - }) - for stdin.Scan() { - } - - case "echo-error": - _ = enc.Encode(map[string]any{ - "type": "result", "subtype": "error_during_execution", - "is_error": true, "error": "boom from fake", - }) - - case "permission": - _ = enc.Encode(map[string]any{ - "type": "control_request", "request_id": "req_cc_42", - "request": map[string]any{ - "subtype": "can_use_tool", "tool_name": "Bash", - "input": map[string]any{"command": "ls"}, - }, - }) - // Wait for the daemon's control_response. - approved := false - ccID := "" - if stdin.Scan() { - var decision struct { - Type string `json:"type"` - Response struct { - RequestID string `json:"request_id"` - Response struct { - Behavior string `json:"behavior"` - } `json:"response"` - } `json:"response"` - } - if err := json.Unmarshal(stdin.Bytes(), &decision); err == nil { - approved = decision.Response.Response.Behavior == "allow" - ccID = decision.Response.RequestID - } - } - text := "denied for " + ccID - if approved { - text = "allowed for " + ccID - } - _ = enc.Encode(map[string]any{ - "type": "result", "subtype": "success", - "result": text, - }) - - case "hang": - _ = enc.Encode(map[string]any{ - "type": "system", "subtype": "init", - "session_id": "sess_hang", - }) - // Long sleep keeps a runtime timer alive so Go's deadlock - // detector doesn't panic to stderr. SIGTERM still kills it. - time.Sleep(10 * time.Minute) - - case "ask-question": - // Stream an AskUserQuestion as a control_request (the path - // claude-code takes under --permission-prompt-tool stdio). - // Block on stdin waiting for the daemon's control_response - // carrying the human's answer; echo it back via the final - // result frame so the test can assert the round-trip text. - _ = enc.Encode(map[string]any{ - "type": "system", "subtype": "init", - "session_id": "sess_ask", - }) - _ = enc.Encode(map[string]any{ - "type": "control_request", - "request_id": "cc_req_ask_1", - "request": map[string]any{ - "subtype": "can_use_tool", - "tool_name": "AskUserQuestion", - "input": map[string]any{ - "questions": []map[string]any{{ - "header": "Confirm delete", - "question": "Delete /tmp directory?", - "multiSelect": false, - "options": []map[string]any{ - {"label": "Confirm delete", "description": "Run rm -rf"}, - {"label": "Cancel", "description": "Do not run"}, - }, - }}, - }, - }, - }) - - // Wait for the daemon's control_response. Body shape: - // {type:"control_response", response:{subtype:"success", - // request_id:"...", response:{behavior:"deny", message:"..."}}} - answerText := "" - if stdin.Scan() { - var cr struct { - Type string `json:"type"` - Response struct { - Subtype string `json:"subtype"` - Response struct { - Behavior string `json:"behavior"` - Message string `json:"message"` - } `json:"response"` - } `json:"response"` - } - if err := json.Unmarshal(stdin.Bytes(), &cr); err == nil { - answerText = cr.Response.Response.Message - } - } - _ = enc.Encode(map[string]any{ - "type": "result", "subtype": "success", - "result": "echoed:" + answerText, - "session_id": "sess_ask", - }) - } -} - -func helperConfig() claudecode.SessionConfigForTest { - return claudecode.SessionConfigForTest{ - ClaudeBinary: os.Args[0], - // belt-and-braces: -test.run=^$ stops any tests from running - // if TestMain forgets to short-circuit. - ExtraArgs: []string{"-test.run=^$"}, - KillTimeout: 200 * time.Millisecond, - } -} - -// helperReq points the helper at a specific role via env passthrough. -func helperReq(runID, prompt, role string) proto.PromptRequestPayload { - return proto.PromptRequestPayload{ - RunID: runID, - Input: proto.TextInput(prompt), - AgentOptions: map[string]any{ - "env": map[string]any{ - helperEnvKey: role, - }, - }, - } -} - -// drain reads envelopes until out closes or dl fires. Second return -// is true on a clean close, false on timeout. -func drain(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { - t.Helper() - deadline := time.After(dl) - var got []proto.Envelope - for { - select { - case env, ok := <-out: - if !ok { - return got, true - } - got = append(got, env) - case <-deadline: - return got, false - } - } -} - -func TestSessionEndToEndSuccess(t *testing.T) { - out := make(chan proto.Envelope, 32) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_s", "hello", "echo-success"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - if len(got) == 0 { - t.Fatal("got no envelopes") - } - if got[len(got)-1].Type != "done" { - t.Errorf("last env type = %q, want done", got[len(got)-1].Type) - } - - types := envTypes(got) - mustContain(t, types, "delta") - mustContain(t, types, "usage") - mustContain(t, types, "done") - for _, e := range got { - if e.ID != "run_s" { - t.Errorf("env type=%s ID=%q, want run_s", e.Type, e.ID) - } - } -} - -func TestTerminalResultKeepsProcessAliveUntilCancel(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_terminal_wait", "start background work", "terminal-wait"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - - got, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envelopes", len(got)) - } - mustContain(t, envTypes(got), proto.TypeDone) - - select { - case <-sess.ProcessDoneForTest(): - t.Fatal("terminal result killed the CLI process before the idle timeout") - case <-time.After(50 * time.Millisecond): - } - - if err := sess.Cancel(context.Background()); err != nil { - t.Fatalf("Cancel: %v", err) - } - select { - case <-sess.ProcessDoneForTest(): - case <-time.After(2 * time.Second): - t.Fatal("CLI process did not exit after explicit cancel") - } -} - -func TestSessionEndToEndError(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_e", "hello", "echo-error"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := envTypes(got) - mustContain(t, types, "error") - mustContain(t, types, "done") - if got[len(got)-1].Type != "done" { - t.Errorf("last env not done: %s", got[len(got)-1].Type) - } -} - -func TestSessionCancelClosesOut(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_c", "hello", "hang"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - - // Give the helper a moment to emit the system init line. - time.Sleep(150 * time.Millisecond) - if err := sess.Cancel(context.Background()); err != nil { - t.Errorf("Cancel: %v", err) - } - - got, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) - } - types := envTypes(got) - // Synthesised cancel terminal: error + done. - mustContain(t, types, "done") -} - -func TestSessionCancelIsIdempotent(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_c2", "hello", "hang"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - _ = sess.Cancel(context.Background()) - _ = sess.Cancel(context.Background()) - _ = sess.Cancel(context.Background()) - _, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatal("out did not close after redundant Cancels") - } -} - -func TestSessionSubmitPermissionUnknownReturnsErrUnknown(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_p0", "hello", "hang"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - err = sess.SubmitPermission(context.Background(), "perm_neverseen", - proto.PermissionDecisionPayload{Approved: true}) - if !errors.Is(err, agent.ErrUnknownPermission) { - t.Errorf("SubmitPermission for unknown id err = %v, want ErrUnknownPermission", err) - } -} - -func TestSessionPermissionRoundTrip(t *testing.T) { - out := make(chan proto.Envelope, 32) - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_p", "approve me", "permission"), out, helperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - // Drain until we see the permission_request, then approve it. - permID := "" - deadline := time.After(5 * time.Second) - var collected []proto.Envelope - for permID == "" { - select { - case env, ok := <-out: - if !ok { - t.Fatalf("out closed before permission_request; collected %d", len(collected)) - } - collected = append(collected, env) - if env.Type == "permission_request" { - if env.ID != "run_p" { - t.Fatalf("permission env.ID = %q, want run_p", env.ID) - } - var request proto.PermissionRequestPayload - if err := env.DecodePayload(&request); err != nil { - t.Fatalf("decode permission request: %v", err) - } - permID = request.RequestID - } - case <-deadline: - t.Fatalf("timeout waiting for permission_request; collected %d", len(collected)) - } - } - if !strings.HasPrefix(permID, "perm_") { - t.Errorf("perm id wrong shape: %q", permID) - } - - if err := sess.SubmitPermission(context.Background(), permID, - proto.PermissionDecisionPayload{Approved: true}); err != nil { - t.Fatalf("SubmitPermission: %v", err) - } - - // Drain the rest. Expect to land at done with "allowed" content. - rest, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatal("out did not close after approval") - } - all := append(collected, rest...) - final := all[len(all)-1] - if final.Type != "done" { - t.Errorf("final env type = %q, want done", final.Type) - } - var done struct { - Content string `json:"content"` - } - if err := json.Unmarshal(final.Payload, &done); err != nil { - t.Fatalf("decode done: %v", err) - } - if !strings.HasPrefix(done.Content, "allowed for ") { - t.Errorf("done.content = %q, want 'allowed for ...'", done.Content) - } - - // After resolution the perm id should no longer be known. - err = sess.SubmitPermission(context.Background(), permID, - proto.PermissionDecisionPayload{Approved: true}) - if !errors.Is(err, agent.ErrUnknownPermission) { - t.Errorf("second SubmitPermission err = %v, want ErrUnknownPermission", err) - } -} - -func TestSessionPermissionTimeoutDeniesInsteadOfHanging(t *testing.T) { - out := make(chan proto.Envelope, 32) - cfg := helperConfig() - cfg.AskTimeout = 150 * time.Millisecond - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_permission_timeout", "approve me", "permission"), out, cfg) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - envs, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatal("out did not close after permission timeout") - } - final := envs[len(envs)-1] - if final.Type != proto.TypeDone { - t.Fatalf("final env type = %q, want done; types=%v", final.Type, envTypes(envs)) - } - var done proto.DonePayload - if err := final.DecodePayload(&done); err != nil { - t.Fatalf("decode done: %v", err) - } - if !strings.Contains(done.Content, "denied for req_cc_42") { - t.Fatalf("timeout did not deny the request: %q", done.Content) - } -} - -func TestSessionRejectsEmptyPrompt(t *testing.T) { - // Pure-image inbound (empty Prompt, non-empty Attachments) is a - // valid prompt today and must NOT be rejected. - out := make(chan proto.Envelope, 4) - _, err := claudecode.NewSessionForTest(context.Background(), - proto.PromptRequestPayload{RunID: "r0", Input: proto.TextInput("")}, - out, helperConfig()) - if err == nil { - t.Fatal("expected error on empty prompt + no attachments") - } -} - -func TestSessionRejectsNilOut(t *testing.T) { - _, err := claudecode.NewSessionForTest(context.Background(), - helperReq("r0", "hi", "echo-success"), - nil, helperConfig()) - if err == nil { - t.Fatal("expected error on nil out") - } -} - -func TestSessionBadBinaryFailsToStart(t *testing.T) { - out := make(chan proto.Envelope, 4) - cfg := helperConfig() - cfg.ClaudeBinary = "/nonexistent/binary/that/does/not/resolve" - cfg.ExtraArgs = nil - _, err := claudecode.NewSessionForTest(context.Background(), - helperReq("r0", "hi", "echo-success"), out, cfg) - if err == nil { - t.Fatal("expected start error for bogus binary") - } -} - -func envTypes(envs []proto.Envelope) []string { - out := make([]string, len(envs)) - for i, e := range envs { - out[i] = e.Type - } - return out -} - -func mustContain(t *testing.T, haystack []string, needle string) { - t.Helper() - if !slices.Contains(haystack, needle) { - t.Errorf("expected %q in %v", needle, haystack) - } -} - -// TestSessionAskUserQuestionRoundTrip drives the full intercept → -// answer → tool_result loop through a real subprocess, so the test -// also catches stdin write / NDJSON-framing regressions the unit -// tests can't see. -func TestSessionAskUserQuestionRoundTrip(t *testing.T) { - out := make(chan proto.Envelope, 32) - cfg := helperConfig() - cfg.AskTimeout = 30 * time.Second - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_a", "ask me", "ask-question"), out, cfg) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - askID := "" - deadline := time.After(5 * time.Second) - var collected []proto.Envelope - for askID == "" { - select { - case env, ok := <-out: - if !ok { - t.Fatalf("out closed before prompt_for_user_choice; collected %d", len(collected)) - } - collected = append(collected, env) - if env.Type == proto.TypePromptForUserChoice { - // env.ID is the run id; the ask id rides on the payload. - var p proto.PromptForUserChoicePayload - if err := env.DecodePayload(&p); err != nil { - t.Fatalf("decode prompt_for_user_choice payload: %v", err) - } - askID = p.AskID - } - case <-deadline: - t.Fatalf("timeout waiting for prompt_for_user_choice; collected %d", len(collected)) - } - } - if !strings.HasPrefix(askID, "ask_") { - t.Errorf("ask id wrong shape: %q", askID) - } - - if err := sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{ - QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "foreign", Answers: []string{"wrong"}}}, - }); err == nil { - t.Fatal("accepted a foreign question ID") - } - if err := sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{ - QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"Confirm delete"}}}, - }); err != nil { - t.Fatalf("SubmitPromptForUserChoice: %v", err) - } - - rest, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatal("out did not close after ask answer") - } - all := append(collected, rest...) - final := all[len(all)-1] - if final.Type != "done" { - t.Fatalf("final env type = %q, want done; types=%v", final.Type, envTypes(all)) - } - var done struct { - Content string `json:"content"` - } - if err := json.Unmarshal(final.Payload, &done); err != nil { - t.Fatalf("decode done: %v", err) - } - if !strings.Contains(done.Content, "Confirm delete") { - t.Errorf("answer not echoed back through the fake claude loop: %q", done.Content) - } - - // Second submit must look unknown. - err = sess.SubmitPromptForUserChoiceForTest(askID, proto.PromptForUserChoiceDecisionPayload{QuestionAnswers: []proto.PromptForUserChoiceQuestionAnswer{{QuestionID: "q0", Answers: []string{"x"}}}}) - if !errors.Is(err, agent.ErrUnknownAsk) { - t.Errorf("second SubmitPromptForUserChoice err = %v, want ErrUnknownAsk", err) - } -} - -// TestSessionAskUserQuestionTimeoutSubmitsCancelled exercises the -// daemon-side timer. AskTimeout is set short so the watchdog fires -// before the human responds; the test then asserts the fake claude -// resumed with the canned "timeout" message (i.e. the timer wrote a -// successful tool_result, not an error). -func TestSessionAskUserQuestionTimeoutSubmitsCancelled(t *testing.T) { - out := make(chan proto.Envelope, 32) - cfg := helperConfig() - cfg.AskTimeout = 150 * time.Millisecond - sess, err := claudecode.NewSessionForTest(context.Background(), - helperReq("run_to", "ask me", "ask-question"), out, cfg) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - envs, closed := drain(t, out, 5*time.Second) - if !closed { - t.Fatal("out did not close after timer fired") - } - final := envs[len(envs)-1] - if final.Type != "done" { - t.Fatalf("final env type = %q, want done; types=%v", final.Type, envTypes(envs)) - } - var done struct { - Content string `json:"content"` - } - if err := json.Unmarshal(final.Payload, &done); err != nil { - t.Fatalf("decode done: %v", err) - } - if !strings.Contains(done.Content, "10 minutes") { - t.Errorf("timeout sentence not echoed: %q", done.Content) - } -} diff --git a/apps/daemon/internal/agent/claudecode/version.go b/apps/daemon/internal/agent/claudecode/version.go deleted file mode 100644 index 751a7de1b..000000000 --- a/apps/daemon/internal/agent/claudecode/version.go +++ /dev/null @@ -1,32 +0,0 @@ -package claudecode - -import ( - "context" - "errors" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe" -) - -// InstallURL points to the official Claude Code install instructions. -// Surfaced by `oac-daemon connect` when the CLI is missing so the user -// has a clear next step instead of an opaque "exec: no such file". -const InstallURL = "https://docs.anthropic.com/claude/docs/claude-code" - -// ErrCLINotFound is returned by CheckCLIAvailable when the binary -// cannot be located on PATH. Callers use errors.Is to distinguish -// "install Claude Code" from "Claude Code is broken". -var ErrCLINotFound = errors.New("claude CLI not found") - -// CheckCLIAvailable runs ` --version` and returns the trimmed -// first line. Empty binary defaults to binpath.ClaudeCode() — the same -// resolver the session spawn uses, so probe and spawn always agree. On -// missing binary the error wraps ErrCLINotFound; on other failures the -// wrapped error keeps the raw stderr. -func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { - return versionprobe.Check(ctx, binary, versionprobe.Config{ - Name: "claude", - DefaultBinary: binpath.ClaudeCode(), - MissingError: ErrCLINotFound, - }) -} diff --git a/apps/daemon/internal/agent/claudecode/version_test.go b/apps/daemon/internal/agent/claudecode/version_test.go deleted file mode 100644 index 3c4704075..000000000 --- a/apps/daemon/internal/agent/claudecode/version_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package claudecode_test - -import ( - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe/testutil" -) - -func TestCheckCLIAvailableContract(t *testing.T) { - testutil.RunContract(t, testutil.Contract{ - Name: "claude", - DefaultBinary: "claude", - MissingError: claudecode.ErrCLINotFound, - Check: claudecode.CheckCLIAvailable, - }) -} diff --git a/apps/daemon/internal/agent/claudesdk/declaration.go b/apps/daemon/internal/agent/claudesdk/declaration.go new file mode 100644 index 000000000..e99650049 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/declaration.go @@ -0,0 +1,154 @@ +package claudesdk + +import configuration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +const claudeSDKEntrypointEnv = "OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT" +const claudeSDKNodeEnv = "OAC_RUNTIME_CLAUDE_SDK_NODE" + +// Declaration owns Claude SDK discovery, configuration and execution factories. +var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{Kind: "claude_sdk", Capabilities: proto.AgentKindCapabilities{ + SubagentObservations: proto.CapabilityUnsupported, + Streaming: proto.CapabilitySupported, + Permissions: proto.CapabilityUnsupported, + Usage: proto.CapabilitySupported, + Resume: proto.CapabilitySupported, + NativeSessionRecovery: proto.CapabilityUnsupported, + WorkspaceAuthoring: proto.CapabilityUnsupported, + Steering: proto.CapabilitySupported, + MessageItems: proto.CapabilitySupported, + ToolObservations: proto.CapabilitySupported, + EnvironmentNone: proto.CapabilitySupported, + LocalEnvironment: proto.CapabilityUnsupported, + Preparation: proto.CapabilityUnsupported, + WorkspaceReadPreparation: proto.CapabilityUnsupported, + WorkspaceOutputExport: proto.CapabilityUnsupported, + ProgrammaticToolCallingDisable: proto.CapabilitySupported, + WebSearchControl: proto.CapabilityUnsupported, + ExecutionControls: proto.CapabilitySupported, + TextVerbosity: proto.CapabilityUnsupported, + StructuredOutput: proto.CapabilityUnsupported, + ToolSearch: proto.CapabilityUnsupported, + MessageImages: proto.CapabilityUnsupported, + FunctionResultImages: proto.CapabilityUnsupported, + SubagentControl: proto.CapabilitySupported, + DurableInputReceipts: proto.CapabilitySupported, + DurableTurns: proto.CapabilitySupported, + FunctionTools: proto.CapabilitySupported, + MCPHTTPTools: proto.CapabilityUnsupported, + MCPHTTPRequired: proto.CapabilityUnsupported, + MCPHTTPBearerAuth: proto.CapabilityUnsupported, +}}, Configuration: configuration.Configuration(), Discover: discover} + +func discover(ctx context.Context, options agent.DiscoveryOptions, info proto.SupportedAgentKind) *agent.Runtime { + return discoverWithCheck(ctx, options, info, CheckRuntime) +} +func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, descriptor proto.SupportedAgentKind, check func(context.Context, Config) (RuntimeInfo, error)) *agent.Runtime { + entrypoint := os.Getenv(claudeSDKEntrypointEnv) + if entrypoint == "" { + return nil + } + out := &agent.Runtime{Info: descriptor, Session: func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, fmt.Errorf("claude_sdk: configured runtime is unavailable") + }} + var config Config + + fail := func(err error) *agent.Runtime { + fmt.Fprintf(options.Stderr, "oac-daemon: configured Claude SDK runtime unavailable: %v\n", err) + return out + } + if !filepath.IsAbs(entrypoint) { + return fail(fmt.Errorf("%s must be absolute", claudeSDKEntrypointEnv)) + } + profileDir, err := paths.ProfileDir(options.Profile) + if err != nil { + return fail(err) + } + if !filepath.IsAbs(profileDir) { + return fail(fmt.Errorf("Claude SDK state requires an absolute OAC_RUNTIME_HOME")) + } + node := os.Getenv(claudeSDKNodeEnv) + if node == "" { + node = "node" + } + node, err = exec.LookPath(node) + if err != nil { + return fail(fmt.Errorf("Claude SDK Node executable is unavailable")) + } + node, err = filepath.Abs(node) + if err != nil { + return fail(err) + } + config = Config{Node: node, Entrypoint: entrypoint, StateDir: filepath.Join(profileDir, "runtime", "claude-sdk")} + binding, err := localworkspace.Load() + if err != nil { + return fail(err) + } + if binding != nil { + root, err := paths.Root() + if err != nil { + return fail(err) + } + config.Node, err = filepath.EvalSymlinks(node) + if err != nil { + return fail(err) + } + config, err = ConfigureLocal(config, root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) + if err != nil { + return fail(err) + } + } + info, err := check(parent, config) + if err != nil { + return fail(err) + } + if config.Workspace != nil { + if !info.SupportsLocalRuntime() { + return fail(fmt.Errorf("Claude SDK bundle does not support the local Runtime contract")) + } + caps := &out.Info.Capabilities + caps.EnvironmentNone, caps.FunctionTools = proto.CapabilityUnsupported, proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()) + caps.Preparation, caps.LocalEnvironment = proto.CapabilitySupported, proto.CapabilitySupported + caps.WorkspaceReadPreparation, caps.NativeSessionRecovery = proto.CapabilitySupported, proto.CapabilitySupported + } + out.Info.Available, out.Info.Version = true, info.SDK + out.Info.Capabilities.MessageImages = proto.CapabilityFromBool(info.SupportsMessageImages()) + out.Info.Capabilities.FunctionResultImages = proto.CapabilityFromBool(info.SupportsFunctionResultImages()) + out.Info.Capabilities.ToolSearch = proto.CapabilityFromBool(info.SupportsToolSearch()) + if config.Workspace != nil { + out.Info.Capabilities.ToolSearch = proto.CapabilityFromBool(info.SupportsWorkspaceToolSearch()) + } + out.Info.Capabilities.StructuredOutput = proto.CapabilityFromBool(info.SupportsStructuredOutput()) + if config.Workspace != nil { + out.Info.Capabilities.StructuredOutput = proto.CapabilityFromBool(info.SupportsWorkspaceStructuredOutput()) + } + out.Info.Capabilities.SubagentObservations = proto.CapabilityFromBool(info.SupportsSubagents()) + out.Info.Capabilities.MCPHTTPTools = proto.CapabilityFromBool(info.SupportsHTTPMCP()) + out.Info.Capabilities.MCPHTTPBearerAuth = proto.CapabilityFromBool(info.SupportsHTTPMCPBearer()) + out.Info.Capabilities.MCPHTTPRequired = proto.CapabilityFromBool(info.SupportsHTTPMCPRequired()) + if config.Workspace != nil && !info.SupportsWorkspaceMCP() { + out.Info.Capabilities.MCPHTTPTools, out.Info.Capabilities.MCPHTTPBearerAuth = proto.CapabilityUnsupported, proto.CapabilityUnsupported + out.Info.Capabilities.MCPHTTPRequired = proto.CapabilityUnsupported + } + out.Session = NewFactory(config) + out.Executor = NewExecutorFactory(config) + if out.Info.Capabilities.LocalEnvironment.IsSupported() { + out.Preparation = NewPreparationFactory(config) + out.WorkspaceReadPreparation = true + } + + fmt.Fprintf(options.Stdout, "Claude SDK preflight ok (SDK %s, %s)\n", info.SDK, info.Native) + return out +} diff --git a/apps/daemon/internal/agent/claudesdk/declaration_test.go b/apps/daemon/internal/agent/claudesdk/declaration_test.go new file mode 100644 index 000000000..d62d77781 --- /dev/null +++ b/apps/daemon/internal/agent/claudesdk/declaration_test.go @@ -0,0 +1,136 @@ +package claudesdk + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestClaudeSDKInvalidPathsFailBeforeProbe(t *testing.T) { + for _, relative := range []string{"entrypoint", "home"} { + t.Run(relative, func(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "main.js")) + if relative == "entrypoint" { + t.Setenv(claudeSDKEntrypointEnv, "main.js") + } else { + t.Setenv("OAC_RUNTIME_HOME", "relative-home") + } + out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Profile: "default", Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info, func(context.Context, Config) (RuntimeInfo, error) { + t.Fatal("invalid paths reached runtime probe") + return RuntimeInfo{}, nil + }) + if out == nil || out.Info.Available { + t.Fatal("invalid runtime advertised as ready") + } + }) + } +} + +func TestClaudeSDKFeatureDiscovery(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "main.js")) + node, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv(claudeSDKNodeEnv, node) + for _, features := range [][]string{nil, {"mcp_http_tools"}, {"mcp_http_bearer_auth"}, {"mcp_http_tools", "mcp_http_bearer_auth"}, {"mcp_http_required"}, {"mcp_http_tools", "mcp_http_required"}, {"subagent_resources"}, {"structured_output"}} { + out := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Profile: "default", Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}, Declaration.Info, func(context.Context, Config) (RuntimeInfo, error) { + info := RuntimeInfo{SDK: "0.3.269", Native: "2.1.269 (Claude Code)", Features: features} + return info, nil + }) + supported := len(features) > 0 && features[0] == "mcp_http_tools" + if out == nil || !out.Info.Available || out.Info.Capabilities.MCPHTTPTools.IsSupported() != supported || out.Info.Capabilities.MCPHTTPBearerAuth.IsSupported() != (supported && slices.Contains(features, "mcp_http_bearer_auth")) || out.Info.Capabilities.MCPHTTPRequired.IsSupported() != (supported && slices.Contains(features, "mcp_http_required")) { + t.Fatal("MCP feature discovery widened the runtime profile") + } + if out.Info.Capabilities.StructuredOutput.IsSupported() != slices.Contains(features, "structured_output") { + t.Fatal("structured output feature does not match the installed runtime") + } + if out.Info.Capabilities.SubagentObservations.IsSupported() != slices.Contains(features, "subagent_resources") { + t.Fatal("Subagent feature discovery does not match the runtime contract") + } + } +} + +// The declaration must retain the complete baseline capability descriptor. +func TestDeclaredCapabilityBaseline(t *testing.T) { + expected := map[string]bool{"Streaming": true, "Usage": true, "Resume": true, "Steering": true, "MessageItems": true, "ToolObservations": true, "EnvironmentNone": true, "ProgrammaticToolCallingDisable": true, "ExecutionControls": true, "SubagentControl": true, "DurableInputReceipts": true, "DurableTurns": true, "FunctionTools": true} + value := reflect.ValueOf(Declaration.Info.Capabilities) + for i := 0; i < value.NumField(); i++ { + name := value.Type().Field(i).Name + want := proto.CapabilityUnsupported + if expected[name] { + want = proto.CapabilitySupported + } + if got := value.Field(i).Interface(); got != want { + t.Errorf("%s = %v, want %v", name, got, want) + } + } + if err := Declaration.Info.ValidateDeclaration(); err != nil { + t.Fatal(err) + } +} + +func TestRuntimeDiscoveryConfigurationAndRegistration(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + node, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv(claudeSDKNodeEnv, node) + entrypoint := filepath.Join(root, "bundle", "main.js") + options := agent.DiscoveryOptions{Profile: "test", Stdout: io.Discard, Stderr: io.Discard} + for _, configured := range []bool{false, true} { + for _, ready := range []bool{false, true} { + t.Setenv(claudeSDKEntrypointEnv, "") + if configured { + t.Setenv(claudeSDKEntrypointEnv, entrypoint) + } + calls := 0 + runtime := discoverWithCheck(t.Context(), options, Declaration.Info, func(_ context.Context, c Config) (RuntimeInfo, error) { + calls++ + if c.Node != node || c.Entrypoint != entrypoint || c.StateDir != filepath.Join(root, "daemon", "test", "runtime", "claude-sdk") || c.Env != nil { + t.Fatalf("configuration: %+v", c) + } + if !ready { + return RuntimeInfo{}, errors.New("readiness failed") + } + return RuntimeInfo{SDK: "test-sdk", Native: "test-native"}, nil + }) + if !configured { + if runtime != nil || calls != 0 { + t.Fatal("unconfigured runtime probed") + } + continue + } + if calls != 1 || runtime.Info.Available != ready || (runtime.Executor != nil) != ready || runtime.Preparation != nil { + t.Fatalf("runtime: %+v", runtime) + } + registry := agent.NewRegistry() + registry.Register(Declaration, *runtime) + info := registry.SupportedAgentKinds()[0] + if info.Capabilities.WorkspaceAuthoring.IsSupported() || info.Capabilities.Preparation.IsSupported() != ready { + t.Fatal(info) + } + if !ready { + factory, _ := registry.Resolve("claude_sdk") + if _, err := factory(t.Context(), proto.PromptRequestPayload{}, nil); err == nil || !strings.Contains(err.Error(), "runtime is unavailable") { + t.Fatal(err) + } + } + } + } +} diff --git a/apps/daemon/internal/agent/claudesdk/usage.go b/apps/daemon/internal/agent/claudesdk/usage.go index 960cc2af2..f76b4dcf6 100644 --- a/apps/daemon/internal/agent/claudesdk/usage.go +++ b/apps/daemon/internal/agent/claudesdk/usage.go @@ -17,7 +17,7 @@ func nativeUsage(raw json.RawMessage) (proto.Usage, error) { } // The SDK owns native counter scopes and cost estimates. Do not expose an // incomplete public token breakdown or a model selected from an unordered map. - return proto.Usage{Provider: "claude_code", Raw: map[string]any{"claude_sdk_result": snapshot}}, nil + return proto.Usage{Provider: "claude_sdk", Raw: map[string]any{"claude_sdk_result": snapshot}}, nil } // Keep every native measurement when a query spans multiple native turns. Each diff --git a/apps/daemon/internal/agent/codex/declaration.go b/apps/daemon/internal/agent/codex/declaration.go new file mode 100644 index 000000000..2c962fe2c --- /dev/null +++ b/apps/daemon/internal/agent/codex/declaration.go @@ -0,0 +1,80 @@ +package codex + +import ( + "context" + "fmt" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + configuration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/codex" +) + +// Declaration owns Codex discovery, configuration and execution factories. +var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{ + Kind: "codex", + Capabilities: proto.AgentKindCapabilities{ + SubagentObservations: proto.CapabilitySupported, + Streaming: proto.CapabilitySupported, + Permissions: proto.CapabilitySupported, + Usage: proto.CapabilitySupported, + Resume: proto.CapabilitySupported, + NativeSessionRecovery: proto.CapabilityUnsupported, + WorkspaceAuthoring: proto.CapabilitySupported, + Steering: proto.CapabilitySupported, + MessageItems: proto.CapabilitySupported, + ToolObservations: proto.CapabilitySupported, + EnvironmentNone: proto.CapabilitySupported, + LocalEnvironment: proto.CapabilityUnsupported, + Preparation: proto.CapabilityUnsupported, + WorkspaceReadPreparation: proto.CapabilityUnsupported, + WorkspaceOutputExport: proto.CapabilityUnsupported, + ProgrammaticToolCallingDisable: proto.CapabilitySupported, + WebSearchControl: proto.CapabilitySupported, + ExecutionControls: proto.CapabilityFromBool(SupportsTextVerbosity), + TextVerbosity: proto.CapabilityFromBool(SupportsTextVerbosity), + StructuredOutput: proto.CapabilityUnsupported, + ToolSearch: proto.CapabilityUnsupported, + MessageImages: proto.CapabilitySupported, + FunctionResultImages: proto.CapabilitySupported, + SubagentControl: proto.CapabilitySupported, + DurableInputReceipts: proto.CapabilitySupported, + DurableTurns: proto.CapabilitySupported, + FunctionTools: proto.CapabilitySupported, + MCPHTTPTools: proto.CapabilitySupported, + MCPHTTPRequired: proto.CapabilityUnsupported, + MCPHTTPBearerAuth: proto.CapabilitySupported, + }, +}, Configuration: configuration.Configuration(), Discover: discover} + +func discover(ctx context.Context, options agent.DiscoveryOptions, info proto.SupportedAgentKind) *agent.Runtime { + return discoverWithCheck(ctx, options, info, CheckCLIAvailable) +} +func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, info proto.SupportedAgentKind, check func(context.Context, string) (string, error)) *agent.Runtime { + runtime := &agent.Runtime{Info: info, Session: Factory, SessionCapabilityContext: true, ExecutorCapabilityContext: true} + ctx, cancel := context.WithTimeout(parent, 15*time.Second) + defer cancel() + version, err := check(ctx, "") + if err != nil { + fmt.Fprintf(options.Stderr, "oac-daemon: codex unavailable: %v\n Install: %s\n", err, InstallURL) + return runtime + } + runtime.Info.Available, runtime.Info.Version = true, version + caps := &runtime.Info.Capabilities + caps.NativeSessionRecovery = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) + caps.LocalEnvironment = proto.CapabilityFromBool(SupportsLocalEnvironment(version)) + caps.MCPHTTPRequired = proto.CapabilityFromBool(SupportsNativeSessionRecovery(version)) + runtime.Executor = NewExecutorFactory() + if caps.LocalEnvironment.IsSupported() { + runtime.WorkspaceReadPreparation = true + runtime.Preparation = func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + prepared, err := Prepare(ctx, req) + if prepared == nil { + return nil, err + } + return prepared, err + } + } + fmt.Fprintf(options.Stdout, "Codex preflight ok (%s)\n", version) + return runtime +} diff --git a/apps/daemon/internal/agent/codex/declaration_test.go b/apps/daemon/internal/agent/codex/declaration_test.go new file mode 100644 index 000000000..652c5cc1e --- /dev/null +++ b/apps/daemon/internal/agent/codex/declaration_test.go @@ -0,0 +1,55 @@ +package codex + +import ( + "context" + "errors" + "io" + "reflect" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { + for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3", "codex-cli 0.154.0"} { + runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info, func(context.Context, string) (string, error) { return version, nil }) + + if !runtime.Info.Available || runtime.Executor == nil || (runtime.Preparation != nil) != SupportsLocalEnvironment(version) || !runtime.SessionCapabilityContext || !runtime.ExecutorCapabilityContext { + t.Fatalf("factories: %+v", runtime) + } + if runtime.Info.Capabilities.MCPHTTPRequired.IsSupported() != (version == "codex-cli 0.153.4") { + t.Fatal("unverified native combination advertised") + } + if runtime.Info.Capabilities.NativeSessionRecovery.IsSupported() != (version == "codex-cli 0.153.4") { + t.Fatal("unverified native recovery advertised") + } + } +} + +// The declaration must retain the complete baseline capability descriptor. +func TestDeclaredCapabilityBaseline(t *testing.T) { + expected := map[string]bool{"SubagentObservations": true, "Streaming": true, "Permissions": true, "Usage": true, "Resume": true, "Steering": true, "MessageItems": true, "ToolObservations": true, "EnvironmentNone": true, "ProgrammaticToolCallingDisable": true, "WebSearchControl": true, "MessageImages": true, "FunctionResultImages": true, "SubagentControl": true, "DurableInputReceipts": true, "DurableTurns": true, "FunctionTools": true, "MCPHTTPTools": true, "MCPHTTPBearerAuth": true, "WorkspaceAuthoring": true} + expected["ExecutionControls"], expected["TextVerbosity"] = SupportsTextVerbosity, SupportsTextVerbosity + value := reflect.ValueOf(Declaration.Info.Capabilities) + for i := 0; i < value.NumField(); i++ { + name := value.Type().Field(i).Name + want := proto.CapabilityUnsupported + if expected[name] { + want = proto.CapabilitySupported + } + if got := value.Field(i).Interface(); got != want { + t.Errorf("%s = %v, want %v", name, got, want) + } + } + if err := Declaration.Info.ValidateDeclaration(); err != nil { + t.Fatal(err) + } +} + +func TestUnavailableRuntimeHasNoExecutionFactories(t *testing.T) { + runtime := discoverWithCheck(t.Context(), agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard}, Declaration.Info, func(context.Context, string) (string, error) { return "", errors.New("missing") }) + if runtime.Info.Available || runtime.Executor != nil || runtime.Preparation != nil || runtime.Session == nil { + t.Fatalf("unavailable runtime: %+v", runtime) + } +} diff --git a/apps/daemon/internal/agent/codex/mcp_config.go b/apps/daemon/internal/agent/codex/mcp_config.go index 538791f10..aac6309cb 100644 --- a/apps/daemon/internal/agent/codex/mcp_config.go +++ b/apps/daemon/internal/agent/codex/mcp_config.go @@ -10,7 +10,7 @@ import ( // mcpServerConfig is the daemon-internal MCP server config flattened // from agent_options["mcp_servers"] (rendered by render.TargetCodex / -// claudecode's mcpServers JSON shape). Written into /config.toml +// the mcpServers JSON shape). Written into /config.toml // before spawning the app-server child. type mcpServerConfig struct { Name string diff --git a/apps/daemon/internal/agent/codex/options.go b/apps/daemon/internal/agent/codex/options.go index b6504a647..147a3d3dc 100644 --- a/apps/daemon/internal/agent/codex/options.go +++ b/apps/daemon/internal/agent/codex/options.go @@ -69,15 +69,14 @@ type SessionPlan struct { } // BuildSessionPlan derives a SessionPlan from PromptRequestPayload's -// fields. The work_dir / opts shape mirrors how claudecode + opencode -// consume their own opts: a string-keyed map of any. +// fields. Options use a string-keyed map. // // The agent_options keys this function reads: // // model string codex model slug, e.g. "gpt-5.5" // system_prompt string forwarded as developerInstructions // override_system_prompt string replaces system_prompt entirely when -// non-empty (mirrors claudecode/opencode) +// non-empty // env map[string]any extra env vars (KEY=string-value) // mcp_servers map[string]any rendered MCP server config — written // to /config.toml [mcp_servers] @@ -231,7 +230,7 @@ func resolveWorkDirCodex(input string) (string, error) { default: return "", fmt.Errorf("codex: work_dir must be absolute or start with ~/, got %q", trimmed) } - // Match claudecode's resolveSessionWorkDir: mkdir -p so a user + // Create the working directory so a user // naming a fresh project root in the agent wizard works on first // run instead of erroring with "does not exist". if err := os.MkdirAll(abs, 0o755); err != nil { diff --git a/apps/daemon/internal/agent/codex/options_test.go b/apps/daemon/internal/agent/codex/options_test.go index 068ef091b..613926e55 100644 --- a/apps/daemon/internal/agent/codex/options_test.go +++ b/apps/daemon/internal/agent/codex/options_test.go @@ -178,7 +178,7 @@ func TestBuildSessionPlan_RejectsRelativeWorkDir(t *testing.T) { } } -// TestBuildSessionPlan_CreatesMissingWorkDir: align with claudecode — +// TestBuildSessionPlan_CreatesMissingWorkDir: // a non-existent absolute path is mkdir -p'd so a user can pin a fresh // project root in the agent wizard. Without this, codex agents would // hard-fail the first turn instead of running. diff --git a/apps/daemon/internal/agent/codex/protocol.go b/apps/daemon/internal/agent/codex/protocol.go index 81a1d9b93..2aa988740 100644 --- a/apps/daemon/internal/agent/codex/protocol.go +++ b/apps/daemon/internal/agent/codex/protocol.go @@ -3,7 +3,7 @@ // speaking the JSON-RPC 2.0 protocol that the app-server exposes over // stdio. // -// Codex differs from the claudecode and opencode adapters in two ways: +// Codex uses an app-server transport: // // 1. The wire protocol is JSON-RPC (request / response / notification / // server-request) rather than NDJSON event-stream. See rpc.go. diff --git a/apps/daemon/internal/agent/codex/rpc.go b/apps/daemon/internal/agent/codex/rpc.go index 665877de3..2102eda9e 100644 --- a/apps/daemon/internal/agent/codex/rpc.go +++ b/apps/daemon/internal/agent/codex/rpc.go @@ -33,7 +33,7 @@ const rpcInitTimeout = 10 * time.Second const rpcKillTimeout = 3 * time.Second // rpcStdoutBufferMax caps a single NDJSON line on stdout. Codex's -// aggregated_output frames can run large; 16 MiB matches the opencode +// aggregated_output frames can run large; 16 MiB bounds the // adapter and is well above any realistic single-line payload. const rpcStdoutBufferMax = 16 * 1024 * 1024 diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go index f772866ce..9b8ce23b0 100644 --- a/apps/daemon/internal/agent/codex/session.go +++ b/apps/daemon/internal/agent/codex/session.go @@ -17,7 +17,7 @@ import ( // terminalSendTimeout caps how long the session waits to deliver the // final done / error envelope on the upstream channel. Matches the -// claudecode + opencode safety net. +// adapter safety net. const terminalSendTimeout = 2 * time.Second // sessionConfig is the cross-cutting knob bag — production callers go diff --git a/apps/daemon/internal/agent/codex/skills.go b/apps/daemon/internal/agent/codex/skills.go index ae8ead08c..aacb6d0ca 100644 --- a/apps/daemon/internal/agent/codex/skills.go +++ b/apps/daemon/internal/agent/codex/skills.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/managedskills" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -30,7 +30,7 @@ func prepareManagedSkills(ctx context.Context, logger *slog.Logger, req proto.Pr if err != nil { return "", fmt.Errorf("codex: resolve managed skills root: %w", err) } - result, err := claudecode.InstallManagedSkills(ctx, logger, root, rawSkills) + result, err := managedskills.InstallManagedSkills(ctx, logger, root, rawSkills) if err != nil { return "", fmt.Errorf("codex: install skills: %w", err) } diff --git a/apps/daemon/internal/agent/codex/version.go b/apps/daemon/internal/agent/codex/version.go index b48db362c..6a93cf979 100644 --- a/apps/daemon/internal/agent/codex/version.go +++ b/apps/daemon/internal/agent/codex/version.go @@ -28,7 +28,7 @@ var ErrCLINotFound = errors.New("codex CLI not found") // CheckCLIAvailable runs ` --version` and returns the trimmed // first line. The empty binary name defaults to defaultBinary(). Matches -// the CheckCLIAvailable signature of the claudecode and opencode adapters +// the CLI availability check signature // so connect.go's preflight loop treats every engine uniformly. func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { if strings.TrimSpace(binary) == "" { diff --git a/apps/daemon/internal/agent/harness.go b/apps/daemon/internal/agent/harness.go index 80afd7021..91ee09f5b 100644 --- a/apps/daemon/internal/agent/harness.go +++ b/apps/daemon/internal/agent/harness.go @@ -11,13 +11,11 @@ // images, structured output and Subagent observations use protocol messages // rather than additional Go interfaces; qualify and advertise them separately. // -// Registration: RegisterKind takes the existing proto.SupportedAgentKind -// descriptor (kind, availability, native version and AgentKindCapabilities), -// the mandatory shared Configuration and the direct-call Factory. RegisterExecutor supplies the Session-owned lifecycle. -// RegisterPreparation optionally adds workspace access without model input. -// RegisterKind resets the other factories, so register it first. Preparation -// flags are derived by these methods; other advertised capabilities must reflect -// verified native behavior. Built-in registration lives in internal/cli. +// Registration: each adapter exports one Declaration. The Runtime discovers the +// static declaration list and installs each resulting Runtime through Register. +// Availability and factory selection belong to the adapter. RegisterKind resets +// the other factories, so Register installs it first. Preparation capabilities +// are derived from the declared factories. // // Runtime registration and Core service qualification remain separate. A public // Harness also needs a profile in services/core/internal/engine; advertising @@ -27,11 +25,56 @@ package agent import ( "context" + "io" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" ) +// Declaration is the complete startup contract for a Harness implementation. +// Discover returns nil when the adapter is not configured. An unavailable +// configured adapter returns a Runtime with Available=false and a session factory. +// Discovery owns runtime-specific configuration, readiness and feature gates. +type Declaration struct { + Info proto.SupportedAgentKind + Configuration harnessconfig.Configuration + Discover func(context.Context, DiscoveryOptions, proto.SupportedAgentKind) *Runtime +} + +// DiscoveryOptions provides process context without naming an implementation. +type DiscoveryOptions struct { + Profile string + Stdout, Stderr io.Writer +} + +// Runtime binds one discovered descriptor to its native factories. +// SessionCapabilityContext and ExecutorCapabilityContext request the Runtime's +// capability-download URL and scoped product-upload context for those factories. +// Preparation never receives those execution-only effects. +type Runtime struct { + Info proto.SupportedAgentKind + Session Factory + Preparation PreparationFactory + Executor ExecutorFactory + WorkspaceReadPreparation bool + SessionCapabilityContext bool + ExecutorCapabilityContext bool +} + +// Register installs a discovered Runtime with its declaration's configuration. +func (r *Registry) Register(declaration Declaration, runtime Runtime) { + if runtime.Info.Kind != declaration.Info.Kind { + panic("agent.Registry.Register: discovery kind differs from declaration") + } + r.RegisterKind(runtime.Info, declaration.Configuration, runtime.Session) + if runtime.Executor != nil { + r.RegisterExecutor(runtime.Info.Kind, runtime.Executor) + } + if runtime.Preparation != nil { + r.RegisterPreparation(runtime.Info.Kind, runtime.WorkspaceReadPreparation, runtime.Preparation) + } +} + // Model configuration has one shared contract, authored in // internal/harnessconfig/harness.go. RegisterKind requires that declaration; // RegisterExecutor and RegisterPreparation inherit it. Every registered entry diff --git a/apps/daemon/internal/agent/claudecode/plugins.go b/apps/daemon/internal/agent/managedskills/archive.go similarity index 66% rename from apps/daemon/internal/agent/claudecode/plugins.go rename to apps/daemon/internal/agent/managedskills/archive.go index 8f963ee7c..363f8b0da 100644 --- a/apps/daemon/internal/agent/claudecode/plugins.go +++ b/apps/daemon/internal/agent/managedskills/archive.go @@ -1,4 +1,4 @@ -package claudecode +package managedskills import ( "archive/zip" @@ -11,32 +11,11 @@ import ( "net/http" "net/url" "os" - "path" "path/filepath" "strings" "time" ) -// pluginDescriptor is the daemon-side view of one server-sent plugin -// entry under agent_options["plugins"]: -// -// { "name": "...", "version": "...", "download_url": "...", "sha256": "..." } -type pluginDescriptor struct { - Name string - Version string - DownloadURL string - SHA256 string -} - -// PluginInstallResult is what installPlugins returns: local directory -// paths to feed into `--plugin-dir`, plus warnings the session should -// surface. Errors that abort install bubble up through the error -// return; warnings cover the "N-1 of N installed" case. -type PluginInstallResult struct { - PluginDirs []string - Warnings []string -} - // pluginInstallTimeout caps a single plugin's download + extract step. const pluginInstallTimeout = 60 * time.Second @@ -307,131 +286,4 @@ func normaliseZipPath(name string) string { return strings.TrimSuffix(p, "/") } -// decodePluginDescriptors converts the raw agent_options["plugins"] -// value into a typed slice. Entries that fail to decode are dropped -// with a warning string returned alongside — the rest of the plugins -// might still be installable. -func decodePluginDescriptors(raw any) ([]pluginDescriptor, []string) { - if raw == nil { - return nil, nil - } - items, ok := raw.([]any) - if !ok { - return nil, []string{fmt.Sprintf("agent_options[plugins] must be array, got %T", raw)} - } - out := make([]pluginDescriptor, 0, len(items)) - warnings := make([]string, 0) - for i, item := range items { - obj, ok := item.(map[string]any) - if !ok { - warnings = append(warnings, fmt.Sprintf("plugins[%d]: not an object", i)) - continue - } - p := pluginDescriptor{ - Name: stringField(obj, "name"), - Version: stringField(obj, "version"), - DownloadURL: stringField(obj, "download_url"), - SHA256: stringField(obj, "sha256"), - } - if err := p.validate(); err != nil { - warnings = append(warnings, fmt.Sprintf("plugins[%d] (%s): %v", i, p.Name, err)) - continue - } - out = append(out, p) - } - return out, warnings -} - -func stringField(m map[string]any, key string) string { - if v, ok := m[key].(string); ok { - return v - } - return "" -} - -// validate is the daemon-side analogue of canonical.PluginSpec.Validate -// with a narrower contract — defense in depth, server-side validator -// is authoritative. -func (p pluginDescriptor) validate() error { - if strings.TrimSpace(p.Name) == "" { - return errors.New("name is required") - } - // Block path-traversal-ish names before they hit filepath.Join. - if strings.ContainsAny(p.Name, "/\\") || p.Name == "." || p.Name == ".." { - return fmt.Errorf("name %q contains path separator or dot-ref", p.Name) - } - if strings.TrimSpace(p.DownloadURL) == "" { - return errors.New("download_url is required") - } - if len(p.SHA256) != 64 { - return fmt.Errorf("sha256 must be 64 hex chars (got %d)", len(p.SHA256)) - } - return nil -} - -// cacheKey is what we stamp into /.cache-key. Including the -// sha256 means a re-published version with the same name+version (but -// rebuilt zip content) invalidates the cache. -func (p pluginDescriptor) cacheKey() string { - return fmt.Sprintf("%s@%s", path.Clean(p.Name), strings.ToLower(p.SHA256)) -} - -// cloneAgentOptions returns a shallow copy of agent_options. Shallow -// is fine — we only overwrite the top-level "plugin_dirs" key. -func cloneAgentOptions(opts map[string]any) map[string]any { - if opts == nil { - return map[string]any{} - } - out := make(map[string]any, len(opts)) - for k, v := range opts { - out[k] = v - } - return out -} - -// mergePluginDirs combines a caller-supplied plugin_dirs override -// (accepted as []string OR []any) with the capability-resolved list, -// preserving order and deduplicating. Override wins on collision. -func mergePluginDirs(existing any, resolved []string) []string { - preset := coerceStringSlice(existing) - seen := make(map[string]bool, len(preset)+len(resolved)) - out := make([]string, 0, len(preset)+len(resolved)) - for _, d := range preset { - if d == "" || seen[d] { - continue - } - seen[d] = true - out = append(out, d) - } - for _, d := range resolved { - if d == "" || seen[d] { - continue - } - seen[d] = true - out = append(out, d) - } - return out -} - -// coerceStringSlice accepts the two wire shapes opts["plugin_dirs"] -// can take: a pre-typed []string or a JSON-decoded []any of strings. -// BuildArgs' stringSlice errors on bad shapes downstream, so a clean -// degradation here is fine. -func coerceStringSlice(v any) []string { - switch t := v.(type) { - case nil: - return nil - case []string: - return t - case []any: - out := make([]string, 0, len(t)) - for _, item := range t { - if s, ok := item.(string); ok { - out = append(out, s) - } - } - return out - default: - return nil - } -} +func stringField(m map[string]any, key string) string { s, _ := m[key].(string); return s } diff --git a/apps/daemon/internal/agent/claudecode/plugins_test.go b/apps/daemon/internal/agent/managedskills/archive_test.go similarity index 57% rename from apps/daemon/internal/agent/claudecode/plugins_test.go rename to apps/daemon/internal/agent/managedskills/archive_test.go index 2c853d43c..af5c30a55 100644 --- a/apps/daemon/internal/agent/claudecode/plugins_test.go +++ b/apps/daemon/internal/agent/managedskills/archive_test.go @@ -1,4 +1,4 @@ -package claudecode +package managedskills import ( "archive/zip" @@ -96,20 +96,20 @@ func TestInstallPlugins_HappyPath_ExtractsAndStampsCacheKey(t *testing.T) { srv := startPluginServer(t, body) workDir := t.TempDir() - res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, }) if err != nil { t.Fatalf("installPlugins: %v", err) } - if len(res.PluginDirs) != 1 { - t.Fatalf("PluginDirs = %v, want 1 entry", res.PluginDirs) + if len(res.SkillDirs) != 1 { + t.Fatalf("SkillDirs = %v, want 1 entry", res.SkillDirs) } if len(res.Warnings) != 0 { t.Fatalf("unexpected warnings: %v", res.Warnings) } - dir := res.PluginDirs[0] + dir := res.SkillDirs[0] if filepath.Base(dir) != "my-plugin" { t.Fatalf("dir basename = %q, want my-plugin", filepath.Base(dir)) } @@ -135,10 +135,10 @@ func TestInstallPlugins_CacheHitSkipsDownload(t *testing.T) { srv := startPluginServer(t, body) workDir := t.TempDir() - desc := []pluginDescriptor{ + desc := []skillDescriptor{ {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, } - if _, err := installPlugins(context.Background(), discardLogger(), workDir, desc); err != nil { + if _, err := installSkillsForTest(context.Background(), discardLogger(), workDir, desc); err != nil { t.Fatalf("first install: %v", err) } hitsAfterFirst := srv.Hits() @@ -148,7 +148,7 @@ func TestInstallPlugins_CacheHitSkipsDownload(t *testing.T) { // Second install with the same descriptor — cache-key match // short-circuits BEFORE any HTTP call. - if _, err := installPlugins(context.Background(), discardLogger(), workDir, desc); err != nil { + if _, err := installSkillsForTest(context.Background(), discardLogger(), workDir, desc); err != nil { t.Fatalf("second install: %v", err) } if got := srv.Hits(); got != hitsAfterFirst { @@ -163,10 +163,10 @@ func TestInstallPlugins_CacheInvalidatedBySHA256Change(t *testing.T) { workDir := t.TempDir() logger := discardLogger() - first := []pluginDescriptor{ + first := []skillDescriptor{ {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, } - if _, err := installPlugins(context.Background(), logger, workDir, first); err != nil { + if _, err := installSkillsForTest(context.Background(), logger, workDir, first); err != nil { t.Fatalf("first install: %v", err) } @@ -178,13 +178,13 @@ func TestInstallPlugins_CacheInvalidatedBySHA256Change(t *testing.T) { }) srv.body = newBody - second := []pluginDescriptor{ + second := []skillDescriptor{ {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(newBody)}, } - if _, err := installPlugins(context.Background(), logger, workDir, second); err != nil { + if _, err := installSkillsForTest(context.Background(), logger, workDir, second); err != nil { t.Fatalf("second install: %v", err) } - dir := filepath.Join(workDir, ".claude", "plugins", "my-plugin") + dir := filepath.Join(workDir, "my-plugin") if _, err := os.Stat(filepath.Join(dir, "commands", "different.md")); err != nil { t.Fatalf("new content not extracted: %v", err) } @@ -201,14 +201,14 @@ func TestInstallPlugins_SHA256MismatchDemotesToWarning(t *testing.T) { // Wrong sha → no install, no hard error; rest of the prompt // continues without this plugin. - res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: strings.Repeat("0", 64)}, }) if err != nil { t.Fatalf("installPlugins: %v", err) } - if len(res.PluginDirs) != 0 { - t.Fatalf("PluginDirs = %v, want empty after sha mismatch", res.PluginDirs) + if len(res.SkillDirs) != 0 { + t.Fatalf("SkillDirs = %v, want empty after sha mismatch", res.SkillDirs) } if len(res.Warnings) == 0 { t.Fatal("expected warning on sha mismatch") @@ -218,7 +218,7 @@ func TestInstallPlugins_SHA256MismatchDemotesToWarning(t *testing.T) { } // No .cache-key file must be stamped — would short-circuit future // retries with the same bad sha. - if _, err := os.Stat(filepath.Join(workDir, ".claude", "plugins", "my-plugin", ".cache-key")); err == nil { + if _, err := os.Stat(filepath.Join(workDir, "my-plugin", ".cache-key")); err == nil { t.Fatal("cache-key stamped despite sha mismatch") } } @@ -229,10 +229,10 @@ func TestInstallPlugins_HTTPErrorDemotesToWarning(t *testing.T) { srv := startPluginServer(t, nil) srv.stat = http.StatusForbidden - res, _ := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + res, _ := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ {Name: "p", Version: "1", DownloadURL: srv.URL, SHA256: strings.Repeat("a", 64)}, }) - if len(res.PluginDirs) != 0 { + if len(res.SkillDirs) != 0 { t.Fatal("expected no installed dirs on 403") } if len(res.Warnings) == 0 { @@ -249,16 +249,16 @@ func TestInstallPlugins_StripWrappingRoot(t *testing.T) { srv := startPluginServer(t, body) workDir := t.TempDir() - res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, }) if err != nil { t.Fatalf("install: %v", err) } - if len(res.PluginDirs) != 1 { - t.Fatalf("PluginDirs = %v", res.PluginDirs) + if len(res.SkillDirs) != 1 { + t.Fatalf("SkillDirs = %v", res.SkillDirs) } - dir := res.PluginDirs[0] + dir := res.SkillDirs[0] if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { t.Fatalf("manifest at expected path missing (wrapper not stripped?): %v", err) } @@ -283,16 +283,16 @@ func TestInstallPlugins_StripWrappingRootWithBareDirEntry(t *testing.T) { srv := startPluginServer(t, body) workDir := t.TempDir() - res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, }) if err != nil { t.Fatalf("install: %v", err) } - if len(res.PluginDirs) != 1 { - t.Fatalf("PluginDirs = %v", res.PluginDirs) + if len(res.SkillDirs) != 1 { + t.Fatalf("SkillDirs = %v", res.SkillDirs) } - dir := res.PluginDirs[0] + dir := res.SkillDirs[0] if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { t.Fatalf("manifest at expected path missing (bare dir entry confused wrapper detection?): %v", err) } @@ -309,13 +309,13 @@ func TestInstallPlugins_MacOSXMetadataIgnored(t *testing.T) { }) srv := startPluginServer(t, body) workDir := t.TempDir() - res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, }) if err != nil { t.Fatalf("install: %v", err) } - dir := res.PluginDirs[0] + dir := res.SkillDirs[0] if _, err := os.Stat(filepath.Join(dir, "__MACOSX")); err == nil { t.Fatal("__MACOSX dir was extracted despite filter") } @@ -334,11 +334,11 @@ func TestInstallPlugins_PathTraversalRejected(t *testing.T) { srv := startPluginServer(t, body) workDir := t.TempDir() - res, _ := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + res, _ := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, }) - if len(res.PluginDirs) != 0 { - t.Fatalf("PluginDirs = %v, want empty on path-traversal", res.PluginDirs) + if len(res.SkillDirs) != 0 { + t.Fatalf("SkillDirs = %v, want empty on path-traversal", res.SkillDirs) } if len(res.Warnings) == 0 { t.Fatal("expected warning on path-traversal") @@ -387,16 +387,16 @@ func TestInstallPlugins_SymlinkEntrySkipped(t *testing.T) { srv := startPluginServer(t, body) workDir := t.TempDir() - res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, }) if err != nil { t.Fatalf("install: %v", err) } - if len(res.PluginDirs) != 1 { - t.Fatalf("PluginDirs = %v, want 1", res.PluginDirs) + if len(res.SkillDirs) != 1 { + t.Fatalf("SkillDirs = %v, want 1", res.SkillDirs) } - dir := res.PluginDirs[0] + dir := res.SkillDirs[0] if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil { t.Fatalf("manifest missing: %v", err) } @@ -417,15 +417,15 @@ func TestInstallPlugins_ConcurrentSamePluginNoTruncation(t *testing.T) { done := make(chan error, 2) for i := 0; i < 2; i++ { go func() { - res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ {Name: "my-plugin", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, }) if err != nil { done <- err return } - if len(res.PluginDirs) != 1 { - done <- fmt.Errorf("PluginDirs = %v", res.PluginDirs) + if len(res.SkillDirs) != 1 { + done <- fmt.Errorf("SkillDirs = %v", res.SkillDirs) return } done <- nil @@ -448,18 +448,18 @@ func TestInstallPlugins_PartialInstall(t *testing.T) { srvBAD := startPluginServer(t, bodyOK) workDir := t.TempDir() - res, err := installPlugins(context.Background(), discardLogger(), workDir, []pluginDescriptor{ + res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ {Name: "good", Version: "1", DownloadURL: srvOK.URL, SHA256: sha256Hex(bodyOK)}, {Name: "bad", Version: "1", DownloadURL: srvBAD.URL, SHA256: strings.Repeat("0", 64)}, }) if err != nil { t.Fatalf("install: %v", err) } - if len(res.PluginDirs) != 1 { - t.Fatalf("PluginDirs = %v, want 1 (only the good one)", res.PluginDirs) + if len(res.SkillDirs) != 1 { + t.Fatalf("SkillDirs = %v, want 1 (only the good one)", res.SkillDirs) } - if !strings.HasSuffix(res.PluginDirs[0], "/good") { - t.Fatalf("PluginDirs[0] = %q, want trailing /good", res.PluginDirs[0]) + if !strings.HasSuffix(res.SkillDirs[0], "/good") { + t.Fatalf("SkillDirs[0] = %q, want trailing /good", res.SkillDirs[0]) } if len(res.Warnings) == 0 { t.Fatal("expected warning for the bad plugin") @@ -468,241 +468,19 @@ func TestInstallPlugins_PartialInstall(t *testing.T) { func TestInstallPlugins_EmptyListIsNoop(t *testing.T) { t.Parallel() - res, err := installPlugins(context.Background(), discardLogger(), t.TempDir(), nil) + res, err := installSkillsForTest(context.Background(), discardLogger(), t.TempDir(), nil) if err != nil { t.Fatalf("install: %v", err) } - if len(res.PluginDirs) != 0 || len(res.Warnings) != 0 { + if len(res.SkillDirs) != 0 || len(res.Warnings) != 0 { t.Fatalf("expected empty result; got %+v", res) } } -func TestInstallPlugins_DescriptorValidatorRejectsBadNames(t *testing.T) { - t.Parallel() - body := validPluginZipBytes(t) - srv := startPluginServer(t, body) - res, _ := installPlugins(context.Background(), discardLogger(), t.TempDir(), []pluginDescriptor{ - {Name: "../escape", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if len(res.PluginDirs) != 0 { - t.Fatalf("PluginDirs = %v; bad name should be rejected", res.PluginDirs) - } - if len(res.Warnings) == 0 { - t.Fatal("expected warning") - } -} - -func TestDecodePluginDescriptors_ArrayShape(t *testing.T) { - t.Parallel() - raw := []any{ - map[string]any{"name": "a", "version": "1", "download_url": "https://x/a.zip", "sha256": strings.Repeat("a", 64)}, - map[string]any{"name": "", "version": "1", "download_url": "https://x/b.zip", "sha256": strings.Repeat("b", 64)}, - "not an object", - } - got, warns := decodePluginDescriptors(raw) - if len(got) != 1 || got[0].Name != "a" { - t.Fatalf("got = %v, want 1 valid entry", got) - } - if len(warns) != 2 { - t.Fatalf("warns = %v, want 2", warns) - } -} - -func TestDecodePluginDescriptors_NilAndWrongType(t *testing.T) { - t.Parallel() - got, warns := decodePluginDescriptors(nil) - if got != nil || warns != nil { - t.Fatalf("nil input should produce nil output; got=%v warns=%v", got, warns) - } - _, warns = decodePluginDescriptors("not an array") - if len(warns) != 1 { - t.Fatalf("expected 1 warning on wrong type, got %v", warns) - } -} - -func TestMergePluginDirs_OverrideWinsAndDedupes(t *testing.T) { - t.Parallel() - got := mergePluginDirs([]any{"/a", "/b"}, []string{"/b", "/c"}) - want := []string{"/a", "/b", "/c"} - if !equalStrings(got, want) { - t.Fatalf("got %v, want %v", got, want) - } -} - -func TestMergePluginDirs_AcceptsTypedStringSlice(t *testing.T) { - t.Parallel() - got := mergePluginDirs([]string{"/x"}, []string{"/y"}) - want := []string{"/x", "/y"} - if !equalStrings(got, want) { - t.Fatalf("got %v, want %v", got, want) - } -} - -func TestMergePluginDirs_NilExisting(t *testing.T) { - t.Parallel() - got := mergePluginDirs(nil, []string{"/x"}) - if !equalStrings(got, []string{"/x"}) { - t.Fatalf("got %v", got) - } -} - -func equalStrings(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -// TestResolveSessionWorkDir_RespectsExplicitDir locks in "caller wins": -// when req.WorkDir is set we must use exactly that path (mkdir -p if it -// doesn't exist yet) and never fall back to the conversation scratch -// dir. -func TestResolveSessionWorkDir_RespectsExplicitDir(t *testing.T) { - t.Parallel() - explicit := filepath.Join(t.TempDir(), "some-explicit-dir") - got, err := resolveSessionWorkDir(explicit, "conv-ignored") - if err != nil { - t.Fatalf("resolveSessionWorkDir: %v", err) - } - if got != explicit { - t.Fatalf("got %q, want explicit dir verbatim", got) - } - info, err := os.Stat(got) - if err != nil { - t.Fatalf("stat explicit dir: %v", err) - } - if !info.IsDir() { - t.Fatalf("explicit dir %q is not a directory", got) - } -} - -func TestResolveSessionWorkDir_ExpandsHomeRelativeDir(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - got, err := resolveSessionWorkDir("~/projects/demo", "conv-ignored") - if err != nil { - t.Fatalf("resolveSessionWorkDir: %v", err) - } - want := filepath.Join(home, "projects", "demo") - if got != want { - t.Fatalf("got %q, want %q", got, want) - } - info, err := os.Stat(got) - if err != nil { - t.Fatalf("stat home-relative dir: %v", err) - } - if !info.IsDir() { - t.Fatalf("home-relative dir %q is not a directory", got) - } -} - -// TestResolveSessionWorkDir_RejectsRelativeDir: relative paths are -// ambiguous (resolved against daemon cwd, which is not a stable anchor -// for user-facing config). The user gets a clear error instead of a -// chdir failure later. -func TestResolveSessionWorkDir_RejectsRelativeDir(t *testing.T) { - t.Parallel() - for _, rel := range []string{"foo", "./bar", "../baz", "a/b/c"} { - if _, err := resolveSessionWorkDir(rel, "conv-x"); err == nil { - t.Fatalf("relative path %q: expected error, got nil", rel) - } - } -} - -// TestResolveSessionWorkDir_ExplicitDirCreated: an absolute path whose -// parents don't exist yet still works — daemon mkdir -p's it. This is -// the "user named a fresh project root" case. -func TestResolveSessionWorkDir_ExplicitDirCreated(t *testing.T) { - t.Parallel() - target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") - got, err := resolveSessionWorkDir(target, "conv-ignored") - if err != nil { - t.Fatalf("resolveSessionWorkDir: %v", err) - } - if got != target { - t.Fatalf("got %q, want %q", got, target) - } - info, err := os.Stat(target) - if err != nil { - t.Fatalf("stat target: %v", err) - } - if !info.IsDir() { - t.Fatalf("target %q is not a directory", target) - } -} - -// TestResolveSessionWorkDir_FallbackCreatesDir: empty req.WorkDir with -// conversation_id must yield a real on-disk per-conversation directory -// under daemon HOME used for BOTH plugin install AND claude cwd. -// Overrides HOME to keep test inside t.TempDir(). -func TestResolveSessionWorkDir_FallbackCreatesDir(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - - got, err := resolveSessionWorkDir("", "conv-abc-123") - if err != nil { - t.Fatalf("resolveSessionWorkDir: %v", err) - } - want := filepath.Join(tmp, ".oac", "runtime", "claudecode", "conv-conv-abc-123") - if got != want { - t.Fatalf("got %q, want %q", got, want) - } - info, err := os.Stat(got) - if err != nil { - t.Fatalf("stat fallback dir: %v", err) - } - if !info.IsDir() { - t.Fatalf("fallback %q is not a directory", got) - } -} - -// TestResolveSessionWorkDir_BothEmptyFallsBackToCwd: when neither -// req.WorkDir nor conversation_id is provided, degrade to daemon cwd -// rather than refuse. -func TestResolveSessionWorkDir_BothEmptyFallsBackToCwd(t *testing.T) { - t.Parallel() - got, err := resolveSessionWorkDir("", "") - if err != nil { - t.Fatalf("resolveSessionWorkDir: %v", err) - } - wantCwd, err := os.Getwd() - if err != nil { - t.Fatalf("os.Getwd: %v", err) - } - if got != wantCwd { - t.Fatalf("got %q, want daemon cwd %q", got, wantCwd) - } - // Whitespace-only inputs must be treated as empty. - got, err = resolveSessionWorkDir(" ", " ") - if err != nil { - t.Fatalf("whitespace inputs: %v", err) - } - if got != wantCwd { - t.Fatalf("whitespace inputs: got %q, want %q", got, wantCwd) - } -} - -// TestResolveSessionWorkDir_FallbackIsIdempotent: a second call with -// the same conversation_id must succeed (MkdirAll on existing dir). -func TestResolveSessionWorkDir_FallbackIsIdempotent(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - - first, err := resolveSessionWorkDir("", "conv-x") - if err != nil { - t.Fatalf("first call: %v", err) - } - second, err := resolveSessionWorkDir("", "conv-x") - if err != nil { - t.Fatalf("second call: %v", err) - } - if first != second { - t.Fatalf("non-deterministic fallback: %q vs %q", first, second) +func installSkillsForTest(ctx context.Context, logger *slog.Logger, root string, descriptors []skillDescriptor) (SkillInstallResult, error) { + raw := make([]any, 0, len(descriptors)) + for _, d := range descriptors { + raw = append(raw, map[string]any{"name": d.Name, "version": d.Version, "download_url": d.DownloadURL, "sha256": d.SHA256}) } + return InstallManagedSkills(ctx, logger, root, raw) } diff --git a/apps/daemon/internal/agent/claudecode/install_concurrency_test.go b/apps/daemon/internal/agent/managedskills/concurrency_test.go similarity index 78% rename from apps/daemon/internal/agent/claudecode/install_concurrency_test.go rename to apps/daemon/internal/agent/managedskills/concurrency_test.go index ee75abfb3..dd2f40255 100644 --- a/apps/daemon/internal/agent/claudecode/install_concurrency_test.go +++ b/apps/daemon/internal/agent/managedskills/concurrency_test.go @@ -1,4 +1,4 @@ -package claudecode +package managedskills import ( "context" @@ -13,27 +13,9 @@ import ( ) func installConcurrentFixture(ctx context.Context, kind, root, url string, body []byte) error { - var dirs, warnings []string - var err error - if kind == "plugin" { - var result PluginInstallResult - result, err = installPlugins(ctx, discardLogger(), root, []pluginDescriptor{ - {Name: "fixture", Version: "1.0.0", DownloadURL: url, SHA256: sha256Hex(body)}, - }) - dirs, warnings = result.PluginDirs, result.Warnings - } else { - var result SkillInstallResult - if kind == "managed" { - result, err = InstallManagedSkills(ctx, discardLogger(), root, []any{map[string]any{ - "name": "fixture", "version": "1.0.0", "download_url": url, "sha256": sha256Hex(body), - }}) - } else { - result, err = installSkillsAtRoot(ctx, discardLogger(), root, []skillDescriptor{ - {Name: "fixture", Version: "1.0.0", DownloadURL: url, SHA256: sha256Hex(body)}, - }, "skills") - } - dirs, warnings = result.SkillDirs, result.Warnings - } + result, err := InstallManagedSkills(ctx, discardLogger(), root, []any{map[string]any{"name": "fixture", "version": "1.0.0", "download_url": url, "sha256": sha256Hex(body)}}) + dirs, warnings := result.SkillDirs, result.Warnings + if err != nil { return err } @@ -51,7 +33,7 @@ func installConcurrentFixture(ctx context.Context, kind, root, url string, body } func TestInstallsConcurrentSameRoot(t *testing.T) { - for _, kind := range []string{"plugin", "skill", "managed"} { + for _, kind := range []string{"managed"} { t.Run(kind, func(t *testing.T) { body := buildPluginZipBytes(t, []pluginZipFile{{Name: "SKILL.md", Body: "Complete fixture contents.\n"}}) var calls atomic.Int32 @@ -86,7 +68,7 @@ func TestInstallsConcurrentSameRoot(t *testing.T) { } func TestInstallWaitCancellation(t *testing.T) { - for _, kind := range []string{"plugin", "skill", "managed"} { + for _, kind := range []string{"managed"} { t.Run(kind, func(t *testing.T) { body := buildPluginZipBytes(t, []pluginZipFile{{Name: "SKILL.md", Body: "Complete fixture contents.\n"}}) started, release := make(chan struct{}, 2), make(chan struct{}) @@ -144,7 +126,7 @@ func TestInstallsIndependentRootsRemainConcurrent(t *testing.T) { defer cancel() results := make(chan error, 2) for _, root := range []string{t.TempDir(), t.TempDir()} { - go func() { results <- installConcurrentFixture(ctx, "plugin", root, srv.URL, body) }() + go func() { results <- installConcurrentFixture(ctx, "managed", root, srv.URL, body) }() } for range 2 { select { diff --git a/apps/daemon/internal/agent/claudecode/skills.go b/apps/daemon/internal/agent/managedskills/skills.go similarity index 80% rename from apps/daemon/internal/agent/claudecode/skills.go rename to apps/daemon/internal/agent/managedskills/skills.go index 374d61f64..3f052ca1c 100644 --- a/apps/daemon/internal/agent/claudecode/skills.go +++ b/apps/daemon/internal/agent/managedskills/skills.go @@ -1,4 +1,4 @@ -package claudecode +package managedskills import ( "context" @@ -16,7 +16,7 @@ import ( ) // skillDescriptor is the daemon-side view of one server-sent skill entry -// under agent_options["skills"]. Wire-identical to pluginDescriptor. +// under agent_options["skills"]. type skillDescriptor struct { Name string Version string @@ -24,32 +24,12 @@ type skillDescriptor struct { SHA256 string } -// SkillInstallResult carries installed directories and warnings. Claude Code -// auto-scans its project root; other adapters register the returned root. +// SkillInstallResult carries installed directories and warnings. type SkillInstallResult struct { SkillDirs []string Warnings []string } -// installSkills materialises every skill under -// /.claude/skills//. Pipeline mirrors installPlugins; -// only the target subdir differs (Claude Code auto-registers skills -// from that path). -func installSkills( - ctx context.Context, - logger *slog.Logger, - workDir string, - skills []skillDescriptor, -) (SkillInstallResult, error) { - if len(skills) == 0 { - return SkillInstallResult{}, nil - } - if strings.TrimSpace(workDir) == "" { - return SkillInstallResult{}, errors.New("claudecode skills: workDir is required") - } - return installSkillsAtRoot(ctx, logger, filepath.Join(workDir, ".claude", "skills"), skills, "claudecode skills") -} - // InstallManagedSkills decodes the portable agent_options["skills"] payload, // materializes it below root, and removes entries that are no longer active. func InstallManagedSkills(ctx context.Context, logger *slog.Logger, root string, raw any) (SkillInstallResult, error) { @@ -73,21 +53,6 @@ func InstallManagedSkills(ctx context.Context, logger *slog.Logger, root string, return result, nil } -func installSkillsAtRoot( - ctx context.Context, - logger *slog.Logger, - root string, - skills []skillDescriptor, - logLabel string, -) (SkillInstallResult, error) { - unlock, err := installroot.Lock(ctx, root) - if err != nil { - return SkillInstallResult{}, err - } - defer unlock() - return installSkillsAtRootLocked(ctx, logger, root, skills, logLabel) -} - func installSkillsAtRootLocked( ctx context.Context, logger *slog.Logger, @@ -121,7 +86,7 @@ func installSkillsAtRootLocked( continue } - // Same timeout / cap as plugins — they share the install pipeline. + // Bound each archive download and extraction. perCtx, cancel := context.WithTimeout(ctx, pluginInstallTimeout) err := installOneSkill(perCtx, logger, root, dir, cacheKey, expectedKey, s, logLabel) cancel() @@ -166,10 +131,7 @@ func pruneManagedSkills(root string, activeDirs []string) error { return nil } -// installOneSkill: same shape as installOnePlugin, only target dir differs. -// Reuses fetchPluginZip / verifyPluginSHA256FromFD / extractPluginZipFromFD -// — the helpers are skill-agnostic and applying them to skill zips keeps -// the path-traversal / TOCTOU / SHA256 defences identical. +// installOneSkill downloads, verifies and extracts one skill archive. func installOneSkill( ctx context.Context, logger *slog.Logger, @@ -222,8 +184,7 @@ func installOneSkill( return nil } -// decodeSkillDescriptors converts agent_options["skills"] into typed -// descriptors. Mirrors decodePluginDescriptors. +// decodeSkillDescriptors converts agent_options["skills"] into typed descriptors. func decodeSkillDescriptors(raw any) ([]skillDescriptor, []string) { if raw == nil { return nil, nil diff --git a/apps/daemon/internal/agent/claudecode/skills_test.go b/apps/daemon/internal/agent/managedskills/skills_test.go similarity index 87% rename from apps/daemon/internal/agent/claudecode/skills_test.go rename to apps/daemon/internal/agent/managedskills/skills_test.go index 58cbcbe80..f2977f371 100644 --- a/apps/daemon/internal/agent/claudecode/skills_test.go +++ b/apps/daemon/internal/agent/managedskills/skills_test.go @@ -1,4 +1,4 @@ -package claudecode +package managedskills import ( "context" @@ -20,7 +20,7 @@ func TestInstallSkills_HappyPath_ExtractsAndStampsCacheKey(t *testing.T) { srv := startPluginServer(t, body) workDir := t.TempDir() - res, err := installSkills(context.Background(), discardLogger(), workDir, []skillDescriptor{ + res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, }) if err != nil { @@ -30,7 +30,7 @@ func TestInstallSkills_HappyPath_ExtractsAndStampsCacheKey(t *testing.T) { t.Fatalf("unexpected warnings: %v", res.Warnings) } - dir := filepath.Join(workDir, ".claude", "skills", "code-review") + dir := filepath.Join(workDir, "code-review") if len(res.SkillDirs) != 1 || res.SkillDirs[0] != dir { t.Fatalf("skill dirs = %v, want [%s]", res.SkillDirs, dir) } @@ -56,14 +56,14 @@ func TestInstallSkills_CacheHitSkipsDownload(t *testing.T) { desc := []skillDescriptor{ {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, } - if _, err := installSkills(context.Background(), discardLogger(), workDir, desc); err != nil { + if _, err := installSkillsForTest(context.Background(), discardLogger(), workDir, desc); err != nil { t.Fatalf("first install: %v", err) } hitsAfterFirst := srv.Hits() if hitsAfterFirst != 1 { t.Fatalf("first install hits = %d, want 1", hitsAfterFirst) } - second, err := installSkills(context.Background(), discardLogger(), workDir, desc) + second, err := installSkillsForTest(context.Background(), discardLogger(), workDir, desc) if err != nil { t.Fatalf("second install: %v", err) } @@ -113,7 +113,7 @@ func TestInstallSkills_SHA256MismatchDemotesToWarning(t *testing.T) { srv := startPluginServer(t, body) workDir := t.TempDir() - res, err := installSkills(context.Background(), discardLogger(), workDir, []skillDescriptor{ + res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, []skillDescriptor{ // Wrong sha256 (all-zero pattern is 64 hex chars, never matches body) {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: "0000000000000000000000000000000000000000000000000000000000000000"}, }) @@ -123,7 +123,7 @@ func TestInstallSkills_SHA256MismatchDemotesToWarning(t *testing.T) { if len(res.Warnings) != 1 { t.Fatalf("want 1 warning, got %d: %v", len(res.Warnings), res.Warnings) } - if _, err := os.Stat(filepath.Join(workDir, ".claude", "skills", "code-review", "SKILL.md")); err == nil { + if _, err := os.Stat(filepath.Join(workDir, "code-review", "SKILL.md")); err == nil { t.Fatal("SKILL.md should not exist after sha mismatch") } } @@ -131,7 +131,7 @@ func TestInstallSkills_SHA256MismatchDemotesToWarning(t *testing.T) { func TestInstallSkills_EmptyListIsNoop(t *testing.T) { t.Parallel() workDir := t.TempDir() - res, err := installSkills(context.Background(), discardLogger(), workDir, nil) + res, err := installSkillsForTest(context.Background(), discardLogger(), workDir, nil) if err != nil { t.Fatalf("empty install: %v", err) } diff --git a/apps/daemon/internal/agent/mcode/declaration.go b/apps/daemon/internal/agent/mcode/declaration.go new file mode 100644 index 000000000..bfab385d3 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/declaration.go @@ -0,0 +1,88 @@ +package mcode + +import ( + "context" + "fmt" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + configuration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" +) + +// Declaration owns MiniMax Code discovery, configuration and execution factories. +var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{Kind: "mcode", Capabilities: proto.AgentKindCapabilities{ + SubagentObservations: proto.CapabilityUnsupported, + Streaming: proto.CapabilitySupported, + Permissions: proto.CapabilitySupported, + Usage: proto.CapabilityUnsupported, + Resume: proto.CapabilitySupported, + NativeSessionRecovery: proto.CapabilityUnsupported, + WorkspaceAuthoring: proto.CapabilitySupported, + Steering: proto.CapabilityUnsupported, + MessageItems: proto.CapabilityUnsupported, + ToolObservations: proto.CapabilityUnsupported, + EnvironmentNone: proto.CapabilityUnsupported, + LocalEnvironment: proto.CapabilityUnsupported, + Preparation: proto.CapabilityUnsupported, + WorkspaceReadPreparation: proto.CapabilityUnsupported, + WorkspaceOutputExport: proto.CapabilityUnsupported, + ProgrammaticToolCallingDisable: proto.CapabilityUnsupported, + WebSearchControl: proto.CapabilityUnsupported, + ExecutionControls: proto.CapabilityUnsupported, + TextVerbosity: proto.CapabilityUnsupported, + StructuredOutput: proto.CapabilityUnsupported, + ToolSearch: proto.CapabilityUnsupported, + MessageImages: proto.CapabilityUnsupported, + FunctionResultImages: proto.CapabilityUnsupported, + SubagentControl: proto.CapabilityUnsupported, + DurableInputReceipts: proto.CapabilityUnsupported, + DurableTurns: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilityUnsupported, + MCPHTTPTools: proto.CapabilityUnsupported, + MCPHTTPRequired: proto.CapabilityUnsupported, + MCPHTTPBearerAuth: proto.CapabilityUnsupported, +}}, Configuration: configuration.Configuration(), Discover: discover} + +func discover(ctx context.Context, options agent.DiscoveryOptions, info proto.SupportedAgentKind) *agent.Runtime { + return discoverWithCheck(ctx, options, info, CheckCLIAvailable) +} +func discoverWithCheck(parent context.Context, options agent.DiscoveryOptions, result proto.SupportedAgentKind, check func(context.Context, string) (string, error)) *agent.Runtime { + runtime := &agent.Runtime{Info: result, Session: Factory, SessionCapabilityContext: true, ExecutorCapabilityContext: true} + + ctx, cancel := context.WithTimeout(parent, 15*time.Second) + defer cancel() + version, err := check(ctx, "") + if err != nil { + fmt.Fprintf(options.Stderr, "oac-daemon: mcode unavailable: %v\n Install: npm install -g @minimax-ai/code@0.4.12\n", err) + return runtime + } + result.Available, result.Version = true, version + if SupportsExecution(version) { + result.Capabilities.Steering = proto.CapabilitySupported + result.Capabilities.DurableTurns = proto.CapabilitySupported + result.Capabilities.DurableInputReceipts = proto.CapabilitySupported + result.Capabilities.ExecutionControls = proto.CapabilitySupported + result.Capabilities.ProgrammaticToolCallingDisable = proto.CapabilitySupported + result.Capabilities.ToolObservations = proto.CapabilitySupported + result.Capabilities.SubagentControl = proto.CapabilitySupported + // Native preparation verifies the applied admission/tool profile before input. + result.Capabilities.SubagentObservations = proto.CapabilitySupported + result.Capabilities.EnvironmentNone = proto.CapabilitySupported + result.Capabilities.MCPHTTPTools = proto.CapabilitySupported + result.Capabilities.MCPHTTPBearerAuth = proto.CapabilitySupported + } + runtime.Info = result + workspace := discoverWorkspace(parent, options, runtime) + if runtime.Info.Available { + runtime.Executor = NewExecutorFactory(workspace) + } + if workspace != nil { + runtime.SessionCapabilityContext = false + runtime.Info.Capabilities.WorkspaceAuthoring = proto.CapabilityUnsupported + runtime.Preparation = NewPreparationFactory(*workspace) + runtime.WorkspaceReadPreparation = true + } + fmt.Fprintf(options.Stdout, "mcode preflight ok (%s)\n", version) + return runtime +} diff --git a/apps/daemon/internal/agent/mcode/declaration_test.go b/apps/daemon/internal/agent/mcode/declaration_test.go new file mode 100644 index 000000000..ab845b1c2 --- /dev/null +++ b/apps/daemon/internal/agent/mcode/declaration_test.go @@ -0,0 +1,53 @@ +package mcode + +import ( + "context" + "io" + "reflect" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" +) + +func TestMCodeExecutionOptInIsVersionBound(t *testing.T) { + for _, tc := range []struct { + enabled, version string + qualified bool + }{{"", "0.4.12", false}, {"1", "0.3.11", false}, {"1", "0.4.12", true}} { + t.Run(tc.enabled+"/"+tc.version, func(t *testing.T) { + t.Setenv("OAC_RUNTIME_MCODE_AGENTS_API", tc.enabled) + rc := agent.DiscoveryOptions{Stdout: io.Discard, Stderr: io.Discard} + runtime := discoverWithCheck(t.Context(), rc, Declaration.Info, func(context.Context, string) (string, error) { return tc.version, nil }) + if runtime.Executor == nil || runtime.Preparation != nil || !runtime.SessionCapabilityContext || !runtime.ExecutorCapabilityContext { + t.Fatalf("factories: %+v", runtime) + } + info := runtime.Info + if !info.Available || info.Capabilities.EnvironmentNone.IsSupported() != tc.qualified || info.Capabilities.DurableInputReceipts.IsSupported() != tc.qualified || info.Capabilities.SubagentObservations.IsSupported() != tc.qualified { + t.Fatalf("capabilities=%+v", info.Capabilities) + } + if info.Capabilities.NativeSessionRecovery.IsSupported() || info.Capabilities.LocalEnvironment.IsSupported() || info.Capabilities.FunctionTools.IsSupported() { + t.Fatal("unqualified capability advertised") + } + }) + } +} + +// The declaration must retain the complete baseline capability descriptor. +func TestDeclaredCapabilityBaseline(t *testing.T) { + expected := map[string]bool{"Streaming": true, "Permissions": true, "Resume": true, "WorkspaceAuthoring": true} + value := reflect.ValueOf(Declaration.Info.Capabilities) + for i := 0; i < value.NumField(); i++ { + name := value.Type().Field(i).Name + want := proto.CapabilityUnsupported + if expected[name] { + want = proto.CapabilitySupported + } + if got := value.Field(i).Interface(); got != want { + t.Errorf("%s = %v, want %v", name, got, want) + } + } + if err := Declaration.Info.ValidateDeclaration(); err != nil { + t.Fatal(err) + } +} diff --git a/apps/daemon/internal/cli/mcode_workspace.go b/apps/daemon/internal/agent/mcode/discovery_workspace.go similarity index 65% rename from apps/daemon/internal/cli/mcode_workspace.go rename to apps/daemon/internal/agent/mcode/discovery_workspace.go index ee634d6af..4438aa643 100644 --- a/apps/daemon/internal/cli/mcode_workspace.go +++ b/apps/daemon/internal/agent/mcode/discovery_workspace.go @@ -1,4 +1,4 @@ -package cli +package mcode import ( "context" @@ -7,34 +7,34 @@ import ( "os/exec" "path/filepath" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func discoverMCodeWorkspace(parent context.Context, rc *runContext, discovery *agentCLIDiscovery) { +func discoverWorkspace(parent context.Context, options agent.DiscoveryOptions, runtime *agent.Runtime) *WorkspaceConfig { fail := func(err error) { - discovery.MCode.Available = false - fmt.Fprintf(rc.stderr, "oac-daemon: mcode workspace unavailable: %v\n", err) + runtime.Info.Available = false + fmt.Fprintf(options.Stderr, "oac-daemon: mcode workspace unavailable: %v\n", err) } binding, err := localworkspace.Load() if err != nil { fail(err) - return + return nil } if binding == nil { - return + return nil } - if !discovery.MCode.Available || !mcode.SupportsExecution(discovery.MCode.Version) { + if !runtime.Info.Available || !SupportsExecution(runtime.Info.Version) { fail(fmt.Errorf("local execution requires the qualified native version")) - return + return nil } root, err := paths.Root() if err != nil { fail(err) - return + return nil } node := os.Getenv("OAC_RUNTIME_MCODE_NODE") if node == "" { @@ -43,34 +43,35 @@ func discoverMCodeWorkspace(parent context.Context, rc *runContext, discovery *a node, err = exec.LookPath(node) if err != nil { fail(err) - return + return nil } node, err = filepath.Abs(node) if err != nil { fail(err) - return + return nil } binary, err := exec.LookPath(binpath.MCode()) if err != nil { fail(err) - return + return nil } binary, err = filepath.Abs(binary) if err != nil { fail(err) - return + return nil } - c, err := mcode.ConfigureLocal(binary, node, os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"), root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) + c, err := ConfigureLocal(binary, node, os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"), root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) if err == nil { - err = mcode.CheckWorkspace(parent, c) + err = CheckWorkspace(parent, c) } if err != nil { fail(err) - return + return nil } - discovery.MCodeWorkspace = &c - caps := &discovery.MCode.Capabilities + + caps := &runtime.Info.Capabilities caps.EnvironmentNone = proto.CapabilityUnsupported caps.Preparation, caps.LocalEnvironment = proto.CapabilitySupported, proto.CapabilitySupported caps.WorkspaceReadPreparation = proto.CapabilitySupported + return &c } diff --git a/apps/daemon/internal/agent/mcode/options.go b/apps/daemon/internal/agent/mcode/options.go index fef178065..d2cbc5192 100644 --- a/apps/daemon/internal/agent/mcode/options.go +++ b/apps/daemon/internal/agent/mcode/options.go @@ -11,7 +11,7 @@ import ( "strings" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/managedskills" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/mcode" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" @@ -58,7 +58,7 @@ func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayloa } } if managedSkills { - installed, err := claudecode.InstallManagedSkills(ctx, log.With("component", "mcode"), root, req.AgentOptions["skills"]) + installed, err := managedskills.InstallManagedSkills(ctx, log.With("component", "mcode"), root, req.AgentOptions["skills"]) if err != nil { return result, err } diff --git a/apps/daemon/internal/agent/opencode/export_test.go b/apps/daemon/internal/agent/opencode/export_test.go deleted file mode 100644 index 07b9bdc1b..000000000 --- a/apps/daemon/internal/agent/opencode/export_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package opencode - -import ( - "context" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type SessionConfigForTest struct { - OpenCodeBinary string - ExtraArgs []string - KillTimeout time.Duration -} - -func NewSessionForTest(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg SessionConfigForTest) (*Session, error) { - return newSession(ctx, req, out, sessionConfig{ - opencodeBinary: cfg.OpenCodeBinary, - extraArgs: cfg.ExtraArgs, - killTimeout: cfg.KillTimeout, - }) -} - -type Translator translator - -type Translation = translation - -func NewTranslatorForTest(runID string) *Translator { return (*Translator)(newTranslator(runID)) } - -func (t *Translator) Translate(line []byte) (Translation, error) { - return (*translator)(t).Translate(line) -} - -func (t *Translator) TerminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { - return (*translator)(t).terminalEnvelopes(waitErr, stderr, cancelled) -} diff --git a/apps/daemon/internal/agent/opencode/options.go b/apps/daemon/internal/agent/opencode/options.go deleted file mode 100644 index 2145d1170..000000000 --- a/apps/daemon/internal/agent/opencode/options.go +++ /dev/null @@ -1,312 +0,0 @@ -package opencode - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" -) - -// BuildResult is the opencode CLI launch plan for one prompt. -type BuildResult struct { - Args []string - Env []string - WorkDir string - ModelSelector string - Cleanup func() -} - -// BuildArgs translates the daemon prompt_request into an `opencode -// run` invocation. -func BuildArgs(runID, prompt, workDir string, opts map[string]any) (BuildResult, error) { - cleanup := func() {} - result := BuildResult{Cleanup: cleanup} - - resolvedWorkDir, err := resolveWorkDir(workDir) - if err != nil { - return result, err - } - - promptText, err := buildPrompt(prompt, opts) - if err != nil { - return result, err - } - - args := []string{"run", "--format", "json"} - if resolvedWorkDir != "" { - args = append(args, "--dir", resolvedWorkDir) - } - if model := firstString(opts, "model_selector", "model"); model != "" { - args = append(args, "--model", model) - result.ModelSelector = model - } - if agent := stringOpt(opts, "agent"); agent != "" { - args = append(args, "--agent", agent) - } - if boolOpt(opts, "dangerously_skip_permissions") { - args = append(args, "--dangerously-skip-permissions") - } - args = append(args, promptText) - - env, err := buildEnv(opts) - if err != nil { - return result, err - } - - rawConfig := stringOpt(opts, "opencode_json") - if servers, ok := opts["mcp_servers"]; ok && servers != nil { - rawConfig, err = mergeMCPConfig(rawConfig, servers) - if err != nil { - return result, err - } - } - if rawConfig != "" { - configHome, scratchCleanup, err := writeConfigHome(runID, rawConfig) - if err != nil { - return result, err - } - cleanup = scratchCleanup - env = append(env, "XDG_CONFIG_HOME="+configHome) - } - - result.Args = args - result.Env = env - result.WorkDir = resolvedWorkDir - result.Cleanup = cleanup - return result, nil -} - -func mergeMCPConfig(rawConfig string, rawServers any) (string, error) { - config := map[string]any{} - if strings.TrimSpace(rawConfig) != "" { - if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { - return "", fmt.Errorf("opencode: opencode_json must be valid JSON: %w", err) - } - } - servers, ok := rawServers.(map[string]any) - if !ok { - return "", fmt.Errorf("opencode: mcp_servers must be object, got %T", rawServers) - } - mcp, _ := config["mcp"].(map[string]any) - if mcp == nil { - mcp = map[string]any{} - } - for name, raw := range servers { - entry, ok := raw.(map[string]any) - if !ok { - return "", fmt.Errorf("opencode: mcp_servers[%q] must be object, got %T", name, raw) - } - enabled := true - if value, ok := entry["enabled"].(bool); ok { - enabled = value - } - if remoteURL, ok := entry["url"].(string); ok && strings.TrimSpace(remoteURL) != "" { - remote := map[string]any{ - "type": "remote", - "url": strings.TrimSpace(remoteURL), - "enabled": enabled, - } - if headers := stringMap(entry["headers"]); len(headers) > 0 { - remote["headers"] = headers - } - mcp[name] = remote - continue - } - command, ok := entry["command"].(string) - if !ok || strings.TrimSpace(command) == "" { - return "", fmt.Errorf("opencode: mcp_servers[%q] missing command or url", name) - } - commandParts := []string{command} - if args, ok := entry["args"].([]any); ok { - for _, arg := range args { - if value, ok := arg.(string); ok { - commandParts = append(commandParts, value) - } - } - } else if args, ok := entry["args"].([]string); ok { - commandParts = append(commandParts, args...) - } - local := map[string]any{"type": "local", "command": commandParts, "enabled": enabled} - if env, ok := entry["env"].(map[string]any); ok && len(env) > 0 { - local["environment"] = env - } else if env, ok := entry["env"].(map[string]string); ok && len(env) > 0 { - local["environment"] = env - } - mcp[name] = local - } - if len(mcp) > 0 { - config["mcp"] = mcp - } - encoded, err := json.Marshal(config) - if err != nil { - return "", fmt.Errorf("opencode: marshal merged MCP config: %w", err) - } - return string(encoded), nil -} - -func stringMap(value any) map[string]string { - switch typed := value.(type) { - case map[string]string: - return typed - case map[string]any: - result := make(map[string]string, len(typed)) - for key, raw := range typed { - if text, ok := raw.(string); ok { - result[key] = text - } - } - return result - default: - return nil - } -} - -func resolveWorkDir(input string) (string, error) { - trimmed := strings.TrimSpace(input) - if trimmed == "" { - return "", nil - } - var abs string - switch { - case strings.HasPrefix(trimmed, "~/"): - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("opencode: resolve home dir: %w", err) - } - abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) - case filepath.IsAbs(trimmed): - abs = trimmed - default: - return "", fmt.Errorf("opencode: work_dir must be absolute or start with ~/, got %q", trimmed) - } - // Match claudecode + codex: mkdir -p so the wizard's "Created if - // it does not exist" hint actually holds across engines. - if err := os.MkdirAll(abs, 0o755); err != nil { - return "", fmt.Errorf("opencode: mkdir work_dir %s: %w", abs, err) - } - return abs, nil -} - -func buildPrompt(prompt string, opts map[string]any) (string, error) { - prompt = strings.TrimSpace(prompt) - if prompt == "" { - return "", fmt.Errorf("opencode: empty prompt") - } - systemPrompt := stringOpt(opts, "system_prompt") - if override := stringOpt(opts, "override_system_prompt"); override != "" { - systemPrompt = override - } - if systemPrompt == "" { - return prompt, nil - } - return systemPrompt + "\n\n" + prompt, nil -} - -func buildEnv(opts map[string]any) ([]string, error) { - env := []string{ - "DISABLE_TELEMETRY=1", - } - raw, ok := opts["env"] - if !ok || raw == nil { - return env, nil - } - envMap, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("opencode.BuildArgs: env must be object, got %T", raw) - } - keys := make([]string, 0, len(envMap)) - for k := range envMap { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - s, ok := envMap[k].(string) - if !ok { - return nil, fmt.Errorf("opencode.BuildArgs: env[%q] must be string, got %T", k, envMap[k]) - } - env = append(env, k+"="+s) - } - return env, nil -} - -func writeConfigHome(runID, raw string) (string, func(), error) { - if strings.TrimSpace(runID) == "" { - return "", func() {}, fmt.Errorf("opencode: runID required for scratch config") - } - var parsed any - if err := json.Unmarshal([]byte(raw), &parsed); err != nil { - return "", func() {}, fmt.Errorf("opencode: opencode_json must be valid JSON: %w", err) - } - root, err := paths.Root() - if err != nil { - return "", func() {}, err - } - scratchRoot := filepath.Join(root, "daemon", "scratch", safeRunID(runID)) - configHome := filepath.Join(scratchRoot, "config-home") - opencodeDir := filepath.Join(configHome, "opencode") - if err := os.MkdirAll(opencodeDir, 0o700); err != nil { - return "", func() {}, fmt.Errorf("opencode: create config dir %s: %w", opencodeDir, err) - } - configPath := filepath.Join(opencodeDir, "opencode.json") - if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { - return "", func() {}, fmt.Errorf("opencode: write %s: %w", configPath, err) - } - cleanup := func() { _ = os.RemoveAll(scratchRoot) } - return configHome, cleanup, nil -} - -func safeRunID(runID string) string { - var b strings.Builder - for _, r := range runID { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { - b.WriteRune(r) - } else { - b.WriteByte('_') - } - } - out := b.String() - if out == "" { - return "run" - } - return out -} - -func firstString(opts map[string]any, keys ...string) string { - for _, key := range keys { - if v := stringOpt(opts, key); v != "" { - return v - } - } - return "" -} - -func stringOpt(opts map[string]any, key string) string { - if opts == nil { - return "" - } - v, ok := opts[key] - if !ok || v == nil { - return "" - } - s, ok := v.(string) - if !ok { - return "" - } - return strings.TrimSpace(s) -} - -func boolOpt(opts map[string]any, key string) bool { - if opts == nil { - return false - } - v, ok := opts[key] - if !ok || v == nil { - return false - } - b, ok := v.(bool) - return ok && b -} diff --git a/apps/daemon/internal/agent/opencode/options_test.go b/apps/daemon/internal/agent/opencode/options_test.go deleted file mode 100644 index 3e1ecec29..000000000 --- a/apps/daemon/internal/agent/opencode/options_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package opencode_test - -import ( - "encoding/json" - "os" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" -) - -func TestBuildArgsUsesOpenCodeRunJSONAndWorkdir(t *testing.T) { - res, err := opencode.BuildArgs("run-1", "hello", os.TempDir(), map[string]any{ - "model_selector": "anthropic/claude-opus-4-7", - "agent": "build", - }) - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--format", "json") || !slices.Contains(res.Args, "run") { - t.Fatalf("args missing run/json: %v", res.Args) - } - if !containsPair(res.Args, "--dir", os.TempDir()) { - t.Fatalf("args missing --dir temp: %v", res.Args) - } - if !containsPair(res.Args, "--model", "anthropic/claude-opus-4-7") { - t.Fatalf("args missing model selector: %v", res.Args) - } - if !containsPair(res.Args, "--agent", "build") { - t.Fatalf("args missing agent: %v", res.Args) - } - if got := res.Args[len(res.Args)-1]; got != "hello" { - t.Fatalf("last arg prompt = %q, want hello; args=%v", got, res.Args) - } -} - -func TestBuildArgsRejectsRelativeWorkdir(t *testing.T) { - _, err := opencode.BuildArgs("run-1", "hello", "./relative", nil) - if err == nil || !strings.Contains(err.Error(), "absolute") { - t.Fatalf("BuildArgs relative err = %v, want absolute-path error", err) - } -} - -// TestBuildArgsCreatesMissingWorkdir: align with claudecode + codex — -// a non-existent absolute path is mkdir -p'd. Pinning this keeps the -// wizard's "Created if it does not exist" hint honest across engines. -func TestBuildArgsCreatesMissingWorkdir(t *testing.T) { - target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") - res, err := opencode.BuildArgs("run-1", "hello", target, nil) - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--dir", target) { - t.Fatalf("args missing --dir %s: %v", target, res.Args) - } - info, err := os.Stat(target) - if err != nil { - t.Fatalf("stat target: %v", err) - } - if !info.IsDir() { - t.Fatalf("target %q is not a directory", target) - } -} - -func TestBuildArgsWritesManagedConfigUnderOpenAgentCoreHome(t *testing.T) { - home := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", home) - res, err := opencode.BuildArgs("run/id", "hello", "", map[string]any{ - "opencode_json": `{"provider":{},"permission":{"*":"allow"}}`, - }) - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - configHome := envValue(res.Env, "XDG_CONFIG_HOME") - if configHome == "" { - t.Fatalf("XDG_CONFIG_HOME missing in env: %v", res.Env) - } - wantPrefix := filepath.Join(home, "daemon", "scratch", "run_id", "config-home") - if configHome != wantPrefix { - t.Fatalf("configHome = %q, want %q", configHome, wantPrefix) - } - configPath := filepath.Join(configHome, "opencode", "opencode.json") - if _, err := os.Stat(configPath); err != nil { - t.Fatalf("expected opencode.json at %s: %v", configPath, err) - } - res.Cleanup() - if _, err := os.Stat(filepath.Join(home, "daemon", "scratch", "run_id")); !os.IsNotExist(err) { - t.Fatalf("scratch dir still exists after cleanup: %v", err) - } -} - -func TestBuildArgsMergesLocalAndRemoteMCPServers(t *testing.T) { - home := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", home) - res, err := opencode.BuildArgs("run-mcp", "hello", "", map[string]any{ - "opencode_json": `{"provider":{}}`, - "mcp_servers": map[string]any{ - "local": map[string]any{"command": "npx", "args": []any{"-y", "pkg"}}, - "docs": map[string]any{ - "url": "https://docs.example.com/mcp", - "headers": map[string]any{"Authorization": "Bearer token"}, - }, - }, - }) - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - path := filepath.Join(envValue(res.Env, "XDG_CONFIG_HOME"), "opencode", "opencode.json") - body, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - var config map[string]any - if err := json.Unmarshal(body, &config); err != nil { - t.Fatal(err) - } - mcp := config["mcp"].(map[string]any) - remote := mcp["docs"].(map[string]any) - if remote["type"] != "remote" || remote["url"] != "https://docs.example.com/mcp" { - t.Fatalf("remote = %+v", remote) - } - headers := remote["headers"].(map[string]any) - if headers["Authorization"] != "Bearer token" { - t.Fatalf("headers = %+v", headers) - } - local := mcp["local"].(map[string]any) - if local["type"] != "local" { - t.Fatalf("local = %+v", local) - } -} - -func TestBuildArgsRejectsBadEnvShape(t *testing.T) { - _, err := opencode.BuildArgs("run-1", "hello", "", map[string]any{"env": map[string]any{"K": 1}}) - if err == nil || !strings.Contains(err.Error(), "env") { - t.Fatalf("BuildArgs env err = %v, want env shape error", err) - } -} - -func containsPair(args []string, flag, value string) bool { - for i, a := range args { - if a == flag && i+1 < len(args) && args[i+1] == value { - return true - } - } - return false -} - -func envValue(env []string, key string) string { - prefix := key + "=" - for _, item := range env { - if strings.HasPrefix(item, prefix) { - return strings.TrimPrefix(item, prefix) - } - } - return "" -} diff --git a/apps/daemon/internal/agent/opencode/parser.go b/apps/daemon/internal/agent/opencode/parser.go deleted file mode 100644 index f18081c3d..000000000 --- a/apps/daemon/internal/agent/opencode/parser.go +++ /dev/null @@ -1,296 +0,0 @@ -package opencode - -import ( - "bytes" - "encoding/json" - "fmt" - "strings" - "sync/atomic" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type translator struct { - runID string - seq atomic.Uint64 - - deltaBuf strings.Builder - plainBuf strings.Builder - rawLines []string - usage proto.Usage - stepUsage usageInfo - toolsSeen map[string]bool -} - -type translation struct { - Envelopes []proto.Envelope -} - -func newTranslator(runID string) *translator { return &translator{runID: runID} } - -func (t *translator) Translate(line []byte) (translation, error) { - line = bytes.TrimSpace(line) - if len(line) == 0 { - return translation{}, nil - } - t.rawLines = append(t.rawLines, string(line)) - - var head struct { - Type string `json:"type"` - Properties json.RawMessage `json:"properties"` - Part json.RawMessage `json:"part"` - } - if err := json.Unmarshal(line, &head); err != nil || head.Type == "" { - if t.plainBuf.Len() > 0 { - t.plainBuf.WriteByte('\n') - } - t.plainBuf.Write(line) - return translation{}, nil - } - - switch head.Type { - case "message.part.delta": - return t.translatePartDelta(head.Properties) - case "text": - return t.translateTextPart(head.Part) - case "tool_use": - return t.translateToolPart(head.Part) - case "message.updated", "message.updated.1": - t.captureUsage(head.Properties) - return translation{}, nil - case "step_finish": - t.capturePartUsage(head.Part) - return translation{}, nil - default: - t.captureGenericUsage(line) - return translation{}, nil - } -} - -func (t *translator) translateToolPart(raw json.RawMessage) (translation, error) { - var p struct { - Type string `json:"type"` - CallID string `json:"callID"` - Tool string `json:"tool"` - State struct { - Status string `json:"status"` - Input map[string]any `json:"input"` - Output string `json:"output"` - Error string `json:"error"` - } `json:"state"` - } - if err := json.Unmarshal(raw, &p); err != nil { - return translation{}, fmt.Errorf("opencode: parse tool part: %w", err) - } - if p.Type != "tool" || p.CallID == "" || p.Tool == "" || - (p.State.Status != "completed" && p.State.Status != "error") || t.toolsSeen[p.CallID] { - return translation{}, nil - } - result := map[string]any{"output": p.State.Output, "is_error": p.State.Status == "error"} - if p.State.Status == "error" { - result["output"] = p.State.Error - } - // JSON CLI tool parts arrive only after execution. Pair the call and - // result for existing trace consumers; neither frame requests approval. - call := proto.ToolCallPayload{ID: p.CallID, Name: p.Tool, Stage: "before", Args: p.State.Input} - before, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, call) - if err != nil { - return translation{}, err - } - call.Stage, call.Result = "after", result - after, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, call) - if err != nil { - return translation{}, err - } - if t.toolsSeen == nil { - t.toolsSeen = make(map[string]bool) - } - t.toolsSeen[p.CallID] = true - return translation{Envelopes: []proto.Envelope{before, after}}, nil -} - -func (t *translator) translatePartDelta(raw json.RawMessage) (translation, error) { - var p struct { - Field string `json:"field"` - Delta string `json:"delta"` - } - if err := json.Unmarshal(raw, &p); err != nil { - return translation{}, fmt.Errorf("opencode: parse message.part.delta: %w", err) - } - if p.Delta == "" || (p.Field != "" && p.Field != "text") { - return translation{}, nil - } - t.deltaBuf.WriteString(p.Delta) - env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: p.Delta, Sequence: t.seq.Add(1)}) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil -} - -func (t *translator) translateTextPart(raw json.RawMessage) (translation, error) { - var p struct { - Type string `json:"type"` - Text string `json:"text"` - } - if err := json.Unmarshal(raw, &p); err != nil { - return translation{}, fmt.Errorf("opencode: parse text part: %w", err) - } - if p.Text == "" || (p.Type != "" && p.Type != "text") { - return translation{}, nil - } - t.deltaBuf.WriteString(p.Text) - env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: p.Text, Sequence: t.seq.Add(1)}) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil -} - -func (t *translator) captureUsage(raw json.RawMessage) { - var p struct { - Info usageInfo `json:"info"` - } - if err := json.Unmarshal(raw, &p); err == nil { - t.mergeUsage(p.Info) - } -} - -func (t *translator) capturePartUsage(raw json.RawMessage) { - var p usageInfo - if err := json.Unmarshal(raw, &p); err == nil { - if p.Tokens.CacheRead == 0 { - p.Tokens.CacheRead = p.Tokens.Cache.Read - } - if p.Tokens.CacheWrite == 0 { - p.Tokens.CacheWrite = p.Tokens.Cache.Write - } - t.stepUsage.Tokens.Input += p.Tokens.Input - t.stepUsage.Tokens.Output += p.Tokens.Output - t.stepUsage.Tokens.Reasoning += p.Tokens.Reasoning - t.stepUsage.Tokens.CacheRead += p.Tokens.CacheRead - t.stepUsage.Tokens.CacheWrite += p.Tokens.CacheWrite - t.stepUsage.Tokens.Total += p.Tokens.Total - t.stepUsage.Cost += p.Cost - t.mergeUsage(t.stepUsage) - } -} - -func (t *translator) captureGenericUsage(raw json.RawMessage) { - var p struct { - Info usageInfo `json:"info"` - Tokens usageTokens `json:"tokens"` - Cost float64 `json:"cost"` - } - if err := json.Unmarshal(raw, &p); err != nil { - return - } - t.mergeUsage(p.Info) - if p.Tokens.Input != 0 || p.Tokens.Output != 0 || p.Cost != 0 { - t.mergeUsage(usageInfo{Tokens: p.Tokens, Cost: p.Cost}) - } -} - -type usageInfo struct { - Tokens usageTokens `json:"tokens"` - Cost float64 `json:"cost"` -} - -type usageTokens struct { - Input int32 `json:"input"` - Output int32 `json:"output"` - Reasoning int32 `json:"reasoning"` - CacheRead int32 `json:"cacheRead"` - CacheWrite int32 `json:"cacheWrite"` - Total int32 `json:"total"` - Cache struct { - Read int32 `json:"read"` - Write int32 `json:"write"` - } `json:"cache"` -} - -func (t *translator) mergeUsage(info usageInfo) { - if info.Tokens.CacheRead == 0 { - info.Tokens.CacheRead = info.Tokens.Cache.Read - } - if info.Tokens.CacheWrite == 0 { - info.Tokens.CacheWrite = info.Tokens.Cache.Write - } - if info.Tokens.Input != 0 { - t.usage.InputTokens = info.Tokens.Input - } - if info.Tokens.Output != 0 { - t.usage.OutputTokens = info.Tokens.Output - } - if info.Cost != 0 { - t.usage.CostUSD = info.Cost - } - if info.Tokens.Reasoning != 0 || info.Tokens.CacheRead != 0 || info.Tokens.CacheWrite != 0 || info.Tokens.Total != 0 { - if t.usage.Raw == nil { - t.usage.Raw = map[string]any{} - } - if info.Tokens.Reasoning != 0 { - t.usage.Raw["reasoning_tokens"] = info.Tokens.Reasoning - } - if info.Tokens.CacheRead != 0 { - t.usage.Raw["cache_read_tokens"] = info.Tokens.CacheRead - } - if info.Tokens.CacheWrite != 0 { - t.usage.Raw["cache_write_tokens"] = info.Tokens.CacheWrite - } - if info.Tokens.Total != 0 { - t.usage.Raw["total_tokens"] = info.Tokens.Total - } - } -} - -func (t *translator) terminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { - var envs []proto.Envelope - if t.plainBuf.Len() > 0 && t.deltaBuf.Len() == 0 { - delta := strings.TrimSpace(t.plainBuf.String()) - if delta != "" { - if env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: delta, Sequence: t.seq.Add(1)}); err == nil { - envs = append(envs, env) - } - t.deltaBuf.WriteString(delta) - } - } - usage := t.usage - usage.Provider = "opencode" - if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.CostUSD != 0 || usage.Raw != nil { - if env, err := proto.NewEnvelope(proto.TypeUsage, t.runID, proto.UsagePayload{Usage: usage}); err == nil { - envs = append(envs, env) - } - } - if waitErr != nil || cancelled { - msg := "opencode: subprocess exited without success" - if waitErr != nil { - msg = fmt.Sprintf("opencode: subprocess exited: %v", waitErr) - } - if strings.TrimSpace(stderr) != "" { - msg += ": " + truncate(strings.TrimSpace(stderr), 400) - } - if cancelled { - msg = "opencode: cancelled" - } - if env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: msg}); err == nil { - envs = append(envs, env) - } - } - content := strings.TrimSpace(t.deltaBuf.String()) - metadata := map[string]any{"connector_path": "opencode_run"} - if len(t.rawLines) > 0 { - metadata["opencode_raw_lines"] = t.rawLines - } - if env, err := proto.NewEnvelope(proto.TypeDone, t.runID, proto.DonePayload{Content: content, Transcript: strings.Join(t.rawLines, "\n"), Usage: usage, Metadata: metadata}); err == nil { - envs = append(envs, env) - } - return envs -} - -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max] -} diff --git a/apps/daemon/internal/agent/opencode/parser_test.go b/apps/daemon/internal/agent/opencode/parser_test.go deleted file mode 100644 index 8242fd865..000000000 --- a/apps/daemon/internal/agent/opencode/parser_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package opencode_test - -import ( - "encoding/json" - "errors" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestTranslatePartDeltaEmitsDeltaAndDone(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-1") - tx, err := tr.Translate([]byte(`{"type":"message.part.delta","properties":{"field":"text","delta":"hello"}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("delta envelopes = %#v", tx.Envelopes) - } - delta := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) - if delta.Delta != "hello" || delta.Sequence == 0 { - t.Fatalf("delta payload = %#v", delta) - } - envs := tr.TerminalEnvelopes(nil, "", false) - last := envs[len(envs)-1] - if last.Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done", last.Type) - } - done := decodePayload[proto.DonePayload](t, last) - if done.Content != "hello" || done.Metadata["connector_path"] != "opencode_run" { - t.Fatalf("done payload = %#v", done) - } -} - -func TestTranslateTextEventsEmitDeltasAndDone(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-text") - tx, err := tr.Translate([]byte(`{"type":"text","timestamp":1785838824775,"sessionID":"ses_1","part":{"id":"prt_1","messageID":"msg_1","sessionID":"ses_1","type":"text","text":"OK"}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("delta envelopes = %#v", tx.Envelopes) - } - delta := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) - if delta.Delta != "OK" || delta.Sequence == 0 { - t.Fatalf("delta payload = %#v", delta) - } - if _, err = tr.Translate([]byte(`{"type":"step_finish","part":{"id":"prt_2","type":"step-finish"}}`)); err != nil { - t.Fatalf("Translate step finish: %v", err) - } - tx, err = tr.Translate([]byte(`{"type":"text","timestamp":1785838824776,"sessionID":"ses_1","part":{"id":"prt_3","messageID":"msg_1","sessionID":"ses_1","type":"text","text":" again"}}`)) - if err != nil { - t.Fatalf("Translate second text: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("second delta envelopes = %#v", tx.Envelopes) - } - second := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) - if second.Delta != " again" || second.Sequence <= delta.Sequence { - t.Fatalf("second delta payload = %#v", second) - } - if _, err = tr.Translate([]byte(`{"type":"step_finish","part":{"id":"prt_4","type":"step-finish"}}`)); err != nil { - t.Fatalf("Translate second step finish: %v", err) - } - - envs := tr.TerminalEnvelopes(nil, "", false) - done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) - if done.Content != "OK again" { - t.Fatalf("done content = %q", done.Content) - } -} - -func TestTranslateCapturesUsage(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-u") - _, err := tr.Translate([]byte(`{"type":"message.updated","properties":{"info":{"cost":0.25,"tokens":{"input":10,"output":7,"reasoning":3,"cacheRead":2,"cacheWrite":1,"total":23}}}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - envs := tr.TerminalEnvelopes(nil, "", false) - var got *proto.UsagePayload - for _, env := range envs { - if env.Type == proto.TypeUsage { - payload := decodePayload[proto.UsagePayload](t, env) - got = &payload - } - } - if got == nil { - t.Fatalf("usage env missing: %#v", envs) - } - if got.Provider != "opencode" || got.InputTokens != 10 || got.OutputTokens != 7 || got.CostUSD != 0.25 { - t.Fatalf("usage = %#v", got) - } - if got.Raw["total_tokens"] != float64(23) { - t.Fatalf("usage raw = %#v", got.Raw) - } -} - -func TestTranslateStepFinishCapturesUsage(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-step-finish") - _, err := tr.Translate([]byte(`{"type":"step_finish","part":{"type":"step-finish","tokens":{"total":12576,"input":11803,"output":645,"reasoning":0,"cache":{"write":4,"read":128}},"cost":0.00432258}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - _, err = tr.Translate([]byte(`{"type":"step_finish","part":{"type":"step-finish","tokens":{"total":25,"input":20,"output":3,"reasoning":2,"cache":{"write":1,"read":4}},"cost":0.001}}`)) - if err != nil { - t.Fatalf("Translate second step: %v", err) - } - envs := tr.TerminalEnvelopes(nil, "", false) - var got *proto.UsagePayload - for _, env := range envs { - if env.Type == proto.TypeUsage { - payload := decodePayload[proto.UsagePayload](t, env) - got = &payload - } - } - if got == nil { - t.Fatalf("usage env missing: %#v", envs) - } - if got.InputTokens != 11823 || got.OutputTokens != 648 || got.CostUSD != 0.00532258 { - t.Fatalf("usage = %#v", got) - } - if got.Raw["total_tokens"] != float64(12601) || got.Raw["reasoning_tokens"] != float64(2) || got.Raw["cache_read_tokens"] != float64(132) || got.Raw["cache_write_tokens"] != float64(5) { - t.Fatalf("usage raw = %#v", got.Raw) - } -} - -func TestPlainOutputFallsBackToDelta(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-p") - _, _ = tr.Translate([]byte("plain output")) - envs := tr.TerminalEnvelopes(nil, "", false) - if len(envs) < 2 || envs[0].Type != proto.TypeDelta || envs[len(envs)-1].Type != proto.TypeDone { - t.Fatalf("plain terminal envs = %#v", envs) - } - done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) - if done.Content != "plain output" { - t.Fatalf("done content = %q", done.Content) - } -} - -func TestTerminalErrorIncludesStderr(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-e") - envs := tr.TerminalEnvelopes(errors.New("exit status 2"), "bad auth", false) - if len(envs) < 2 || envs[0].Type != proto.TypeError || envs[len(envs)-1].Type != proto.TypeDone { - t.Fatalf("error terminal envs = %#v", envs) - } - errPayload := decodePayload[proto.ErrorPayload](t, envs[0]) - if errPayload.Error == "" || !contains(errPayload.Error, "bad auth") { - t.Fatalf("error payload = %#v", errPayload) - } -} - -func decodePayload[T any](t *testing.T, env proto.Envelope) T { - t.Helper() - var out T - if err := json.Unmarshal(env.Payload, &out); err != nil { - t.Fatalf("decode %s payload: %v", env.Type, err) - } - return out -} - -func contains(s, sub string) bool { - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return sub == "" -} diff --git a/apps/daemon/internal/agent/opencode/parser_tools_test.go b/apps/daemon/internal/agent/opencode/parser_tools_test.go deleted file mode 100644 index b9492c719..000000000 --- a/apps/daemon/internal/agent/opencode/parser_tools_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package opencode_test - -import ( - "fmt" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestTranslateCompletedToolsPreservesTraceAndReply(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-tools") - for i, tool := range []string{"skill", "bash", "qa-service-desk_get_test_ticket", "read", "read"} { - id := fmt.Sprintf("call_%d", i) - line := []byte(fmt.Sprintf(`{"type":"tool_use","part":{"type":"tool","callID":%q,"tool":%q,"state":{"status":"completed","input":{"name":"qa-onboarding-zip","limit":3,"enabled":true},"output":"synthetic result"}}}`, id, tool)) - tx, err := tr.Translate(line) - if err != nil || len(tx.Envelopes) != 2 { - t.Fatalf("tool %s: envelopes=%#v err=%v", id, tx.Envelopes, err) - } - for j, stage := range []string{"before", "after"} { - env := tx.Envelopes[j] - call := decodePayload[proto.ToolCallPayload](t, env) - if env.Type != proto.TypeToolCall || env.ID != "run-tools" || call.ID != id || call.Name != tool || call.Stage != stage { - t.Fatalf("tool envelope=%#v payload=%#v", env, call) - } - if call.Args["name"] != "qa-onboarding-zip" || call.Args["limit"] != float64(3) || call.Args["enabled"] != true { - t.Fatalf("typed args = %#v", call.Args) - } - if stage == "before" && call.Result != nil { - t.Fatalf("call already has result: %#v", call.Result) - } - if stage == "after" && (call.Result["output"] != "synthetic result" || call.Result["is_error"] != false) { - t.Fatalf("tool result = %#v", call.Result) - } - } - duplicate, err := tr.Translate(line) - if err != nil || len(duplicate.Envelopes) != 0 { - t.Fatalf("duplicate call %s: %#v, %v", id, duplicate, err) - } - } - if _, err := tr.Translate([]byte(`{"type":"text","part":{"type":"text","text":"third working day"}}`)); err != nil { - t.Fatal(err) - } - if _, err := tr.Translate([]byte(`{"type":"step_finish","part":{"tokens":{"input":12,"output":4},"cost":0.01}}`)); err != nil { - t.Fatal(err) - } - envs := tr.TerminalEnvelopes(nil, "", false) - done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) - if done.Content != "third working day" || done.Usage.InputTokens != 12 || done.Usage.OutputTokens != 4 || done.Usage.CostUSD != 0.01 { - t.Fatalf("done = %#v", done) - } -} - -func TestTranslateFailedToolRemainsToolFailure(t *testing.T) { - tr := opencode.NewTranslatorForTest("run-error") - tx, err := tr.Translate([]byte(`{"type":"tool_use","part":{"type":"tool","callID":"call_error","tool":"read","state":{"status":"error","input":{"filePath":"/missing"},"error":"File not found"}}}`)) - if err != nil || len(tx.Envelopes) != 2 { - t.Fatalf("envelopes=%#v err=%v", tx.Envelopes, err) - } - result := decodePayload[proto.ToolCallPayload](t, tx.Envelopes[1]) - if result.Result["is_error"] != true || result.Result["output"] != "File not found" || result.Args["filePath"] != "/missing" { - t.Fatalf("failed tool = %#v", result) - } - // A recoverable tool error must not become a run error or contaminate text. - _, _ = tr.Translate([]byte(`{"type":"text","part":{"type":"text","text":"Please supply a valid path."}}`)) - for _, env := range tr.TerminalEnvelopes(nil, "", false) { - if env.Type == proto.TypeError { - t.Fatal("tool failure became run failure") - } - if env.Type == proto.TypeDone && decodePayload[proto.DonePayload](t, env).Content != "Please supply a valid path." { - t.Fatal("tool failure leaked into final text") - } - } -} - -func TestTranslateToolRequiresTerminalIdentity(t *testing.T) { - for _, part := range []string{ - `null`, - `{"type":"text","callID":"c","tool":"read","state":{"status":"completed"}}`, - `{"type":"tool","tool":"read","state":{"status":"completed"}}`, - `{"type":"tool","callID":"c","state":{"status":"completed"}}`, - `{"type":"tool","callID":"c","tool":"read","state":{"status":"pending"}}`, - `{"type":"tool","callID":"c","tool":"read","state":{"status":"running"}}`, - } { - tr := opencode.NewTranslatorForTest("run-ignored") - tx, err := tr.Translate([]byte(`{"type":"tool_use","part":` + part + `}`)) - if err != nil || len(tx.Envelopes) != 0 { - t.Fatalf("unexpected events for %s: %#v, %v", part, tx, err) - } - // An incomplete frame must not consume the eventual terminal call ID. - tx, err = tr.Translate([]byte(`{"type":"tool_use","part":{"type":"tool","callID":"c","tool":"read","state":{"status":"completed","output":""}}}`)) - if err != nil || len(tx.Envelopes) != 2 { - t.Fatalf("terminal events missing: %#v, %v", tx, err) - } - } -} diff --git a/apps/daemon/internal/agent/opencode/session.go b/apps/daemon/internal/agent/opencode/session.go deleted file mode 100644 index 162e88293..000000000 --- a/apps/daemon/internal/agent/opencode/session.go +++ /dev/null @@ -1,221 +0,0 @@ -// Package opencode is the agent_kind="opencode" adapter. It drives the -// OpenCode CLI via `opencode run --format json`. -package opencode - -import ( - "bufio" - "bytes" - "context" - "errors" - "fmt" - "io" - "log/slog" - "os" - "os/exec" - "strings" - "sync" - "syscall" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" -) - -type sessionConfig struct { - opencodeBinary string - extraArgs []string - killTimeout time.Duration - logger *slog.Logger -} - -func defaultConfig() sessionConfig { - return sessionConfig{opencodeBinary: defaultBinary(), killTimeout: 3 * time.Second, logger: obslog.Bg()} -} - -// Factory implements agent.Factory for agent_kind="opencode". -func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return newSession(ctx, req, out, defaultConfig()) -} - -// Session wraps a single `opencode run` subprocess. -type Session struct { - runID string - model string - cfg sessionConfig - - cmd *exec.Cmd - out chan<- proto.Envelope - - cancelCtx context.Context - cancelFn context.CancelFunc - - cancelOnce sync.Once - closeOutOnce sync.Once - waitDone chan struct{} - cleanup func() - - stderrMu sync.Mutex - stderr bytes.Buffer -} - -var _ agent.Session = (*Session)(nil) - -func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { - if out == nil { - return nil, errors.New("opencode: nil out channel") - } - if cfg.logger == nil { - cfg.logger = obslog.Bg() - } - if cfg.opencodeBinary == "" { - cfg.opencodeBinary = defaultBinary() - } - if cfg.killTimeout <= 0 { - cfg.killTimeout = 3 * time.Second - } - - opts, err := prepareManagedSkills(parent, cfg.logger, req) - if err != nil { - return nil, err - } - - prompt, err := req.Input.TextOnly() - if err != nil { - return nil, err - } - buildRes, err := BuildArgs(req.RunID, prompt, req.WorkDir, opts) - if err != nil { - return nil, fmt.Errorf("opencode: build args: %w", err) - } - cancelCtx, cancelFn := context.WithCancel(parent) - - args := append([]string{}, buildRes.Args...) - args = append(args, cfg.extraArgs...) - cmd := exec.CommandContext(cancelCtx, cfg.opencodeBinary, args...) - if buildRes.WorkDir != "" { - cmd.Dir = buildRes.WorkDir - } - cmd.Env = append(os.Environ(), buildRes.Env...) - - stdout, err := cmd.StdoutPipe() - if err != nil { - cancelFn() - buildRes.Cleanup() - return nil, fmt.Errorf("opencode: stdout pipe: %w", err) - } - stderr, err := cmd.StderrPipe() - if err != nil { - cancelFn() - buildRes.Cleanup() - return nil, fmt.Errorf("opencode: stderr pipe: %w", err) - } - if err := cmd.Start(); err != nil { - cancelFn() - buildRes.Cleanup() - return nil, fmt.Errorf("opencode: start %q: %w", cfg.opencodeBinary, err) - } - model := buildRes.ModelSelector - if _, key, qualified := strings.Cut(model, "/"); qualified { - model = key - } - - s := &Session{ - runID: req.RunID, - model: model, - cfg: cfg, - cmd: cmd, - out: out, - cancelCtx: cancelCtx, - cancelFn: cancelFn, - waitDone: make(chan struct{}), - cleanup: buildRes.Cleanup, - } - go s.pumpStderr(stderr) - go s.run(stdout) - return s, nil -} - -func (s *Session) Cancel(context.Context) error { - s.cancelOnce.Do(func() { - if s.cmd.Process == nil { - return - } - _ = s.cmd.Process.Signal(syscall.SIGTERM) - go func() { - select { - case <-s.waitDone: - return - case <-time.After(s.cfg.killTimeout): - _ = s.cmd.Process.Signal(syscall.SIGKILL) - } - }() - s.cancelFn() - }) - return nil -} - -func (s *Session) run(stdout io.Reader) { - defer close(s.waitDone) - defer s.cleanup() - defer s.closeOut() - - tr := newTranslator(s.runID) - tr.usage.Model = s.model - sc := bufio.NewScanner(stdout) - sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) - for sc.Scan() { - tx, err := tr.Translate(sc.Bytes()) - if err != nil { - s.cfg.logger.Warn("opencode: translate line", "run_id", s.runID, "err", err) - continue - } - for _, env := range tx.Envelopes { - select { - case s.out <- env: - case <-s.cancelCtx.Done(): - _ = s.cmd.Wait() - return - } - } - } - if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { - s.cfg.logger.Warn("opencode: scan stdout", "run_id", s.runID, "err", err) - } - - waitErr := s.cmd.Wait() - for _, env := range tr.terminalEnvelopes(waitErr, s.stderrString(), s.cancelCtx.Err() != nil) { - s.trySend(env) - } -} - -func (s *Session) pumpStderr(stderr io.Reader) { - sc := bufio.NewScanner(stderr) - sc.Buffer(make([]byte, 0, 16*1024), 1<<20) - for sc.Scan() { - line := sc.Text() - s.stderrMu.Lock() - if s.stderr.Len() > 0 { - s.stderr.WriteByte('\n') - } - s.stderr.WriteString(line) - s.stderrMu.Unlock() - s.cfg.logger.Warn("opencode stderr", "run_id", s.runID, "line", line) - } -} - -func (s *Session) stderrString() string { - s.stderrMu.Lock() - defer s.stderrMu.Unlock() - return s.stderr.String() -} - -func (s *Session) trySend(env proto.Envelope) { - select { - case s.out <- env: - case <-time.After(2 * time.Second): - s.cfg.logger.Warn("opencode: terminal send timed out", "type", env.Type, "run_id", s.runID) - } -} - -func (s *Session) closeOut() { s.closeOutOnce.Do(func() { close(s.out) }) } diff --git a/apps/daemon/internal/agent/opencode/session_model_test.go b/apps/daemon/internal/agent/opencode/session_model_test.go deleted file mode 100644 index 2db480ea1..000000000 --- a/apps/daemon/internal/agent/opencode/session_model_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package opencode_test - -import ( - "context" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestSessionUsageCarriesSelectedModel(t *testing.T) { - for _, tc := range []struct { - name, selector, fallback, want string - }{ - {"managed selector wins", "anthropic/MiniMax-M3", "old-model", "MiniMax-M3"}, - {"model path", "openrouter/anthropic/claude", "", "anthropic/claude"}, - {"legacy model", "", "openai/gpt-4o", "gpt-4o"}, - {"bare model", "", "MiniMax-M3", "MiniMax-M3"}, - {"native default remains unknown", "", "", ""}, - } { - t.Run(tc.name, func(t *testing.T) { - req := opencodeHelperReq("run_model", "hello", "json-success") - req.AgentOptions["model_selector"] = tc.selector - req.AgentOptions["model"] = tc.fallback - out := make(chan proto.Envelope, 32) - session, err := opencode.NewSessionForTest(context.Background(), req, out, opencodeHelperConfig()) - if err != nil { - t.Fatal(err) - } - defer session.Cancel(context.Background()) - events, closed := drainOpenCode(t, out, 5*time.Second) - if !closed { - t.Fatal("session did not finish") - } - count := 0 - for _, event := range events { - var usage proto.Usage - switch event.Type { - case proto.TypeUsage: - usage = decodePayload[proto.UsagePayload](t, event).Usage - case proto.TypeDone: - usage = decodePayload[proto.DonePayload](t, event).Usage - default: - continue - } - count++ - if usage.Model != tc.want || usage.Provider != "opencode" || usage.InputTokens != 4 || usage.OutputTokens != 2 { - t.Fatalf("%s usage = %#v; want model %q with existing provider/tokens", event.Type, usage, tc.want) - } - } - if count != 2 { - t.Fatalf("usage and done frames = %d, want 2", count) - } - }) - } -} diff --git a/apps/daemon/internal/agent/opencode/session_test.go b/apps/daemon/internal/agent/opencode/session_test.go deleted file mode 100644 index 7ba1acaf9..000000000 --- a/apps/daemon/internal/agent/opencode/session_test.go +++ /dev/null @@ -1,406 +0,0 @@ -package opencode_test - -import ( - "archive/zip" - "bytes" - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "slices" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// TestMain re-execs the test binary as a fake `opencode` when -// OPENCODE_TESTHELPER_ROLE is set, bypassing m.Run so the test -// framework's PASS line doesn't pollute fake stdout. -const opencodeHelperEnvKey = "OPENCODE_TESTHELPER_ROLE" - -func TestMain(m *testing.M) { - if role := os.Getenv(opencodeHelperEnvKey); role != "" { - runFakeOpenCode(role) - os.Exit(0) - } - os.Exit(m.Run()) -} - -func runFakeOpenCode(role string) { - if dumpPath := os.Getenv("OPENCODE_TESTHELPER_CONFIG_DUMP"); dumpPath != "" { - body, err := json.Marshal(map[string]string{ - "config_dir": os.Getenv("OPENCODE_CONFIG_DIR"), - "xdg_config_home": os.Getenv("XDG_CONFIG_HOME"), - }) - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "encode managed config env: %v\n", err) - os.Exit(65) - } - if err := os.WriteFile(dumpPath, body, 0o600); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "dump managed config: %v\n", err) - os.Exit(65) - } - } - enc := json.NewEncoder(os.Stdout) - enc.SetEscapeHTML(false) - - sawRun := false - sawJSONFormat := false - for i, arg := range os.Args[1:] { - if arg == "run" { - sawRun = true - } - if arg == "--format" && i+2 <= len(os.Args[1:]) && os.Args[i+2] == "json" { - sawJSONFormat = true - } - } - - switch role { - case "json-success": - if !sawRun || !sawJSONFormat { - _, _ = os.Stderr.WriteString("missing opencode run --format json\n") - os.Exit(64) - } - _ = enc.Encode(map[string]any{ - "type": "message.part.delta", - "properties": map[string]any{ - "field": "text", - "delta": "hi ", - }, - }) - _ = enc.Encode(map[string]any{ - "type": "message.part.delta", - "properties": map[string]any{ - "field": "text", - "delta": "there", - }, - }) - _ = enc.Encode(map[string]any{ - "type": "message.updated", - "properties": map[string]any{ - "info": map[string]any{ - "cost": 0.12, - "tokens": map[string]any{ - "input": 4, - "output": 2, - "total": 6, - }, - }, - }, - }) - - case "plain-success": - _, _ = os.Stdout.WriteString("plain line one\nplain line two\n") - - case "nonzero": - _, _ = os.Stderr.WriteString("bad auth from fake opencode\n") - os.Exit(17) - - case "hang": - _ = enc.Encode(map[string]any{ - "type": "message.part.delta", - "properties": map[string]any{ - "field": "text", - "delta": "started", - }, - }) - time.Sleep(10 * time.Minute) - } -} - -func opencodeHelperConfig() opencode.SessionConfigForTest { - return opencode.SessionConfigForTest{ - OpenCodeBinary: os.Args[0], - ExtraArgs: []string{"-test.run=^$"}, - KillTimeout: 200 * time.Millisecond, - } -} - -func opencodeHelperReq(runID, prompt, role string) proto.PromptRequestPayload { - return proto.PromptRequestPayload{ - RunID: runID, - Input: proto.TextInput(prompt), - AgentOptions: map[string]any{ - "env": map[string]any{ - opencodeHelperEnvKey: role, - }, - }, - } -} - -func drainOpenCode(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { - t.Helper() - deadline := time.After(dl) - var got []proto.Envelope - for { - select { - case env, ok := <-out: - if !ok { - return got, true - } - got = append(got, env) - case <-deadline: - return got, false - } - } -} - -func TestSessionJSONSuccessEmitsDeltaUsageAndDone(t *testing.T) { - out := make(chan proto.Envelope, 32) - sess, err := opencode.NewSessionForTest(context.Background(), - opencodeHelperReq("run_json", "hello", "json-success"), out, opencodeHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drainOpenCode(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := opencodeEnvTypes(got) - mustContainOpenCode(t, types, proto.TypeDelta) - mustContainOpenCode(t, types, proto.TypeUsage) - mustContainOpenCode(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } - for _, env := range got { - if env.ID != "run_json" { - t.Errorf("env type=%s ID=%q, want run_json", env.Type, env.ID) - } - } - done := decodePayload[proto.DonePayload](t, got[len(got)-1]) - if done.Content != "hi there" { - t.Fatalf("done content = %q, want hi there", done.Content) - } - if done.Usage.Provider != "opencode" || done.Usage.InputTokens != 4 || done.Usage.OutputTokens != 2 { - t.Fatalf("done usage = %#v", done.Usage) - } -} - -func TestSessionInstallsAndRegistersManagedSkills(t *testing.T) { - home := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", home) - t.Setenv("OPENCODE_CONFIG_DIR", "") - userConfigHome := filepath.Join(t.TempDir(), "user-config") - t.Setenv("XDG_CONFIG_HOME", userConfigHome) - body := openCodeSkillZip(t) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write(body) - })) - defer srv.Close() - - dumpPath := filepath.Join(t.TempDir(), "opencode.json") - out := make(chan proto.Envelope, 32) - req := opencodeHelperReq("run_skills", "hello", "json-success") - req.ConversationID = "conv-skills" - req.AgentStateKey = "conv-skills/agent-1/opencode" - req.AgentOptions["skills"] = []any{map[string]any{ - "name": "find-skills", "version": "1.0.0", "download_url": srv.URL, - "sha256": fmt.Sprintf("%x", sha256.Sum256(body)), - }} - req.AgentOptions["env"].(map[string]any)["OPENCODE_TESTHELPER_CONFIG_DUMP"] = dumpPath - - sess, err := opencode.NewSessionForTest(context.Background(), req, out, opencodeHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - if _, closed := drainOpenCode(t, out, 5*time.Second); !closed { - t.Fatal("out did not close") - } - - skillRoot := filepath.Join(home, "runtime", "opencode", "state", "conv-skills", "agent-1", "opencode", "skills") - if _, err := os.Stat(filepath.Join(skillRoot, "find-skills", "SKILL.md")); err != nil { - t.Fatalf("managed skill missing: %v", err) - } - dumped, err := os.ReadFile(dumpPath) - if err != nil { - t.Fatalf("read dumped config: %v", err) - } - var configEnv map[string]string - if err := json.Unmarshal(dumped, &configEnv); err != nil { - t.Fatalf("decode dumped config env: %v", err) - } - if got, want := configEnv["config_dir"], filepath.Dir(skillRoot); got != want { - t.Fatalf("OPENCODE_CONFIG_DIR = %q, want %q", got, want) - } - if got := configEnv["xdg_config_home"]; got != userConfigHome { - t.Fatalf("XDG_CONFIG_HOME = %q, want inherited %q", got, userConfigHome) - } - - cleanupReq := opencodeHelperReq("run_skills_cleanup", "hello", "json-success") - cleanupReq.ConversationID = req.ConversationID - cleanupReq.AgentStateKey = req.AgentStateKey - cleanupOut := make(chan proto.Envelope, 32) - cleanupSession, err := opencode.NewSessionForTest(context.Background(), cleanupReq, cleanupOut, opencodeHelperConfig()) - if err != nil { - t.Fatalf("cleanup session: %v", err) - } - defer cleanupSession.Cancel(context.Background()) - if _, closed := drainOpenCode(t, cleanupOut, 5*time.Second); !closed { - t.Fatal("cleanup out did not close") - } - if _, err := os.Stat(filepath.Join(skillRoot, "find-skills")); !os.IsNotExist(err) { - t.Fatalf("unbound skill still exists: %v", err) - } -} - -func openCodeSkillZip(t *testing.T) []byte { - t.Helper() - var buffer bytes.Buffer - writer := zip.NewWriter(&buffer) - entry, err := writer.Create("SKILL.md") - if err != nil { - t.Fatal(err) - } - if _, err := entry.Write([]byte("---\nname: find-skills\ndescription: Find skills\n---\nUse the catalog.")); err != nil { - t.Fatal(err) - } - if err := writer.Close(); err != nil { - t.Fatal(err) - } - return buffer.Bytes() -} - -func TestSessionPlainStdoutFallsBackToDeltaAndDone(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := opencode.NewSessionForTest(context.Background(), - opencodeHelperReq("run_plain", "hello", "plain-success"), out, opencodeHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drainOpenCode(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := opencodeEnvTypes(got) - if len(got) < 2 || got[0].Type != proto.TypeDelta || got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("plain envs = %v", types) - } - done := decodePayload[proto.DonePayload](t, got[len(got)-1]) - if done.Content != "plain line one\nplain line two" { - t.Fatalf("done content = %q", done.Content) - } -} - -func TestSessionNonZeroExitEmitsErrorAndDone(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := opencode.NewSessionForTest(context.Background(), - opencodeHelperReq("run_err", "hello", "nonzero"), out, opencodeHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drainOpenCode(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := opencodeEnvTypes(got) - mustContainOpenCode(t, types, proto.TypeError) - mustContainOpenCode(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } - var errPayload proto.ErrorPayload - for _, env := range got { - if env.Type == proto.TypeError { - errPayload = decodePayload[proto.ErrorPayload](t, env) - } - } - if !strings.Contains(errPayload.Error, "bad auth from fake opencode") { - t.Fatalf("error payload = %#v", errPayload) - } -} - -func TestSessionCancelClosesOutAndEmitsTerminalFrames(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := opencode.NewSessionForTest(context.Background(), - opencodeHelperReq("run_cancel", "hello", "hang"), out, opencodeHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - - // Let the helper emit its first delta before cancellation. - time.Sleep(150 * time.Millisecond) - if err := sess.Cancel(context.Background()); err != nil { - t.Errorf("Cancel: %v", err) - } - - got, closed := drainOpenCode(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) - } - types := opencodeEnvTypes(got) - mustContainOpenCode(t, types, proto.TypeError) - mustContainOpenCode(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } -} - -func TestSessionDoesNotDeclareHumanResponses(t *testing.T) { - var session any = (*opencode.Session)(nil) - if _, ok := session.(agent.PermissionResponder); ok { - t.Fatal("unexpected permission responder") - } - if _, ok := session.(agent.UserChoiceResponder); ok { - t.Fatal("unexpected user-choice responder") - } -} - -func TestSessionRejectsNilOut(t *testing.T) { - _, err := opencode.NewSessionForTest(context.Background(), - opencodeHelperReq("run_nil", "hello", "json-success"), nil, opencodeHelperConfig()) - if err == nil { - t.Fatal("expected error on nil out") - } -} - -func TestSessionRejectsEmptyPrompt(t *testing.T) { - out := make(chan proto.Envelope, 4) - _, err := opencode.NewSessionForTest(context.Background(), - proto.PromptRequestPayload{RunID: "run_empty", Input: proto.TextInput("")}, out, opencodeHelperConfig()) - if err == nil { - t.Fatal("expected error on empty prompt") - } -} - -func TestSessionBadBinaryFailsToStart(t *testing.T) { - out := make(chan proto.Envelope, 4) - cfg := opencodeHelperConfig() - cfg.OpenCodeBinary = "/nonexistent/binary/that/does/not/resolve" - cfg.ExtraArgs = nil - _, err := opencode.NewSessionForTest(context.Background(), - opencodeHelperReq("run_bad", "hello", "json-success"), out, cfg) - if err == nil { - t.Fatal("expected start error for bogus binary") - } -} - -func opencodeEnvTypes(envs []proto.Envelope) []string { - out := make([]string, len(envs)) - for i, env := range envs { - out[i] = env.Type - } - return out -} - -func mustContainOpenCode(t *testing.T, haystack []string, needle string) { - t.Helper() - if !slices.Contains(haystack, needle) { - t.Fatalf("expected %q in %v", needle, haystack) - } -} diff --git a/apps/daemon/internal/agent/opencode/skills.go b/apps/daemon/internal/agent/opencode/skills.go deleted file mode 100644 index 8f8d3dcf6..000000000 --- a/apps/daemon/internal/agent/opencode/skills.go +++ /dev/null @@ -1,134 +0,0 @@ -package opencode - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -const ( - openCodeConfigDirEnv = "OPENCODE_CONFIG_DIR" - openCodeConfigContentEnv = "OPENCODE_CONFIG_CONTENT" -) - -func prepareManagedSkills(ctx context.Context, logger *slog.Logger, req proto.PromptRequestPayload) (map[string]any, error) { - rawSkills, hasSkills := req.AgentOptions["skills"] - if !hasSkills && strings.TrimSpace(req.AgentStateKey) == "" && strings.TrimSpace(req.ConversationID) == "" && strings.TrimSpace(req.RunID) == "" { - return req.AgentOptions, nil - } - root, err := agent.ManagedSkillsRoot("opencode", req.AgentStateKey, req.ConversationID, req.RunID) - if err != nil { - return nil, fmt.Errorf("opencode: resolve managed skills root: %w", err) - } - result, err := claudecode.InstallManagedSkills(ctx, logger, root, rawSkills) - if err != nil { - return nil, fmt.Errorf("opencode: install skills: %w", err) - } - for _, warning := range result.Warnings { - logger.Warn("opencode: skill install warning", "run_id", req.RunID, "msg", warning) - } - if len(result.SkillDirs) == 0 { - return req.AgentOptions, nil - } - return withOpenCodeSkillRoot(req.AgentOptions, root) -} - -func withOpenCodeSkillRoot(opts map[string]any, root string) (map[string]any, error) { - out := make(map[string]any, len(opts)+1) - for key, value := range opts { - out[key] = value - } - env := map[string]any{} - if raw := opts["env"]; raw != nil { - existing, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("opencode.BuildArgs: env must be object, got %T", raw) - } - for key, value := range existing { - env[key] = value - } - } - configDir, err := openCodeEnvValue(env, openCodeConfigDirEnv) - if err != nil { - return nil, err - } - if strings.TrimSpace(configDir) == "" { - env[openCodeConfigDirEnv] = filepath.Dir(root) - } else { - inline, err := openCodeEnvValue(env, openCodeConfigContentEnv) - if err != nil { - return nil, err - } - inline, err = addOpenCodeSkillPath(inline, root) - if err != nil { - return nil, err - } - env[openCodeConfigContentEnv] = inline - } - out["env"] = env - return out, nil -} - -func openCodeEnvValue(env map[string]any, key string) (string, error) { - if raw, ok := env[key]; ok { - value, ok := raw.(string) - if !ok { - return "", fmt.Errorf("opencode.BuildArgs: env[%q] must be string, got %T", key, raw) - } - return value, nil - } - return os.Getenv(key), nil -} - -func addOpenCodeSkillPath(rawConfig, root string) (string, error) { - config := map[string]any{} - if strings.TrimSpace(rawConfig) != "" { - if err := json.Unmarshal([]byte(rawConfig), &config); err != nil { - return "", fmt.Errorf("opencode: %s must be valid JSON: %w", openCodeConfigContentEnv, err) - } - if config == nil { - config = map[string]any{} - } - } - skills, ok := config["skills"].(map[string]any) - if !ok && config["skills"] != nil { - return "", fmt.Errorf("opencode: %s skills must be object, got %T", openCodeConfigContentEnv, config["skills"]) - } - if skills == nil { - skills = map[string]any{} - } - paths := []any{} - if rawPaths := skills["paths"]; rawPaths != nil { - var ok bool - paths, ok = rawPaths.([]any) - if !ok { - return "", fmt.Errorf("opencode: %s skills.paths must be array, got %T", openCodeConfigContentEnv, rawPaths) - } - } - found := false - for _, path := range paths { - value, ok := path.(string) - if !ok { - return "", fmt.Errorf("opencode: %s skills.paths entries must be strings", openCodeConfigContentEnv) - } - found = found || value == root - } - if found { - return rawConfig, nil - } - skills["paths"] = append(paths, root) - config["skills"] = skills - body, err := json.Marshal(config) - if err != nil { - return "", fmt.Errorf("opencode: marshal %s: %w", openCodeConfigContentEnv, err) - } - return string(body), nil -} diff --git a/apps/daemon/internal/agent/opencode/skills_test.go b/apps/daemon/internal/agent/opencode/skills_test.go deleted file mode 100644 index 5219cd67f..000000000 --- a/apps/daemon/internal/agent/opencode/skills_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package opencode - -import ( - "encoding/json" - "testing" -) - -func TestWithOpenCodeSkillRootPreservesExistingConfigDir(t *testing.T) { - t.Setenv(openCodeConfigDirEnv, "/user/opencode") - t.Setenv(openCodeConfigContentEnv, `{"agent":{"review":{}},"skills":{"paths":["/user/skills"],"urls":["https://example.com/skills"]}}`) - - opts, err := withOpenCodeSkillRoot(map[string]any{ - "env": map[string]any{"KEEP": "value"}, - }, "/managed/skills") - if err != nil { - t.Fatalf("withOpenCodeSkillRoot: %v", err) - } - env := opts["env"].(map[string]any) - if _, overridden := env[openCodeConfigDirEnv]; overridden { - t.Fatalf("%s must remain inherited", openCodeConfigDirEnv) - } - if env["KEEP"] != "value" { - t.Fatalf("existing env was not preserved: %v", env) - } - var config struct { - Agent map[string]any `json:"agent"` - Skills struct { - Paths []string `json:"paths"` - URLs []string `json:"urls"` - } `json:"skills"` - } - if err := json.Unmarshal([]byte(env[openCodeConfigContentEnv].(string)), &config); err != nil { - t.Fatal(err) - } - if len(config.Agent) != 1 || len(config.Skills.Paths) != 2 || config.Skills.Paths[1] != "/managed/skills" || len(config.Skills.URLs) != 1 { - t.Fatalf("merged config = %+v", config) - } -} diff --git a/apps/daemon/internal/agent/opencode/version.go b/apps/daemon/internal/agent/opencode/version.go deleted file mode 100644 index f815f80a2..000000000 --- a/apps/daemon/internal/agent/opencode/version.go +++ /dev/null @@ -1,35 +0,0 @@ -package opencode - -import ( - "context" - "errors" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe" -) - -// InstallURL points operators at the OpenCode documentation when the -// daemon can see the adapter but not the CLI binary. -const InstallURL = "https://opencode.ai/docs" - -// defaultBinary is the executable to probe and spawn: binpath.OpenCode() -// honours the OAC_RUNTIME_OPENCODE_BIN override so a bare-name PATH lookup can -// be bypassed in images where PATH is not under our control. A function -// rather than a const so the env is read at call time. -func defaultBinary() string { return binpath.OpenCode() } - -// ErrCLINotFound is returned by CheckCLIAvailable when the binary -// cannot be located on PATH. Callers use errors.Is to distinguish an -// install problem from a present-but-broken CLI. -var ErrCLINotFound = errors.New("opencode CLI not found") - -// CheckCLIAvailable runs ` --version` and returns the trimmed -// first line. The empty binary name defaults to defaultBinary(). -func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { - return versionprobe.Check(ctx, binary, versionprobe.Config{ - Name: "opencode", - DefaultBinary: defaultBinary(), - MissingError: ErrCLINotFound, - TrimBinary: true, - }) -} diff --git a/apps/daemon/internal/agent/opencode/version_test.go b/apps/daemon/internal/agent/opencode/version_test.go deleted file mode 100644 index bdc3c531f..000000000 --- a/apps/daemon/internal/agent/opencode/version_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package opencode_test - -import ( - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe/testutil" -) - -func TestCheckCLIAvailableContract(t *testing.T) { - testutil.RunContract(t, testutil.Contract{ - Name: "opencode", - DefaultBinary: "opencode", - MissingError: opencode.ErrCLINotFound, - Check: opencode.CheckCLIAvailable, - WhitespaceDefaults: true, - }) -} diff --git a/apps/daemon/internal/agent/pi/export_test.go b/apps/daemon/internal/agent/pi/export_test.go deleted file mode 100644 index 5bae4f52e..000000000 --- a/apps/daemon/internal/agent/pi/export_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package pi - -import ( - "context" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -type SessionConfigForTest struct { - PiBinary string - ExtraArgs []string - KillTimeout time.Duration -} - -func NewSessionForTest(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg SessionConfigForTest) (*Session, error) { - return newSession(ctx, req, out, sessionConfig{ - piBinary: cfg.PiBinary, - extraArgs: cfg.ExtraArgs, - killTimeout: cfg.KillTimeout, - }) -} - -type Translator translator - -type Translation = translation - -func NewTranslatorForTest(runID string) *Translator { return (*Translator)(newTranslator(runID)) } - -func (t *Translator) Translate(line []byte) (Translation, error) { - return (*translator)(t).Translate(line) -} - -func (t *Translator) TerminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { - return (*translator)(t).terminalEnvelopes(waitErr, stderr, cancelled) -} diff --git a/apps/daemon/internal/agent/pi/options.go b/apps/daemon/internal/agent/pi/options.go deleted file mode 100644 index 648ddc590..000000000 --- a/apps/daemon/internal/agent/pi/options.go +++ /dev/null @@ -1,217 +0,0 @@ -package pi - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" -) - -// BuildResult is the pi CLI launch plan for one prompt. Cleanup is -// always non-nil so callers can defer it blindly. -type BuildResult struct { - Args []string - Env []string - WorkDir string - Cleanup func() -} - -// BuildArgs translates the daemon prompt_request into a `pi --mode json` invocation. -func BuildArgs(runID, prompt, workDir string, opts map[string]any, resumeSessionID string) (BuildResult, error) { - _ = runID - result := BuildResult{Cleanup: func() {}} - - resolvedWorkDir, err := resolveWorkDir(workDir) - if err != nil { - return result, err - } - - promptText, err := buildPrompt(prompt) - if err != nil { - return result, err - } - - // --mode json: machine-readable NDJSON output for the translator. - // (Non-interactive mode is switched on by the trailing `-p` flag - // below, which pi consumes together with the prompt argument — - // that flag must be last for pi's arg parser to bind the prompt - // correctly, so we can't add it here.) - args := []string{"--mode", "json"} - - if model := stringOpt(opts, "model"); model != "" { - args = append(args, "--model", model) - } - if provider := stringOpt(opts, "provider"); provider != "" { - args = append(args, "--provider", provider) - } - // A managed api_key is deliberately NOT forwarded as --api-key: secrets - // ride the environment (OAC_RUNTIME_PI_API_KEY, referenced from the - // materialised models.json) so they never land on the pi child's argv, - // where `ps` would leak them. See server injectPiManagedModel. - - // override replaces the base system prompt and wins over append. - if override := stringOpt(opts, "override_system_prompt"); override != "" { - args = append(args, "--system-prompt", override) - } else if sys := stringOpt(opts, "system_prompt"); sys != "" { - args = append(args, "--append-system-prompt", sys) - } - - if sessionDir := stringOpt(opts, "session_dir"); sessionDir != "" { - resolvedSessionDir, err := resolveSessionDirOption(sessionDir) - if err != nil { - return result, err - } - args = append(args, "--session-dir", resolvedSessionDir) - } - - resume := strings.TrimSpace(resumeSessionID) - if resume != "" { - args = append(args, "--session", resume) - } - - skillDirs, err := stringSlice(opts["skill_dirs"]) - if err != nil { - return result, fmt.Errorf("pi.BuildArgs: skill_dirs: %w", err) - } - for _, d := range skillDirs { - if d = strings.TrimSpace(d); d != "" { - args = append(args, "--skill", d) - } - } - - // pi's -p consumes the immediately-following arg as the prompt, so it - // must be appended last, after every other flag. - args = append(args, "-p", promptText) - - env, err := buildEnv(opts) - if err != nil { - return result, err - } - - result.Args = args - result.Env = env - result.WorkDir = resolvedWorkDir - return result, nil -} - -func resolveWorkDir(input string) (string, error) { - trimmed := strings.TrimSpace(input) - if trimmed == "" { - return "", nil - } - var abs string - switch { - case strings.HasPrefix(trimmed, "~/"): - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("pi: resolve home dir: %w", err) - } - abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) - case filepath.IsAbs(trimmed): - abs = trimmed - default: - return "", fmt.Errorf("pi: work_dir must be absolute or start with ~/, got %q", trimmed) - } - if err := os.MkdirAll(abs, 0o755); err != nil { - return "", fmt.Errorf("pi: mkdir work_dir %s: %w", abs, err) - } - return abs, nil -} - -func resolveSessionDirOption(input string) (string, error) { - trimmed := strings.TrimSpace(input) - if trimmed == "" { - return "", nil - } - var abs string - switch { - case strings.HasPrefix(trimmed, "~/"): - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("pi: resolve home dir: %w", err) - } - abs = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) - case filepath.IsAbs(trimmed): - abs = trimmed - default: - return "", fmt.Errorf("pi: session_dir must be absolute or start with ~/, got %q", trimmed) - } - if err := os.MkdirAll(abs, 0o700); err != nil { - return "", fmt.Errorf("pi: mkdir session_dir %s: %w", abs, err) - } - return abs, nil -} - -func buildPrompt(prompt string) (string, error) { - prompt = strings.TrimSpace(prompt) - if prompt == "" { - return "", fmt.Errorf("pi: empty prompt") - } - return prompt, nil -} - -func buildEnv(opts map[string]any) ([]string, error) { - // PI_TELEMETRY=0 force-disables pi's opt-in install telemetry for - // unattended daemon runs (pi reads PI_TELEMETRY, not DISABLE_TELEMETRY). - env := []string{"PI_TELEMETRY=0"} - raw, ok := opts["env"] - if !ok || raw == nil { - return env, nil - } - envMap, ok := raw.(map[string]any) - if !ok { - return nil, fmt.Errorf("pi.BuildArgs: env must be object, got %T", raw) - } - keys := make([]string, 0, len(envMap)) - for k := range envMap { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - s, ok := envMap[k].(string) - if !ok { - return nil, fmt.Errorf("pi.BuildArgs: env[%q] must be string, got %T", k, envMap[k]) - } - env = append(env, k+"="+s) - } - return env, nil -} - -func stringOpt(opts map[string]any, key string) string { - if opts == nil { - return "" - } - v, ok := opts[key] - if !ok || v == nil { - return "" - } - s, ok := v.(string) - if !ok { - return "" - } - return strings.TrimSpace(s) -} - -// stringSlice coerces a value to []string, accepting a typed []string or -// the []any json.Unmarshal produces for a JSON array. nil yields nil. -func stringSlice(v any) ([]string, error) { - switch x := v.(type) { - case nil: - return nil, nil - case []string: - return x, nil - case []any: - out := make([]string, 0, len(x)) - for i, el := range x { - s, ok := el.(string) - if !ok { - return nil, fmt.Errorf("element %d must be string, got %T", i, el) - } - out = append(out, s) - } - return out, nil - default: - return nil, fmt.Errorf("must be array of strings, got %T", v) - } -} diff --git a/apps/daemon/internal/agent/pi/options_test.go b/apps/daemon/internal/agent/pi/options_test.go deleted file mode 100644 index 94829de58..000000000 --- a/apps/daemon/internal/agent/pi/options_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package pi_test - -import ( - "os" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" -) - -func TestBuildArgsUsesModeJsonAndPromptLast(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", os.TempDir(), map[string]any{ - "model": "anthropic/claude-opus-4-7", - "api_key": "sk-test", - "provider": "anthropic", - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - - if !containsPair(res.Args, "--mode", "json") { - t.Fatalf("args missing --mode json: %v", res.Args) - } - if !containsPair(res.Args, "--model", "anthropic/claude-opus-4-7") { - t.Fatalf("args missing --model: %v", res.Args) - } - // Secrets must never ride on argv (ps would leak them): a provided - // api_key is intentionally dropped here and delivered via env instead - // (see server injectPiManagedModel / OAC_RUNTIME_PI_API_KEY). - if slices.Contains(res.Args, "--api-key") || slices.Contains(res.Args, "sk-test") { - t.Fatalf("api_key must not leak onto argv: %v", res.Args) - } - if !containsPair(res.Args, "--provider", "anthropic") { - t.Fatalf("args missing --provider: %v", res.Args) - } - // pi's -p consumes the immediately-following arg as the prompt, so the - // prompt MUST be the final arg, preceded by -p. - n := len(res.Args) - if n < 2 || res.Args[n-2] != "-p" || res.Args[n-1] != "hello" { - t.Fatalf("expected args to end with -p hello, got %v", res.Args) - } - if res.WorkDir != os.TempDir() { - t.Fatalf("WorkDir = %q, want %q", res.WorkDir, os.TempDir()) - } -} - -func TestBuildArgsRejectsRelativeWorkdir(t *testing.T) { - _, err := pi.BuildArgs("run-1", "hello", "./relative", nil, "") - if err == nil || !strings.Contains(err.Error(), "absolute") { - t.Fatalf("BuildArgs relative err = %v, want absolute-path error", err) - } -} - -func TestBuildArgsCreatesMissingWorkdir(t *testing.T) { - target := filepath.Join(t.TempDir(), "missing", "parents", "leaf") - res, err := pi.BuildArgs("run-1", "hello", target, nil, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if res.WorkDir != target { - t.Fatalf("WorkDir = %q, want %q", res.WorkDir, target) - } - info, err := os.Stat(target) - if err != nil { - t.Fatalf("stat target: %v", err) - } - if !info.IsDir() { - t.Fatalf("target %q is not a directory", target) - } -} - -func TestBuildArgsSystemPromptAppends(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "system_prompt": "be terse", - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--append-system-prompt", "be terse") { - t.Fatalf("args missing --append-system-prompt: %v", res.Args) - } - if slices.Contains(res.Args, "--system-prompt") { - t.Fatalf("system_prompt must map to append, not override: %v", res.Args) - } -} - -func TestBuildArgsOverrideSystemPromptReplaces(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "system_prompt": "be terse", - "override_system_prompt": "you are root", - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--system-prompt", "you are root") { - t.Fatalf("args missing --system-prompt override: %v", res.Args) - } - // Override wins: the append we tentatively added must be stripped. - if slices.Contains(res.Args, "--append-system-prompt") { - t.Fatalf("override must strip the append: %v", res.Args) - } -} - -func TestBuildArgsResumeSessionUsesExplicitID(t *testing.T) { - sessionDir := filepath.Join(t.TempDir(), "sessions") - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "resume_session_id": "from-opts", - "session_dir": sessionDir, - }, "from-param") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--session-dir", sessionDir) { - t.Fatalf("args missing --session-dir: %v", res.Args) - } - if !containsPair(res.Args, "--session", "from-param") { - t.Fatalf("explicit resume id must win: %v", res.Args) - } -} - -func TestBuildArgsSessionDirCreatesAndAddsFlag(t *testing.T) { - sessionDir := filepath.Join(t.TempDir(), "missing", "sessions") - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "session_dir": sessionDir, - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--session-dir", sessionDir) { - t.Fatalf("args missing --session-dir: %v", res.Args) - } - info, err := os.Stat(sessionDir) - if err != nil { - t.Fatalf("stat session_dir: %v", err) - } - if !info.IsDir() { - t.Fatalf("session_dir %q is not a directory", sessionDir) - } -} - -func TestBuildArgsRejectsRelativeSessionDir(t *testing.T) { - _, err := pi.BuildArgs("run-1", "hello", "", map[string]any{"session_dir": "./sessions"}, "") - if err == nil || !strings.Contains(err.Error(), "session_dir") { - t.Fatalf("BuildArgs session_dir err = %v, want session_dir error", err) - } -} - -func TestBuildArgsIgnoresResumeSessionIDOption(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "resume_session_id": "sess-42", - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if slices.Contains(res.Args, "--session") || slices.Contains(res.Args, "sess-42") { - t.Fatalf("resume_session_id option must be ignored: %v", res.Args) - } -} - -func TestBuildArgsSkillDirsRepeatFlag(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "skill_dirs": []any{"/skills/a", "/skills/b"}, - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if !containsPair(res.Args, "--skill", "/skills/a") { - t.Fatalf("args missing first --skill: %v", res.Args) - } - if !containsPair(res.Args, "--skill", "/skills/b") { - t.Fatalf("args missing second --skill: %v", res.Args) - } -} - -func TestBuildArgsTelemetryOptOutEnv(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", "", nil, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if envValue(res.Env, "PI_TELEMETRY") != "0" { - t.Fatalf("expected PI_TELEMETRY=0 opt-out, env=%v", res.Env) - } -} - -func TestBuildArgsPassesThroughEnvSorted(t *testing.T) { - res, err := pi.BuildArgs("run-1", "hello", "", map[string]any{ - "env": map[string]any{"BBB": "2", "AAA": "1"}, - }, "") - if err != nil { - t.Fatalf("BuildArgs: %v", err) - } - defer res.Cleanup() - if envValue(res.Env, "AAA") != "1" || envValue(res.Env, "BBB") != "2" { - t.Fatalf("env passthrough missing: %v", res.Env) - } -} - -func TestBuildArgsRejectsBadEnvShape(t *testing.T) { - _, err := pi.BuildArgs("run-1", "hello", "", map[string]any{"env": map[string]any{"K": 1}}, "") - if err == nil || !strings.Contains(err.Error(), "env") { - t.Fatalf("BuildArgs env err = %v, want env shape error", err) - } -} - -func TestBuildArgsRejectsEmptyPrompt(t *testing.T) { - _, err := pi.BuildArgs("run-1", " ", "", nil, "") - if err == nil || !strings.Contains(err.Error(), "prompt") { - t.Fatalf("BuildArgs empty prompt err = %v, want prompt error", err) - } -} - -func containsPair(args []string, flag, value string) bool { - for i, a := range args { - if a == flag && i+1 < len(args) && args[i+1] == value { - return true - } - } - return false -} - -func envValue(env []string, key string) string { - prefix := key + "=" - for _, item := range env { - if v, ok := strings.CutPrefix(item, prefix); ok { - return v - } - } - return "" -} diff --git a/apps/daemon/internal/agent/pi/parser.go b/apps/daemon/internal/agent/pi/parser.go deleted file mode 100644 index f25064ad4..000000000 --- a/apps/daemon/internal/agent/pi/parser.go +++ /dev/null @@ -1,337 +0,0 @@ -package pi - -import ( - "bytes" - "encoding/json" - "fmt" - "strings" - "sync/atomic" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// translator converts one NDJSON line from pi's `--mode json` stdout -// into zero or more proto.Envelope frames. One translator lives per -// session. pi has no explicit terminal frame: the stream ends at process -// EOF, so the session pump calls terminalEnvelopes after cmd.Wait. -type translator struct { - runID string - seq atomic.Uint64 - - deltaBuf strings.Builder - finalText string - rawLines []string - usage proto.Usage - usageSet bool - sessionID string - failed bool -} - -type translation struct { - Envelopes []proto.Envelope - // SessionID is surfaced from the session header line so session.go - // can write it into binding metadata for --session resume. - SessionID string -} - -func newTranslator(runID string) *translator { return &translator{runID: runID} } - -func (t *translator) Translate(line []byte) (translation, error) { - line = bytes.TrimSpace(line) - if len(line) == 0 { - return translation{}, nil - } - t.rawLines = append(t.rawLines, string(line)) - - var head struct { - Type string `json:"type"` - } - // pi emits strict JSON per line; a non-JSON line is a stray log, not - // a fatal error — skip it to keep one bad line from killing the run. - if err := json.Unmarshal(line, &head); err != nil || head.Type == "" { - return translation{}, nil - } - - switch head.Type { - case "session": - return t.translateSessionHeader(line) - case "message_update": - return t.translateMessageUpdate(line) - case "tool_execution_start": - return t.translateToolStart(line) - case "tool_execution_end": - return t.translateToolEnd(line) - case "message_end": - return t.translateMessageEnd(line) - default: - return translation{}, nil - } -} - -func (t *translator) translateSessionHeader(line []byte) (translation, error) { - var msg struct { - ID string `json:"id"` - } - _ = json.Unmarshal(line, &msg) - if msg.ID != "" { - t.sessionID = msg.ID - } - return translation{SessionID: msg.ID}, nil -} - -func (t *translator) translateMessageUpdate(line []byte) (translation, error) { - var msg struct { - Event struct { - Type string `json:"type"` - Delta string `json:"delta"` - } `json:"assistantMessageEvent"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("pi: parse message_update: %w", err) - } - switch msg.Event.Type { - case "text_delta": - if msg.Event.Delta == "" { - return translation{}, nil - } - t.deltaBuf.WriteString(msg.Event.Delta) - env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{ - Delta: msg.Event.Delta, - Sequence: t.seq.Add(1), - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil - case "thinking_delta": - if msg.Event.Delta == "" { - return translation{}, nil - } - env, err := proto.NewEnvelope(proto.TypeThinking, t.runID, proto.ThinkingPayload{ - Text: msg.Event.Delta, - Sequence: t.seq.Add(1), - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil - default: - return translation{}, nil - } -} - -func (t *translator) translateToolStart(line []byte) (translation, error) { - var msg struct { - ToolCallID string `json:"toolCallId"` - ToolName string `json:"toolName"` - Args map[string]any `json:"args"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("pi: parse tool_execution_start: %w", err) - } - env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ - ID: msg.ToolCallID, - Name: msg.ToolName, - Stage: "before", - Args: msg.Args, - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil -} - -func (t *translator) translateToolEnd(line []byte) (translation, error) { - var msg struct { - ToolCallID string `json:"toolCallId"` - ToolName string `json:"toolName"` - Result any `json:"result"` - IsError bool `json:"isError"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("pi: parse tool_execution_end: %w", err) - } - env, err := proto.NewEnvelope(proto.TypeToolCall, t.runID, proto.ToolCallPayload{ - ID: msg.ToolCallID, - Name: msg.ToolName, - Stage: "after", - Result: map[string]any{ - "content": msg.Result, - "is_error": msg.IsError, - }, - }) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil -} - -type piUsage struct { - Input int32 `json:"input"` - Output int32 `json:"output"` - CacheRead int32 `json:"cacheRead"` - CacheWrite int32 `json:"cacheWrite"` - CacheWrite1h int32 `json:"cacheWrite1h"` - Reasoning int32 `json:"reasoning"` - TotalTokens int32 `json:"totalTokens"` - Cost struct { - Total float64 `json:"total"` - } `json:"cost"` -} - -func (t *translator) translateMessageEnd(line []byte) (translation, error) { - var msg struct { - Message struct { - Role string `json:"role"` - Content []json.RawMessage - Provider string `json:"provider"` - Model string `json:"model"` - Usage piUsage `json:"usage"` - StopReason string `json:"stopReason"` - ErrorMessage string `json:"errorMessage"` - } `json:"message"` - } - if err := json.Unmarshal(line, &msg); err != nil { - return translation{}, fmt.Errorf("pi: parse message_end: %w", err) - } - if msg.Message.Role != "assistant" { - return translation{}, nil - } - - t.mergeUsage(msg.Message.Usage, msg.Message.Provider, msg.Message.Model) - if text := extractText(msg.Message.Content); text != "" { - t.finalText = text - } - - if msg.Message.StopReason == "error" { - t.failed = true - errMsg := msg.Message.ErrorMessage - if errMsg == "" { - errMsg = "pi: assistant message ended with error" - } - env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: errMsg}) - if err != nil { - return translation{}, err - } - return translation{Envelopes: []proto.Envelope{env}}, nil - } - return translation{}, nil -} - -func extractText(content []json.RawMessage) string { - var b strings.Builder - for _, raw := range content { - var item struct { - Type string `json:"type"` - Text string `json:"text"` - } - if err := json.Unmarshal(raw, &item); err != nil { - continue - } - if item.Type == "text" { - b.WriteString(item.Text) - } - } - return b.String() -} - -func (t *translator) mergeUsage(u piUsage, provider, model string) { - t.usageSet = true - t.usage.InputTokens += u.Input - t.usage.OutputTokens += u.Output - t.usage.CostUSD += u.Cost.Total - if provider != "" { - t.usage.Provider = provider - } - if model != "" { - t.usage.Model = model - } - // pi reports usage per assistant message; a tool loop yields several - // message_end frames, so accumulate cache/reasoning/total the same way as - // input/output above — overwriting would leave Raw showing only the last - // frame while the summed token counts reflect all of them. - t.addRawTokens("cache_read_tokens", u.CacheRead) - t.addRawTokens("cache_write_tokens", u.CacheWrite) - t.addRawTokens("cache_write_1h_tokens", u.CacheWrite1h) - t.addRawTokens("reasoning_tokens", u.Reasoning) - t.addRawTokens("total_tokens", u.TotalTokens) -} - -// addRawTokens sums one counter into usage.Raw. In-process the values stay -// int32 (matching piUsage); they only become float64 once the envelope is -// JSON-encoded downstream, which is why the lookup asserts int32. -func (t *translator) addRawTokens(key string, v int32) { - if v == 0 { - return - } - if t.usage.Raw == nil { - t.usage.Raw = map[string]any{} - } - if existing, ok := t.usage.Raw[key].(int32); ok { - v += existing - } - t.usage.Raw[key] = v -} - -func (t *translator) terminalEnvelopes(waitErr error, stderr string, cancelled bool) []proto.Envelope { - var envs []proto.Envelope - - content := strings.TrimSpace(t.deltaBuf.String()) - if content == "" && t.finalText != "" { - content = strings.TrimSpace(t.finalText) - if content != "" { - if env, err := proto.NewEnvelope(proto.TypeDelta, t.runID, proto.DeltaPayload{Delta: content, Sequence: t.seq.Add(1)}); err == nil { - envs = append(envs, env) - } - } - } - - usage := t.usage - if usage.Provider == "" { - usage.Provider = "pi" - } - if t.usageSet { - if env, err := proto.NewEnvelope(proto.TypeUsage, t.runID, proto.UsagePayload{Usage: usage}); err == nil { - envs = append(envs, env) - } - } - - terminalFailed := t.failed || waitErr != nil || cancelled - if waitErr != nil || cancelled { - msg := "pi: subprocess exited without success" - if waitErr != nil { - msg = fmt.Sprintf("pi: subprocess exited: %v", waitErr) - } - if strings.TrimSpace(stderr) != "" { - msg += ": " + truncate(strings.TrimSpace(stderr), 400) - } - if cancelled { - msg = "pi: cancelled" - } - if env, err := proto.NewEnvelope(proto.TypeError, t.runID, proto.ErrorPayload{Error: msg}); err == nil { - envs = append(envs, env) - } - } - - metadata := map[string]any{"connector_path": "pi_print"} - if t.sessionID != "" && !terminalFailed { - metadata[proto.DoneMetaAgentSessionID] = t.sessionID - metadata[proto.DoneMetaAgentSessionType] = "pi_session" - } - if env, err := proto.NewEnvelope(proto.TypeDone, t.runID, proto.DonePayload{ - Content: content, - Transcript: strings.Join(t.rawLines, "\n"), - Usage: usage, - Metadata: metadata, - }); err == nil { - envs = append(envs, env) - } - return envs -} - -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max] -} diff --git a/apps/daemon/internal/agent/pi/parser_test.go b/apps/daemon/internal/agent/pi/parser_test.go deleted file mode 100644 index 9bf38054d..000000000 --- a/apps/daemon/internal/agent/pi/parser_test.go +++ /dev/null @@ -1,259 +0,0 @@ -package pi_test - -import ( - "encoding/json" - "errors" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func TestTranslateSessionHeaderSurfacesSessionID(t *testing.T) { - tr := pi.NewTranslatorForTest("run-1") - tx, err := tr.Translate([]byte(`{"type":"session","id":"sess-abc","cwd":"/x","timestamp":"t"}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if tx.SessionID != "sess-abc" { - t.Fatalf("SessionID = %q, want sess-abc", tx.SessionID) - } - if len(tx.Envelopes) != 0 { - t.Fatalf("session header should emit no envelopes, got %#v", tx.Envelopes) - } -} - -func TestTranslateTextDeltaEmitsDelta(t *testing.T) { - tr := pi.NewTranslatorForTest("run-1") - tx, err := tr.Translate([]byte(`{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"hello"}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeDelta { - t.Fatalf("delta envelopes = %#v", tx.Envelopes) - } - delta := decodePayload[proto.DeltaPayload](t, tx.Envelopes[0]) - if delta.Delta != "hello" || delta.Sequence == 0 { - t.Fatalf("delta payload = %#v", delta) - } -} - -func TestTranslateThinkingDeltaEmitsThinking(t *testing.T) { - tr := pi.NewTranslatorForTest("run-1") - tx, err := tr.Translate([]byte(`{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"thinking_delta","contentIndex":0,"delta":"pondering"}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeThinking { - t.Fatalf("thinking envelopes = %#v", tx.Envelopes) - } - think := decodePayload[proto.ThinkingPayload](t, tx.Envelopes[0]) - if think.Text != "pondering" || think.Sequence == 0 { - t.Fatalf("thinking payload = %#v", think) - } -} - -func TestTranslateToolExecutionStartEmitsBeforeToolCall(t *testing.T) { - tr := pi.NewTranslatorForTest("run-1") - tx, err := tr.Translate([]byte(`{"type":"tool_execution_start","toolCallId":"t1","toolName":"bash","args":{"cmd":"ls"}}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeToolCall { - t.Fatalf("toolcall envelopes = %#v", tx.Envelopes) - } - tc := decodePayload[proto.ToolCallPayload](t, tx.Envelopes[0]) - if tc.ID != "t1" || tc.Name != "bash" || tc.Stage != "before" { - t.Fatalf("toolcall payload = %#v", tc) - } - if tc.Args["cmd"] != "ls" { - t.Fatalf("toolcall args = %#v", tc.Args) - } -} - -func TestTranslateToolExecutionEndEmitsAfterToolCall(t *testing.T) { - tr := pi.NewTranslatorForTest("run-1") - tx, err := tr.Translate([]byte(`{"type":"tool_execution_end","toolCallId":"t1","toolName":"bash","result":{"stdout":"x"},"isError":true}`)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - if len(tx.Envelopes) != 1 || tx.Envelopes[0].Type != proto.TypeToolCall { - t.Fatalf("toolcall envelopes = %#v", tx.Envelopes) - } - tc := decodePayload[proto.ToolCallPayload](t, tx.Envelopes[0]) - if tc.ID != "t1" || tc.Stage != "after" { - t.Fatalf("toolcall payload = %#v", tc) - } - if tc.Result["is_error"] != true { - t.Fatalf("toolcall result = %#v", tc.Result) - } -} - -func TestTranslateMessageEndCapturesUsage(t *testing.T) { - tr := pi.NewTranslatorForTest("run-u") - line := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"provider":"anthropic","model":"claude-x","usage":{"input":10,"output":7,"cacheRead":2,"cacheWrite":1,"reasoning":3,"totalTokens":23,"cost":{"input":0.1,"output":0.2,"cacheRead":0,"cacheWrite":0,"total":0.3}},"stopReason":"stop"}}` - if _, err := tr.Translate([]byte(line)); err != nil { - t.Fatalf("Translate: %v", err) - } - envs := tr.TerminalEnvelopes(nil, "", false) - var got *proto.UsagePayload - for _, env := range envs { - if env.Type == proto.TypeUsage { - payload := decodePayload[proto.UsagePayload](t, env) - got = &payload - } - } - if got == nil { - t.Fatalf("usage env missing: %#v", envs) - } - if got.Provider != "anthropic" || got.Model != "claude-x" { - t.Fatalf("usage provider/model = %#v", got) - } - if got.InputTokens != 10 || got.OutputTokens != 7 || got.CostUSD != 0.3 { - t.Fatalf("usage tokens/cost = %#v", got) - } - if got.Raw["cache_read_tokens"] != float64(2) { - t.Fatalf("usage raw = %#v", got.Raw) - } -} - -// A tool loop makes pi emit one message_end per assistant turn. Every -// counter — including the cache/reasoning/total ones parked in Raw — must -// sum across frames, not get clobbered by the final frame. -func TestTranslateMessageEndAccumulatesUsageAcrossFrames(t *testing.T) { - tr := pi.NewTranslatorForTest("run-multi") - first := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"a"}],"provider":"anthropic","model":"m","usage":{"input":10,"output":7,"cacheRead":2,"cacheWrite":1,"reasoning":3,"totalTokens":23,"cost":{"total":0.3}},"stopReason":"tool_use"}}` - second := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"b"}],"provider":"anthropic","model":"m","usage":{"input":5,"output":4,"cacheRead":6,"cacheWrite":2,"reasoning":1,"totalTokens":12,"cost":{"total":0.2}},"stopReason":"stop"}}` - for _, line := range []string{first, second} { - if _, err := tr.Translate([]byte(line)); err != nil { - t.Fatalf("Translate: %v", err) - } - } - envs := tr.TerminalEnvelopes(nil, "", false) - var got *proto.UsagePayload - for _, env := range envs { - if env.Type == proto.TypeUsage { - payload := decodePayload[proto.UsagePayload](t, env) - got = &payload - } - } - if got == nil { - t.Fatalf("usage env missing: %#v", envs) - } - if got.InputTokens != 15 || got.OutputTokens != 11 { - t.Fatalf("summed input/output = %d/%d, want 15/11", got.InputTokens, got.OutputTokens) - } - if got.CostUSD < 0.49 || got.CostUSD > 0.51 { - t.Fatalf("summed cost = %v, want ~0.5", got.CostUSD) - } - // Raw round-trips through JSON, so the counters decode back as float64. - for key, want := range map[string]float64{ - "cache_read_tokens": 8, - "cache_write_tokens": 3, - "reasoning_tokens": 4, - "total_tokens": 35, - } { - if got.Raw[key] != want { - t.Fatalf("Raw[%q] = %#v, want %v (must sum across frames)", key, got.Raw[key], want) - } - } -} - -// pi exits 0 even when the model errors: it emits a message_end whose -// assistant message carries stopReason "error". The parser MUST surface -// that as TypeError despite the clean process exit. -func TestTranslateMessageEndErrorStopReasonEmitsError(t *testing.T) { - tr := pi.NewTranslatorForTest("run-err") - if _, err := tr.Translate([]byte(`{"type":"session","id":"sess-bad","cwd":"/x","timestamp":"t"}`)); err != nil { - t.Fatalf("Translate header: %v", err) - } - line := `{"type":"message_end","message":{"role":"assistant","content":[],"provider":"anthropic","model":"m","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"boom"}}` - tx, err := tr.Translate([]byte(line)) - if err != nil { - t.Fatalf("Translate: %v", err) - } - var found bool - for _, env := range tx.Envelopes { - if env.Type == proto.TypeError { - ep := decodePayload[proto.ErrorPayload](t, env) - if strings.Contains(ep.Error, "boom") { - found = true - } - } - } - if !found { - t.Fatalf("expected TypeError mentioning boom, got %#v", tx.Envelopes) - } - envs := tr.TerminalEnvelopes(nil, "", false) - done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) - if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { - t.Fatalf("failed pi turn must not persist session metadata: %#v", done.Metadata) - } -} - -func TestTerminalAlwaysEmitsDoneWithSessionMetadata(t *testing.T) { - tr := pi.NewTranslatorForTest("run-1") - if _, err := tr.Translate([]byte(`{"type":"session","id":"sess-abc","cwd":"/x","timestamp":"t"}`)); err != nil { - t.Fatalf("Translate header: %v", err) - } - if _, err := tr.Translate([]byte(`{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"hello"}}`)); err != nil { - t.Fatalf("Translate delta: %v", err) - } - envs := tr.TerminalEnvelopes(nil, "", false) - last := envs[len(envs)-1] - if last.Type != proto.TypeDone { - t.Fatalf("last env = %q, want done", last.Type) - } - done := decodePayload[proto.DonePayload](t, last) - if done.Content != "hello" { - t.Fatalf("done content = %q, want hello", done.Content) - } - if done.Metadata[proto.DoneMetaAgentSessionID] != "sess-abc" { - t.Fatalf("done metadata = %#v, want agent_session_id sess-abc", done.Metadata) - } - if done.Metadata[proto.DoneMetaAgentSessionType] != "pi_session" { - t.Fatalf("done metadata = %#v, want pi_session", done.Metadata) - } -} - -func TestTerminalErrorIncludesStderr(t *testing.T) { - tr := pi.NewTranslatorForTest("run-e") - if _, err := tr.Translate([]byte(`{"type":"session","id":"sess-failed","cwd":"/x","timestamp":"t"}`)); err != nil { - t.Fatalf("Translate header: %v", err) - } - envs := tr.TerminalEnvelopes(errors.New("exit status 2"), "bad auth", false) - if len(envs) < 2 || envs[0].Type != proto.TypeError || envs[len(envs)-1].Type != proto.TypeDone { - t.Fatalf("error terminal envs = %#v", envs) - } - errPayload := decodePayload[proto.ErrorPayload](t, envs[0]) - if !strings.Contains(errPayload.Error, "bad auth") { - t.Fatalf("error payload = %#v", errPayload) - } - done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) - if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { - t.Fatalf("failed pi process must not persist session metadata: %#v", done.Metadata) - } -} - -func TestMessageEndContentFallbackWhenNoDeltas(t *testing.T) { - tr := pi.NewTranslatorForTest("run-f") - line := `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"final answer"}],"provider":"anthropic","model":"m","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop"}}` - if _, err := tr.Translate([]byte(line)); err != nil { - t.Fatalf("Translate: %v", err) - } - envs := tr.TerminalEnvelopes(nil, "", false) - done := decodePayload[proto.DonePayload](t, envs[len(envs)-1]) - if done.Content != "final answer" { - t.Fatalf("done content = %q, want final answer", done.Content) - } -} - -func decodePayload[T any](t *testing.T, env proto.Envelope) T { - t.Helper() - var out T - if err := json.Unmarshal(env.Payload, &out); err != nil { - t.Fatalf("decode %s payload: %v", env.Type, err) - } - return out -} diff --git a/apps/daemon/internal/agent/pi/provider_config.go b/apps/daemon/internal/agent/pi/provider_config.go deleted file mode 100644 index 0d4a7f3b5..000000000 --- a/apps/daemon/internal/agent/pi/provider_config.go +++ /dev/null @@ -1,211 +0,0 @@ -package pi - -import ( - "encoding/json" - "fmt" - "maps" - "os" - "path/filepath" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" -) - -// piManagedProviderSlug belongs to the separate Parsar product integration. -// Its server pins opts["model"] to "parsar/"; preserve that external -// identity while the daemon uses the shared OpenAgentCore Runtime settings. -const piManagedProviderSlug = "parsar" - -// piAgentDirEnvVar is pi's sole override for its config directory -// (config.ts ENV_AGENT_DIR); pi reads models.json from /models.json. -const piAgentDirEnvVar = "PI_CODING_AGENT_DIR" - -type piProviderConfig struct { - Name string - BaseURL string - API string - APIKeyEnv string - Model string - Headers map[string]string - AuthHeader bool -} - -func writePiModelsJSON(agentDir string, cfg piProviderConfig) error { - if strings.TrimSpace(cfg.BaseURL) == "" { - return fmt.Errorf("pi: provider base_url is required") - } - if strings.TrimSpace(cfg.API) == "" { - return fmt.Errorf("pi: provider api is required") - } - if strings.TrimSpace(cfg.APIKeyEnv) == "" { - return fmt.Errorf("pi: provider api_key_env is required") - } - if strings.TrimSpace(cfg.Model) == "" { - return fmt.Errorf("pi: provider model is required") - } - if err := os.MkdirAll(agentDir, 0o700); err != nil { - return fmt.Errorf("pi: mkdir agent dir %s: %w", agentDir, err) - } - - provider := map[string]any{ - "baseUrl": cfg.BaseURL, - "api": cfg.API, - // pi runs apiKey through resolveConfigValue (resolve-config-value.ts): - // only a "$NAME" / "${NAME}" template is looked up in process.env; a - // bare string is treated as a LITERAL key. So the env var name must be - // written with a "$" prefix, otherwise pi sends "OAC_RUNTIME_PI_API_KEY" - // verbatim to the provider and the request 401s. - "apiKey": "$" + cfg.APIKeyEnv, - "models": []map[string]any{{"id": cfg.Model}}, - } - if cfg.Name != "" { - provider["name"] = cfg.Name - } - if len(cfg.Headers) > 0 { - provider["headers"] = cfg.Headers - } - if cfg.AuthHeader { - provider["authHeader"] = true - } - - doc := map[string]any{"providers": map[string]any{piManagedProviderSlug: provider}} - data, err := json.MarshalIndent(doc, "", " ") - if err != nil { - return fmt.Errorf("pi: marshal models.json: %w", err) - } - path := filepath.Join(agentDir, "models.json") - if err := os.WriteFile(path, data, 0o600); err != nil { - return fmt.Errorf("pi: write %s: %w", path, err) - } - return nil -} - -// normalisePiProvider flattens agent_options["pi_provider"] (the string-keyed -// map injectPiManagedModel emits) into a typed piProviderConfig. Returns -// hasProvider=false when the key is absent so callers skip materialisation. -// Required-field validation lives in writePiModelsJSON (single source). -func normalisePiProvider(raw any) (piProviderConfig, bool, error) { - if raw == nil { - return piProviderConfig{}, false, nil - } - m, ok := raw.(map[string]any) - if !ok { - return piProviderConfig{}, false, fmt.Errorf("pi: pi_provider must be object, got %T", raw) - } - cfg := piProviderConfig{ - Name: stringOpt(m, "name"), - BaseURL: stringOpt(m, "base_url"), - API: stringOpt(m, "api"), - APIKeyEnv: stringOpt(m, "api_key_env"), - Model: stringOpt(m, "model"), - } - if v, ok := m["auth_header"].(bool); ok { - cfg.AuthHeader = v - } - if hdrs, ok := m["headers"].(map[string]any); ok { - cfg.Headers = make(map[string]string, len(hdrs)) - for k, v := range hdrs { - if s, ok := v.(string); ok { - cfg.Headers[k] = s - } - } - } - return cfg, true, nil -} - -// resolveAgentDir returns the directory set as PI_CODING_AGENT_DIR for this -// prompt. AgentStateKey is preferred because it scopes by conversation, agent, -// and engine; conversation/run fallbacks exist for older callers and tests. -func resolveAgentDir(agentStateKey, conversationID, runID string) (string, error) { - root, err := paths.Root() - if err != nil { - return "", fmt.Errorf("pi: resolve state root: %w", err) - } - base := filepath.Join(root, "runtime", "pi") - if key := strings.TrimSpace(agentStateKey); key != "" { - parts := safeStatePathParts(key) - if len(parts) == 0 { - return "", fmt.Errorf("pi: invalid agentStateKey %q", agentStateKey) - } - dirParts := append([]string{base, "state"}, parts...) - return filepath.Join(append(dirParts, "agent")...), nil - } - if id := strings.TrimSpace(conversationID); id != "" { - return filepath.Join(base, "conv-"+id, "agent"), nil - } - return filepath.Join(base, "run-"+strings.TrimSpace(runID), "agent"), nil -} - -func resolveSessionDir(agentDir string) (string, error) { - sessionDir := filepath.Join(agentDir, "sessions") - if err := os.MkdirAll(sessionDir, 0o700); err != nil { - return "", fmt.Errorf("pi: mkdir session dir %s: %w", sessionDir, err) - } - return sessionDir, nil -} - -// applyPiRuntimeState returns a clone of opts with a stable pi --session-dir. -// When opts["pi_provider"] is present it also writes models.json and injects -// PI_CODING_AGENT_DIR into opts["env"] so buildEnv forwards it. -func applyPiRuntimeState(opts map[string]any, agentStateKey, conversationID, runID string) (map[string]any, error) { - agentDir, err := resolveAgentDir(agentStateKey, conversationID, runID) - if err != nil { - return opts, err - } - sessionDir, err := resolveSessionDir(agentDir) - if err != nil { - return opts, err - } - - out := cloneAgentOptions(opts) - out["session_dir"] = sessionDir - - cfg, ok, err := normalisePiProvider(opts["pi_provider"]) - if err != nil { - return opts, err - } - if !ok { - return out, nil - } - if err := writePiModelsJSON(agentDir, cfg); err != nil { - return opts, err - } - out["env"] = withAgentDirEnv(opts["env"], agentDir) - return out, nil -} - -func safeStatePathParts(key string) []string { - rawParts := strings.Split(key, "/") - parts := make([]string, 0, len(rawParts)) - for _, part := range rawParts { - if safe := safeStatePathPart(part); safe != "" { - parts = append(parts, safe) - } - } - return parts -} - -func safeStatePathPart(part string) string { - var b strings.Builder - for _, r := range strings.TrimSpace(part) { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { - b.WriteRune(r) - } else { - b.WriteByte('_') - } - } - out := b.String() - if out == "." || out == ".." { - return "" - } - return out -} - -func withAgentDirEnv(existing any, agentDir string) map[string]any { - out := map[string]any{} - if m, ok := existing.(map[string]any); ok { - maps.Copy(out, m) - } - out[piAgentDirEnvVar] = agentDir - return out -} diff --git a/apps/daemon/internal/agent/pi/provider_config_test.go b/apps/daemon/internal/agent/pi/provider_config_test.go deleted file mode 100644 index d6c268fac..000000000 --- a/apps/daemon/internal/agent/pi/provider_config_test.go +++ /dev/null @@ -1,308 +0,0 @@ -package pi - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -// modelsFile mirrors the subset of pi's models.json schema this adapter -// emits (packages/coding-agent/src/core/model-registry.ts ProviderConfigSchema). -type modelsFile struct { - Providers map[string]struct { - Name string `json:"name"` - BaseURL string `json:"baseUrl"` - APIKey string `json:"apiKey"` - API string `json:"api"` - Headers map[string]string `json:"headers"` - AuthHeader bool `json:"authHeader"` - Models []struct { - ID string `json:"id"` - } `json:"models"` - } `json:"providers"` -} - -func readModelsJSON(t *testing.T, dir string) modelsFile { - t.Helper() - raw, err := os.ReadFile(filepath.Join(dir, "models.json")) - if err != nil { - t.Fatalf("read models.json: %v", err) - } - var mf modelsFile - if err := json.Unmarshal(raw, &mf); err != nil { - t.Fatalf("models.json is not valid JSON: %v\n%s", err, raw) - } - return mf -} - -func TestWritePiModelsJSON_AnthropicProvider(t *testing.T) { - dir := t.TempDir() - cfg := piProviderConfig{ - Name: "Parsar Anthropic", - BaseURL: "https://platform-api.example.com", - API: "anthropic-messages", - APIKeyEnv: "OAC_RUNTIME_PI_API_KEY", - Model: "claude-opus-4-6-thinking-max", - Headers: map[string]string{"X-Sub-Module": "claude-code-internal"}, - } - if err := writePiModelsJSON(dir, cfg); err != nil { - t.Fatalf("writePiModelsJSON: %v", err) - } - - p, ok := readModelsJSON(t, dir).Providers[piManagedProviderSlug] - if !ok { - t.Fatalf("models.json missing provider %q", piManagedProviderSlug) - } - if p.BaseURL != cfg.BaseURL { - t.Errorf("baseUrl = %q, want %q", p.BaseURL, cfg.BaseURL) - } - if p.API != "anthropic-messages" { - t.Errorf("api = %q, want anthropic-messages", p.API) - } - // pi's resolveConfigValue() only resolves a "$NAME" template from - // process.env; a bare string is a literal key. So the env var name must - // be written with a "$" prefix. - if p.APIKey != "$OAC_RUNTIME_PI_API_KEY" { - t.Errorf("apiKey = %q, want $OAC_RUNTIME_PI_API_KEY (env ref, with $)", p.APIKey) - } - if p.Headers["X-Sub-Module"] != "claude-code-internal" { - t.Errorf("headers[X-Sub-Module] = %q, want claude-code-internal", p.Headers["X-Sub-Module"]) - } - if len(p.Models) != 1 || p.Models[0].ID != cfg.Model { - t.Errorf("models = %+v, want one model id %q", p.Models, cfg.Model) - } - if p.Name != cfg.Name { - t.Errorf("name = %q, want %q", p.Name, cfg.Name) - } - // anthropic-messages carries auth via x-api-key (the resolved apiKey), - // not an Authorization bearer header, so authHeader must stay false. - if p.AuthHeader { - t.Errorf("authHeader = true, want false for anthropic-messages") - } -} - -func TestWritePiModelsJSON_OpenAIAuthHeader(t *testing.T) { - dir := t.TempDir() - cfg := piProviderConfig{ - BaseURL: "https://gw.example.com/v1", - API: "openai-completions", - APIKeyEnv: "OAC_RUNTIME_PI_API_KEY", - Model: "gpt-5.5", - AuthHeader: true, - } - if err := writePiModelsJSON(dir, cfg); err != nil { - t.Fatalf("writePiModelsJSON: %v", err) - } - p := readModelsJSON(t, dir).Providers[piManagedProviderSlug] - if p.API != "openai-completions" { - t.Errorf("api = %q, want openai-completions", p.API) - } - if !p.AuthHeader { - t.Errorf("authHeader = false, want true so pi sends Authorization: Bearer") - } -} - -func TestWritePiModelsJSON_RejectsMissingFields(t *testing.T) { - base := piProviderConfig{ - BaseURL: "https://x/v1", - API: "anthropic-messages", - APIKeyEnv: "OAC_RUNTIME_PI_API_KEY", - Model: "m", - } - cases := map[string]func(*piProviderConfig){ - "missing base_url": func(c *piProviderConfig) { c.BaseURL = "" }, - "missing api": func(c *piProviderConfig) { c.API = "" }, - "missing api_key_env": func(c *piProviderConfig) { c.APIKeyEnv = "" }, - "missing model": func(c *piProviderConfig) { c.Model = "" }, - } - for name, mutate := range cases { - t.Run(name, func(t *testing.T) { - cfg := base - mutate(&cfg) - dir := t.TempDir() - if err := writePiModelsJSON(dir, cfg); err == nil { - t.Fatalf("expected error for %s, got nil", name) - } - if _, err := os.Stat(filepath.Join(dir, "models.json")); err == nil { - t.Fatalf("%s: models.json must not be written on invalid config", name) - } - }) - } -} - -func TestNormalisePiProvider_FullRoundTrip(t *testing.T) { - raw := map[string]any{ - "name": "Parsar Anthropic", - "base_url": "https://platform-api.example.com", - "api": "openai-completions", - "api_key_env": "OAC_RUNTIME_PI_API_KEY", - "model": "gpt-5.5", - "auth_header": true, - // Headers cross the daemon boundary as JSON, so they arrive as - // map[string]any even though the server typed them map[string]string. - "headers": map[string]any{"X-Sub-Module": "codex-internal"}, - } - cfg, ok, err := normalisePiProvider(raw) - if err != nil { - t.Fatalf("normalisePiProvider: %v", err) - } - if !ok { - t.Fatal("hasProvider must be true for non-nil raw") - } - if cfg.Name != "Parsar Anthropic" || cfg.BaseURL != "https://platform-api.example.com" { - t.Fatalf("scalar fields wrong: %+v", cfg) - } - if cfg.API != "openai-completions" || cfg.APIKeyEnv != "OAC_RUNTIME_PI_API_KEY" || cfg.Model != "gpt-5.5" { - t.Fatalf("scalar fields wrong: %+v", cfg) - } - if !cfg.AuthHeader { - t.Fatalf("auth_header lost: %+v", cfg) - } - if cfg.Headers["X-Sub-Module"] != "codex-internal" { - t.Fatalf("headers lost: %+v", cfg.Headers) - } -} - -func TestNormalisePiProvider_Nil(t *testing.T) { - cfg, ok, err := normalisePiProvider(nil) - if err != nil { - t.Fatalf("normalisePiProvider nil: %v", err) - } - if ok { - t.Fatal("hasProvider must be false for nil") - } - _ = cfg -} - -func TestNormalisePiProvider_WrongType(t *testing.T) { - if _, _, err := normalisePiProvider("not-an-object"); err == nil { - t.Fatal("expected error for non-object pi_provider") - } -} - -func TestResolveAgentDirConversationScoped(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - got, err := resolveAgentDir("", "conv-abc", "run-1") - if err != nil { - t.Fatalf("resolveAgentDir: %v", err) - } - // Sibling of resolveSkillsRoot's conv-/skills so one conversation's - // pi runtime state (models.json, sessions) co-locates under one dir. - want := filepath.Join(tmp, ".oac", "runtime", "pi", "conv-conv-abc", "agent") - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} - -func TestResolveAgentDirStateKeyScoped(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - got, err := resolveAgentDir("conv-abc/agent-xyz/pi", "ignored-conv", "run-1") - if err != nil { - t.Fatalf("resolveAgentDir: %v", err) - } - want := filepath.Join(tmp, ".oac", "runtime", "pi", "state", "conv-abc", "agent-xyz", "pi", "agent") - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} - -func TestResolveAgentDirSanitizesStateKey(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - got, err := resolveAgentDir("../conv abc/agent:xyz/pi", "ignored-conv", "run-1") - if err != nil { - t.Fatalf("resolveAgentDir: %v", err) - } - want := filepath.Join(tmp, ".oac", "runtime", "pi", "state", "conv_abc", "agent_xyz", "pi", "agent") - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} - -func TestResolveAgentDirRunScopedFallback(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - got, err := resolveAgentDir("", "", "run-9") - if err != nil { - t.Fatalf("resolveAgentDir: %v", err) - } - want := filepath.Join(tmp, ".oac", "runtime", "pi", "run-run-9", "agent") - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} - -func TestApplyPiRuntimeState_WritesModelsSetsEnvAndSessionDir(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - callerEnv := map[string]any{"OAC_RUNTIME_PI_API_KEY": "sk-proxy", "OTHER": "x"} - opts := map[string]any{ - "model": "parsar/claude-opus-4-6-thinking-max", - "env": callerEnv, - "pi_provider": map[string]any{ - "base_url": "https://platform-api.example.com", - "api": "anthropic-messages", - "api_key_env": "OAC_RUNTIME_PI_API_KEY", - "model": "claude-opus-4-6-thinking-max", - "headers": map[string]any{"X-Sub-Module": "claude-code-internal"}, - }, - } - - out, err := applyPiRuntimeState(opts, "conv-xyz/agent-1/pi", "ignored-conv", "run-1") - if err != nil { - t.Fatalf("applyPiRuntimeState: %v", err) - } - - agentDir := filepath.Join(tmp, ".oac", "runtime", "pi", "state", "conv-xyz", "agent-1", "pi", "agent") - sessionDir := filepath.Join(agentDir, "sessions") - env, ok := out["env"].(map[string]any) - if !ok { - t.Fatalf("out[env] not a map: %T", out["env"]) - } - if env["PI_CODING_AGENT_DIR"] != agentDir { - t.Errorf("PI_CODING_AGENT_DIR = %v, want %q", env["PI_CODING_AGENT_DIR"], agentDir) - } - if env["OAC_RUNTIME_PI_API_KEY"] != "sk-proxy" || env["OTHER"] != "x" { - t.Errorf("pre-existing env not preserved: %+v", env) - } - if out["session_dir"] != sessionDir { - t.Errorf("session_dir = %v, want %q", out["session_dir"], sessionDir) - } - if info, err := os.Stat(sessionDir); err != nil || !info.IsDir() { - t.Fatalf("session dir not created at %s: %v", sessionDir, err) - } - - p := readModelsJSON(t, agentDir).Providers[piManagedProviderSlug] - if p.APIKey != "$OAC_RUNTIME_PI_API_KEY" || p.BaseURL != "https://platform-api.example.com" { - t.Errorf("models.json not materialised correctly: %+v", p) - } - - // The caller's env map must be untouched — buildEnv reads opts["env"] - // and a shared reference would leak PI_CODING_AGENT_DIR back to the - // server-owned options map across turns. - if _, leaked := callerEnv["PI_CODING_AGENT_DIR"]; leaked { - t.Error("applyPiRuntimeState mutated the caller's env map") - } -} - -func TestApplyPiRuntimeState_NoProviderStillPinsSessionDir(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - opts := map[string]any{"model": "anthropic/x"} - out, err := applyPiRuntimeState(opts, "conv-1/agent-1/pi", "ignored-conv", "run-1") - if err != nil { - t.Fatalf("applyPiRuntimeState: %v", err) - } - if env, ok := out["env"].(map[string]any); ok { - if _, set := env["PI_CODING_AGENT_DIR"]; set { - t.Fatal("PI_CODING_AGENT_DIR must not be set when no pi_provider present") - } - } - want := filepath.Join(tmp, ".oac", "runtime", "pi", "state", "conv-1", "agent-1", "pi", "agent", "sessions") - if out["session_dir"] != want { - t.Fatalf("session_dir = %v, want %q", out["session_dir"], want) - } -} diff --git a/apps/daemon/internal/agent/pi/session.go b/apps/daemon/internal/agent/pi/session.go deleted file mode 100644 index 6563f2fa4..000000000 --- a/apps/daemon/internal/agent/pi/session.go +++ /dev/null @@ -1,215 +0,0 @@ -// Package pi is the agent_kind="pi" adapter. It drives the pi CLI via -// `pi --mode json -p `, translating pi's NDJSON event stream on -// stdout into proto.Envelope frames for the dispatch router. -package pi - -import ( - "bufio" - "bytes" - "context" - "errors" - "fmt" - "io" - "log/slog" - "os" - "sync" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" -) - -type sessionConfig struct { - piBinary string - extraArgs []string - killTimeout time.Duration - logger *slog.Logger -} - -func defaultConfig() sessionConfig { - return sessionConfig{piBinary: defaultBinary(), killTimeout: 3 * time.Second, logger: obslog.Bg()} -} - -// Factory implements agent.Factory for agent_kind="pi". -func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - return newSession(ctx, req, out, defaultConfig()) -} - -// Session wraps a single `pi --mode json` subprocess. -type Session struct { - runID string - cfg sessionConfig - - proc *clirunner.Process - out chan<- proto.Envelope - - cancelCtx context.Context - - cancelOnce sync.Once - closeOutOnce sync.Once - cleanup func() - - stderrMu sync.Mutex - stderr bytes.Buffer -} - -var _ agent.Session = (*Session)(nil) - -func newSession(parent context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope, cfg sessionConfig) (*Session, error) { - if out == nil { - return nil, errors.New("pi: nil out channel") - } - if cfg.logger == nil { - cfg.logger = obslog.Bg() - } - if cfg.piBinary == "" { - cfg.piBinary = defaultBinary() - } - if cfg.killTimeout <= 0 { - cfg.killTimeout = 3 * time.Second - } - - // pi needs an explicit --skill flag per skill (unlike Claude Code's - // auto-scan), so installSkills returns dirs even on a cache hit. - opts := req.AgentOptions - if rawSkills, ok := opts["skills"]; ok { - descriptors, decodeWarns := decodeSkillDescriptors(rawSkills) - for _, w := range decodeWarns { - cfg.logger.Warn("pi: skill descriptor decode warning", "run_id", req.RunID, "msg", w) - } - root, rErr := resolveSkillsRoot(req.ConversationID, req.RunID) - if rErr != nil { - return nil, fmt.Errorf("pi: resolve skills root: %w", rErr) - } - installRes, iErr := installSkills(parent, cfg.logger, root, descriptors) - if iErr != nil { - return nil, fmt.Errorf("pi: install skills: %w", iErr) - } - for _, w := range installRes.Warnings { - cfg.logger.Warn("pi: skill install warning", "run_id", req.RunID, "msg", w) - } - if len(installRes.SkillDirs) > 0 { - opts = cloneAgentOptions(req.AgentOptions) - opts["skill_dirs"] = mergeSkillDirs(opts["skill_dirs"], installRes.SkillDirs) - } - } - - // Materialise pi's managed config and pin --session-dir to the stable - // conversation/agent/engine state key so --session can resolve reliably. - provOpts, provErr := applyPiRuntimeState(opts, req.AgentStateKey, req.ConversationID, req.RunID) - if provErr != nil { - return nil, fmt.Errorf("pi: apply managed provider: %w", provErr) - } - opts = provOpts - - prompt, err := req.Input.TextOnly() - if err != nil { - return nil, err - } - buildRes, err := BuildArgs(req.RunID, prompt, req.WorkDir, opts, req.AgentSessionID) - if err != nil { - return nil, fmt.Errorf("pi: build args: %w", err) - } - args := append([]string{}, buildRes.Args...) - args = append(args, cfg.extraArgs...) - dir := "" - if buildRes.WorkDir != "" { - dir = buildRes.WorkDir - } - proc, err := clirunner.Start(clirunner.StartOptions{ - Parent: parent, - Binary: cfg.piBinary, - Args: args, - Dir: dir, - Env: append(os.Environ(), buildRes.Env...), - KillTimeout: cfg.killTimeout, - }) - if err != nil { - buildRes.Cleanup() - return nil, fmt.Errorf("pi: start %q: %w", cfg.piBinary, err) - } - - s := &Session{ - runID: req.RunID, - cfg: cfg, - proc: proc, - out: out, - cancelCtx: proc.Context(), - cleanup: buildRes.Cleanup, - } - go s.pumpStderr(proc.Stderr) - go s.run(proc.Stdout) - return s, nil -} - -func (s *Session) Cancel(context.Context) error { - s.cancelOnce.Do(func() { - s.proc.Cancel() - }) - return nil -} - -func (s *Session) run(stdout io.Reader) { - defer s.cleanup() - defer s.closeOut() - - tr := newTranslator(s.runID) - sc := bufio.NewScanner(stdout) - sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) - for sc.Scan() { - tx, err := tr.Translate(sc.Bytes()) - if err != nil { - s.cfg.logger.Warn("pi: translate line", "run_id", s.runID, "err", err) - continue - } - for _, env := range tx.Envelopes { - select { - case s.out <- env: - case <-s.cancelCtx.Done(): - _ = s.proc.Wait() - return - } - } - } - if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { - s.cfg.logger.Warn("pi: scan stdout", "run_id", s.runID, "err", err) - } - - waitErr := s.proc.Wait() - for _, env := range tr.terminalEnvelopes(waitErr, s.stderrString(), s.cancelCtx.Err() != nil) { - s.trySend(env) - } -} - -func (s *Session) pumpStderr(stderr io.Reader) { - sc := bufio.NewScanner(stderr) - sc.Buffer(make([]byte, 0, 16*1024), 1<<20) - for sc.Scan() { - line := sc.Text() - s.stderrMu.Lock() - if s.stderr.Len() > 0 { - s.stderr.WriteByte('\n') - } - s.stderr.WriteString(line) - s.stderrMu.Unlock() - s.cfg.logger.Warn("pi stderr", "run_id", s.runID, "line", line) - } -} - -func (s *Session) stderrString() string { - s.stderrMu.Lock() - defer s.stderrMu.Unlock() - return s.stderr.String() -} - -func (s *Session) trySend(env proto.Envelope) { - select { - case s.out <- env: - case <-time.After(2 * time.Second): - s.cfg.logger.Warn("pi: terminal send timed out", "type", env.Type, "run_id", s.runID) - } -} - -func (s *Session) closeOut() { s.closeOutOnce.Do(func() { close(s.out) }) } diff --git a/apps/daemon/internal/agent/pi/session_provider_test.go b/apps/daemon/internal/agent/pi/session_provider_test.go deleted file mode 100644 index d3ce2f035..000000000 --- a/apps/daemon/internal/agent/pi/session_provider_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package pi - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// TestNewSessionMaterialisesPiProviderModelsJSON proves agent_options["pi_provider"] -// flows through newSession → models.json on disk at the per-conversation agent -// dir. The env-forwarding of PI_CODING_AGENT_DIR is covered by the -// applyPiRuntimeState + buildEnv unit tests. -func TestNewSessionMaterialisesPiProviderModelsJSON(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - out := make(chan proto.Envelope, 64) - req := proto.PromptRequestPayload{ - RunID: "run_prov", - ConversationID: "conv-prov", - AgentStateKey: "conv-prov/agent-prov/pi", - Input: proto.TextInput("hello"), - AgentOptions: map[string]any{ - "model": "parsar/claude-opus-4-6-thinking-max", - "pi_provider": map[string]any{ - "base_url": "https://platform-api.example.com", - "api": "anthropic-messages", - "api_key_env": "OAC_RUNTIME_PI_API_KEY", - "model": "claude-opus-4-6-thinking-max", - "headers": map[string]any{"X-Sub-Module": "claude-code-internal"}, - }, - "env": map[string]any{ - "PI_TESTHELPER_ROLE": "json-success", - "OAC_RUNTIME_PI_API_KEY": "sk-proxy", - }, - }, - } - sess, err := newSession(context.Background(), req, out, sessionConfig{ - piBinary: os.Args[0], - extraArgs: []string{"-test.run=^$"}, - killTimeout: 200 * time.Millisecond, - }) - if err != nil { - t.Fatalf("newSession: %v", err) - } - defer sess.Cancel(context.Background()) - - deadline := time.After(5 * time.Second) - for draining := true; draining; { - select { - case _, ok := <-out: - if !ok { - draining = false - } - case <-deadline: - t.Fatal("out did not close") - } - } - - agentDir := filepath.Join(home, ".oac", "runtime", "pi", "state", "conv-prov", "agent-prov", "pi", "agent") - p := readModelsJSON(t, agentDir).Providers[piManagedProviderSlug] - if p.BaseURL != "https://platform-api.example.com" { - t.Fatalf("models.json baseUrl wrong: %+v", p) - } - if p.APIKey != "$OAC_RUNTIME_PI_API_KEY" { - t.Fatalf("models.json apiKey = %q, want $OAC_RUNTIME_PI_API_KEY", p.APIKey) - } - if _, err := os.Stat(filepath.Join(agentDir, "sessions")); err != nil { - t.Fatalf("session dir not created: %v", err) - } -} diff --git a/apps/daemon/internal/agent/pi/session_skills_test.go b/apps/daemon/internal/agent/pi/session_skills_test.go deleted file mode 100644 index fe10a4fc3..000000000 --- a/apps/daemon/internal/agent/pi/session_skills_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package pi - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// TestNewSessionInstallsSkillsAndInjectsSkillFlag is the end-to-end proof -// that agent_options["skills"] flows download → disk → repeated --skill -// argv on the pi subprocess. The fake pi records its argv into -// PI_TESTHELPER_ARGS_FILE (see runFakePi). -func TestNewSessionInstallsSkillsAndInjectsSkillFlag(t *testing.T) { - body := validSkillZip(t) - srv := startZipServer(t, body) - home := t.TempDir() - t.Setenv("HOME", home) - - argsFile := filepath.Join(t.TempDir(), "argv") - out := make(chan proto.Envelope, 64) - req := proto.PromptRequestPayload{ - RunID: "run_skill", - ConversationID: "conv-skill", - Input: proto.TextInput("hello"), - AgentOptions: map[string]any{ - "skills": []any{ - map[string]any{ - "name": "code-review", "version": "1.0.0", - "download_url": srv.URL, "sha256": sha256Hex(body), - }, - }, - "env": map[string]any{ - "PI_TESTHELPER_ROLE": "json-success", - "PI_TESTHELPER_ARGS_FILE": argsFile, - }, - }, - } - sess, err := newSession(context.Background(), req, out, sessionConfig{ - piBinary: os.Args[0], - extraArgs: []string{"-test.run=^$"}, - killTimeout: 200 * time.Millisecond, - }) - if err != nil { - t.Fatalf("newSession: %v", err) - } - defer sess.Cancel(context.Background()) - - deadline := time.After(5 * time.Second) - for draining := true; draining; { - select { - case _, ok := <-out: - if !ok { - draining = false - } - case <-deadline: - t.Fatal("out did not close") - } - } - - wantDir := filepath.Join(home, ".oac", "runtime", "pi", "conv-conv-skill", "skills", "code-review") - if _, err := os.Stat(filepath.Join(wantDir, "SKILL.md")); err != nil { - t.Fatalf("SKILL.md not installed at %s: %v", wantDir, err) - } - raw, err := os.ReadFile(argsFile) - if err != nil { - t.Fatalf("read args file: %v", err) - } - argv := strings.Split(string(raw), "\n") - if !containsArgPair(argv, "--skill", wantDir) { - t.Fatalf("argv missing --skill %s: %v", wantDir, argv) - } -} - -func containsArgPair(argv []string, flag, val string) bool { - for i, a := range argv { - if a == flag && i+1 < len(argv) && argv[i+1] == val { - return true - } - } - return false -} diff --git a/apps/daemon/internal/agent/pi/session_test.go b/apps/daemon/internal/agent/pi/session_test.go deleted file mode 100644 index c9ad07c29..000000000 --- a/apps/daemon/internal/agent/pi/session_test.go +++ /dev/null @@ -1,331 +0,0 @@ -package pi_test - -import ( - "context" - "encoding/json" - "os" - "slices" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -// TestMain re-execs the test binary as a fake `pi` when -// PI_TESTHELPER_ROLE is set, bypassing m.Run so the framework's PASS -// line doesn't pollute fake stdout. -const piHelperEnvKey = "PI_TESTHELPER_ROLE" - -func TestMain(m *testing.M) { - if role := os.Getenv(piHelperEnvKey); role != "" { - runFakePi(role) - os.Exit(0) - } - os.Exit(m.Run()) -} - -func runFakePi(role string) { - enc := json.NewEncoder(os.Stdout) - enc.SetEscapeHTML(false) - - // Record argv so the skill-injection test can assert --skill - // reached the subprocess. - if argsFile := os.Getenv("PI_TESTHELPER_ARGS_FILE"); argsFile != "" { - _ = os.WriteFile(argsFile, []byte(strings.Join(os.Args, "\n")), 0o644) - } - - sawModeJSON := false - for i, arg := range os.Args { - if arg == "--mode" && i+1 < len(os.Args) && os.Args[i+1] == "json" { - sawModeJSON = true - } - } - - header := map[string]any{"type": "session", "id": "sess-xyz", "cwd": "/", "timestamp": "t"} - delta := func(s string) map[string]any { - return map[string]any{ - "type": "message_update", - "message": map[string]any{"role": "assistant"}, - "assistantMessageEvent": map[string]any{"type": "text_delta", "contentIndex": 0, "delta": s}, - } - } - - switch role { - case "json-success": - if !sawModeJSON { - _, _ = os.Stderr.WriteString("missing --mode json\n") - os.Exit(64) - } - _ = enc.Encode(header) - _ = enc.Encode(delta("hi ")) - _ = enc.Encode(delta("there")) - _ = enc.Encode(map[string]any{ - "type": "message_end", - "message": map[string]any{ - "role": "assistant", - "content": []any{map[string]any{"type": "text", "text": "hi there"}}, - "provider": "anthropic", - "model": "claude-x", - "stopReason": "stop", - "usage": map[string]any{ - "input": 4, "output": 2, "cacheRead": 0, "cacheWrite": 0, - "totalTokens": 6, - "cost": map[string]any{"input": 0.1, "output": 0.02, "cacheRead": 0, "cacheWrite": 0, "total": 0.12}, - }, - }, - }) - - case "error-stop": - // pi exits 0 even when the model errors — it just emits a - // message_end with stopReason "error". - _ = enc.Encode(header) - _ = enc.Encode(map[string]any{ - "type": "message_end", - "message": map[string]any{ - "role": "assistant", - "content": []any{}, - "provider": "anthropic", - "model": "claude-x", - "stopReason": "error", - "errorMessage": "model boom", - "usage": map[string]any{ - "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0, - "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0}, - }, - }, - }) - - case "nonzero": - _, _ = os.Stderr.WriteString("bad auth from fake pi\n") - os.Exit(17) - - case "hang": - _ = enc.Encode(header) - _ = enc.Encode(delta("started")) - time.Sleep(10 * time.Minute) - } -} - -func piHelperConfig() pi.SessionConfigForTest { - return pi.SessionConfigForTest{ - PiBinary: os.Args[0], - ExtraArgs: []string{"-test.run=^$"}, - KillTimeout: 200 * time.Millisecond, - } -} - -func piHelperReq(runID, prompt, role string) proto.PromptRequestPayload { - return proto.PromptRequestPayload{ - RunID: runID, - Input: proto.TextInput(prompt), - AgentOptions: map[string]any{ - "env": map[string]any{ - piHelperEnvKey: role, - }, - }, - } -} - -func drainPi(t *testing.T, out <-chan proto.Envelope, dl time.Duration) ([]proto.Envelope, bool) { - t.Helper() - deadline := time.After(dl) - var got []proto.Envelope - for { - select { - case env, ok := <-out: - if !ok { - return got, true - } - got = append(got, env) - case <-deadline: - return got, false - } - } -} - -func TestSessionJSONSuccessEmitsDeltaUsageAndDone(t *testing.T) { - out := make(chan proto.Envelope, 32) - sess, err := pi.NewSessionForTest(context.Background(), - piHelperReq("run_json", "hello", "json-success"), out, piHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drainPi(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := piEnvTypes(got) - mustContainPi(t, types, proto.TypeDelta) - mustContainPi(t, types, proto.TypeUsage) - mustContainPi(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } - for _, env := range got { - if env.ID != "run_json" { - t.Errorf("env type=%s ID=%q, want run_json", env.Type, env.ID) - } - } - done := decodePayload[proto.DonePayload](t, got[len(got)-1]) - if done.Content != "hi there" { - t.Fatalf("done content = %q, want hi there", done.Content) - } - if done.Usage.Provider != "anthropic" || done.Usage.InputTokens != 4 || done.Usage.OutputTokens != 2 { - t.Fatalf("done usage = %#v", done.Usage) - } - if done.Metadata[proto.DoneMetaAgentSessionID] != "sess-xyz" { - t.Fatalf("done metadata = %#v, want agent_session_id sess-xyz", done.Metadata) - } - if done.Metadata[proto.DoneMetaAgentSessionType] != "pi_session" { - t.Fatalf("done metadata = %#v, want pi_session", done.Metadata) - } -} - -// pi exits 0 on a model error: the session must still surface TypeError -// (from stopReason) and Done, and close out. -func TestSessionModelErrorStopReasonStillEmitsErrorAndDone(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := pi.NewSessionForTest(context.Background(), - piHelperReq("run_mod_err", "hello", "error-stop"), out, piHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drainPi(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := piEnvTypes(got) - mustContainPi(t, types, proto.TypeError) - mustContainPi(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } - var errPayload proto.ErrorPayload - for _, env := range got { - if env.Type == proto.TypeError { - errPayload = decodePayload[proto.ErrorPayload](t, env) - } - } - if !strings.Contains(errPayload.Error, "model boom") { - t.Fatalf("error payload = %#v, want model boom", errPayload) - } - done := decodePayload[proto.DonePayload](t, got[len(got)-1]) - if _, ok := done.Metadata[proto.DoneMetaAgentSessionID]; ok { - t.Fatalf("model error must not persist session metadata: %#v", done.Metadata) - } -} - -func TestSessionNonZeroExitEmitsErrorAndDone(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := pi.NewSessionForTest(context.Background(), - piHelperReq("run_err", "hello", "nonzero"), out, piHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - defer sess.Cancel(context.Background()) - - got, closed := drainPi(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close, drained %d envs", len(got)) - } - types := piEnvTypes(got) - mustContainPi(t, types, proto.TypeError) - mustContainPi(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } - var errPayload proto.ErrorPayload - for _, env := range got { - if env.Type == proto.TypeError { - errPayload = decodePayload[proto.ErrorPayload](t, env) - } - } - if !strings.Contains(errPayload.Error, "bad auth from fake pi") { - t.Fatalf("error payload = %#v", errPayload) - } -} - -func TestSessionCancelClosesOutAndEmitsTerminalFrames(t *testing.T) { - out := make(chan proto.Envelope, 16) - sess, err := pi.NewSessionForTest(context.Background(), - piHelperReq("run_cancel", "hello", "hang"), out, piHelperConfig()) - if err != nil { - t.Fatalf("NewSessionForTest: %v", err) - } - - time.Sleep(150 * time.Millisecond) - if err := sess.Cancel(context.Background()); err != nil { - t.Errorf("Cancel: %v", err) - } - - got, closed := drainPi(t, out, 5*time.Second) - if !closed { - t.Fatalf("out did not close after Cancel, drained %d envs", len(got)) - } - types := piEnvTypes(got) - mustContainPi(t, types, proto.TypeDone) - if got[len(got)-1].Type != proto.TypeDone { - t.Fatalf("last env type = %q, want done; all=%v", got[len(got)-1].Type, types) - } -} - -func TestSessionDoesNotDeclareHumanResponses(t *testing.T) { - var session any = (*pi.Session)(nil) - if _, ok := session.(agent.PermissionResponder); ok { - t.Fatal("unexpected permission responder") - } - if _, ok := session.(agent.UserChoiceResponder); ok { - t.Fatal("unexpected user-choice responder") - } -} - -func TestSessionRejectsNilOut(t *testing.T) { - _, err := pi.NewSessionForTest(context.Background(), - piHelperReq("run_nil", "hello", "json-success"), nil, piHelperConfig()) - if err == nil { - t.Fatal("expected error on nil out") - } -} - -func TestSessionRejectsEmptyPrompt(t *testing.T) { - out := make(chan proto.Envelope, 4) - _, err := pi.NewSessionForTest(context.Background(), - proto.PromptRequestPayload{RunID: "run_empty", Input: proto.TextInput("")}, out, piHelperConfig()) - if err == nil { - t.Fatal("expected error on empty prompt") - } -} - -func TestSessionBadBinaryFailsToStart(t *testing.T) { - out := make(chan proto.Envelope, 4) - cfg := piHelperConfig() - cfg.PiBinary = "/nonexistent/binary/that/does/not/resolve" - cfg.ExtraArgs = nil - _, err := pi.NewSessionForTest(context.Background(), - piHelperReq("run_bad", "hello", "json-success"), out, cfg) - if err == nil { - t.Fatal("expected start error for bogus binary") - } -} - -func piEnvTypes(envs []proto.Envelope) []string { - out := make([]string, len(envs)) - for i, env := range envs { - out[i] = env.Type - } - return out -} - -func mustContainPi(t *testing.T, haystack []string, needle string) { - t.Helper() - if !slices.Contains(haystack, needle) { - t.Fatalf("expected %q in %v", needle, haystack) - } -} diff --git a/apps/daemon/internal/agent/pi/skills.go b/apps/daemon/internal/agent/pi/skills.go deleted file mode 100644 index 88e7eaa53..000000000 --- a/apps/daemon/internal/agent/pi/skills.go +++ /dev/null @@ -1,393 +0,0 @@ -package pi - -import ( - "archive/zip" - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "maps" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "time" -) - -// skillDescriptor is the daemon-side view of one server-sent skill entry -// under agent_options["skills"]: -// -// { "name": "...", "version": "...", "download_url": "...", "sha256": "..." } -type skillDescriptor struct { - Name string - Version string - DownloadURL string - SHA256 string -} - -// SkillInstallResult carries the per-skill directories to feed into -// repeated `--skill ` flags plus warnings the session surfaces. -// Unlike Claude Code (which auto-scans .claude/skills/), pi needs an -// explicit flag per skill, so SkillDirs is populated even on a cache hit. -type SkillInstallResult struct { - SkillDirs []string - Warnings []string -} - -const skillInstallTimeout = 60 * time.Second - -// maxSkillZipBytes mirrors the server-side cap. Defense in depth. -const maxSkillZipBytes int64 = 32 * 1024 * 1024 - -var skillsHTTPClient = &http.Client{Timeout: skillInstallTimeout + 10*time.Second} - -// fetchSkillZip GETs url into dst, capping the body at maxSkillZipBytes. -// Returns an OPEN file descriptor at offset 0; the caller closes it. -// Holding the FD across verify + extract closes the TOCTOU between -// hashing the on-disk bytes and reading them for extract. -// -// Only http/https are accepted to defend against a future download_url -// reaching this code with file:// or http://internal-ip/... values. -func fetchSkillZip(ctx context.Context, downloadURL, dst string) (*os.File, error) { - parsed, err := url.Parse(downloadURL) - if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { - return nil, errors.New("download_url must be http(s)") - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) - if err != nil { - return nil, errors.New("build request failed") - } - resp, err := skillsHTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("get failed: %s", sanitizeHTTPClientError(err)) - } - defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) - return nil, fmt.Errorf("get: status %d", resp.StatusCode) - } - - f, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) - if err != nil { - return nil, fmt.Errorf("open dst: %w", err) - } - - limited := io.LimitReader(resp.Body, maxSkillZipBytes+1) - written, err := io.Copy(f, limited) - if err != nil { - _ = f.Close() - return nil, fmt.Errorf("copy body: %w", err) - } - if written > maxSkillZipBytes { - _ = f.Close() - return nil, fmt.Errorf("zip exceeds %d byte cap", maxSkillZipBytes) - } - if _, err := f.Seek(0, io.SeekStart); err != nil { - _ = f.Close() - return nil, fmt.Errorf("seek after write: %w", err) - } - return f, nil -} - -// sanitizeHTTPClientError strips the URL embedded by *url.Error so a -// presigned download_url (OSSAccessKeyId + Signature) never lands in the -// daemon log. Format is ` "": `. -func sanitizeHTTPClientError(err error) string { - if err == nil { - return "" - } - msg := err.Error() - open := strings.Index(msg, `"`) - if open < 0 { - return msg - } - closeRel := strings.Index(msg[open+1:], `"`) - if closeRel < 0 { - return msg - } - closeAbs := open + 1 + closeRel - if closeAbs+2 > len(msg) { - return msg - } - return msg[:open] + "" + msg[closeAbs+1:] -} - -func verifySHA256FromFD(fd *os.File, want string) error { - want = strings.ToLower(strings.TrimSpace(want)) - if want == "" { - return errors.New("verify: empty expected sha256") - } - if _, err := fd.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("verify: seek: %w", err) - } - h := sha256.New() - if _, err := io.Copy(h, fd); err != nil { - return fmt.Errorf("verify: hash: %w", err) - } - got := hex.EncodeToString(h.Sum(nil)) - if got != want { - return fmt.Errorf("verify: sha256 mismatch (want=%s got=%s)", want, got) - } - return nil -} - -// extractSkillZipFromFD reads via io.NewSectionReader rather than -// re-opening the path so the byte stream stays identical to the verified -// one (TOCTOU defense). -func extractSkillZipFromFD(fd *os.File, size int64, dst string) error { - zr, err := zip.NewReader(io.NewSectionReader(fd, 0, size), size) - if err != nil { - return fmt.Errorf("extract: open zip: %w", err) - } - - root := detectSingleZipRoot(zr.File) - absDst, err := filepath.Abs(dst) - if err != nil { - return fmt.Errorf("extract: abs dst: %w", err) - } - - for _, f := range zr.File { - name := normaliseZipPath(f.Name) - if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { - continue - } - // Skip non-regular entries (symlinks, devices). A symlink entry - // would otherwise be written as a plain file holding the link - // target string — an exfil vector. - mode := f.Mode() - if !f.FileInfo().IsDir() && !mode.IsRegular() { - continue - } - if root != "" { - if !strings.HasPrefix(name, root) { - continue - } - name = strings.TrimPrefix(name, root) - if name == "" { - continue - } - } - - target := filepath.Join(absDst, name) - rel, err := filepath.Rel(absDst, target) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return fmt.Errorf("extract: entry %q escapes target", f.Name) - } - - if f.FileInfo().IsDir() { - if err := os.MkdirAll(target, 0o755); err != nil { - return fmt.Errorf("extract: mkdir %s: %w", target, err) - } - continue - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return fmt.Errorf("extract: mkdir parent of %s: %w", target, err) - } - if err := writeZipEntry(f, target); err != nil { - return err - } - } - return nil -} - -func writeZipEntry(f *zip.File, target string) error { - rc, err := f.Open() - if err != nil { - return fmt.Errorf("extract: open entry %s: %w", f.Name, err) - } - defer rc.Close() - - mode := f.Mode().Perm() - if mode == 0 { - mode = 0o644 - } - out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) - if err != nil { - return fmt.Errorf("extract: open target %s: %w", target, err) - } - defer out.Close() - if _, err := io.Copy(out, rc); err != nil { - return fmt.Errorf("extract: copy %s: %w", target, err) - } - return nil -} - -// detectSingleZipRoot returns the common wrapping directory (with -// trailing slash) shared by every non-MACOSX entry, or "" when there is -// none. Bare directory entries (no internal "/") are skipped when picking -// the first candidate so `zip -r skill skill/` doesn't short-circuit on -// its own leading directory entry. Hidden roots (".*") are NOT treated as -// wrappers. -func detectSingleZipRoot(files []*zip.File) string { - var first string - for _, f := range files { - name := normaliseZipPath(f.Name) - if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { - continue - } - if !strings.Contains(name, "/") { - continue - } - first = name - break - } - if first == "" { - return "" - } - idx := strings.Index(first, "/") - if idx <= 0 { - return "" - } - root := first[:idx+1] - if strings.HasPrefix(root, ".") { - return "" - } - for _, f := range files { - name := normaliseZipPath(f.Name) - if name == "" || strings.HasPrefix(name, "__MACOSX/") || name == "__MACOSX" { - continue - } - if name+"/" == root { - continue - } - if !strings.HasPrefix(name, root) { - return "" - } - } - return root -} - -func normaliseZipPath(name string) string { - p := strings.ReplaceAll(name, "\\", "/") - return strings.TrimSuffix(p, "/") -} - -// decodeSkillDescriptors converts agent_options["skills"] into typed -// descriptors. Entries that fail to decode are dropped with a warning; -// the rest may still be installable. -func decodeSkillDescriptors(raw any) ([]skillDescriptor, []string) { - if raw == nil { - return nil, nil - } - items, ok := raw.([]any) - if !ok { - return nil, []string{fmt.Sprintf("agent_options[skills] must be array, got %T", raw)} - } - out := make([]skillDescriptor, 0, len(items)) - warnings := make([]string, 0) - for i, item := range items { - obj, ok := item.(map[string]any) - if !ok { - warnings = append(warnings, fmt.Sprintf("skills[%d]: not an object", i)) - continue - } - s := skillDescriptor{ - Name: stringField(obj, "name"), - Version: stringField(obj, "version"), - DownloadURL: stringField(obj, "download_url"), - SHA256: stringField(obj, "sha256"), - } - if err := s.validate(); err != nil { - warnings = append(warnings, fmt.Sprintf("skills[%d] (%s): %v", i, s.Name, err)) - continue - } - out = append(out, s) - } - return out, warnings -} - -func stringField(m map[string]any, key string) string { - if v, ok := m[key].(string); ok { - return v - } - return "" -} - -func (s skillDescriptor) validate() error { - if strings.TrimSpace(s.Name) == "" { - return errors.New("name is required") - } - // Block path-traversal names before they hit filepath.Join. - if strings.ContainsAny(s.Name, "/\\") || s.Name == "." || s.Name == ".." { - return fmt.Errorf("name %q contains path separator or dot-ref", s.Name) - } - if strings.TrimSpace(s.DownloadURL) == "" { - return errors.New("download_url is required") - } - if len(s.SHA256) != 64 { - return fmt.Errorf("sha256 must be 64 hex chars (got %d)", len(s.SHA256)) - } - return nil -} - -func (s skillDescriptor) cacheKey() string { - return fmt.Sprintf("%s@%s", strings.TrimSpace(s.Name), strings.ToLower(s.SHA256)) -} - -// resolveSkillsRoot returns the absolute directory under which managed -// skills install, one subdir per skill. Kept under ~/.oac/ (runtime -// state lives there, not the user's project tree) and scoped per -// conversation so consecutive turns reuse .cache-key files without two -// conversations racing the same skill dir. runID scopes the one-shot -// fallback when there is no conversation. -func resolveSkillsRoot(conversationID, runID string) (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("pi skills: resolve home: %w", err) - } - base := filepath.Join(home, ".oac", "runtime", "pi") - if id := strings.TrimSpace(conversationID); id != "" { - return filepath.Join(base, "conv-"+id, "skills"), nil - } - return filepath.Join(base, "run-"+strings.TrimSpace(runID), "skills"), nil -} - -// mergeSkillDirs combines a caller-supplied skill_dirs override (accepted -// as []string OR []any) with the install-resolved list, preserving order -// and deduplicating. Override wins on collision. -func mergeSkillDirs(existing any, resolved []string) []string { - preset := coerceStringSlice(existing) - seen := make(map[string]bool, len(preset)+len(resolved)) - out := make([]string, 0, len(preset)+len(resolved)) - for _, d := range append(append([]string{}, preset...), resolved...) { - if d == "" || seen[d] { - continue - } - seen[d] = true - out = append(out, d) - } - return out -} - -func coerceStringSlice(v any) []string { - switch t := v.(type) { - case nil: - return nil - case []string: - return t - case []any: - out := make([]string, 0, len(t)) - for _, item := range t { - if s, ok := item.(string); ok { - out = append(out, s) - } - } - return out - default: - return nil - } -} - -// cloneAgentOptions returns a shallow copy so we never mutate the -// caller's map when overwriting the top-level "skill_dirs" key. -func cloneAgentOptions(opts map[string]any) map[string]any { - if opts == nil { - return map[string]any{} - } - out := make(map[string]any, len(opts)) - maps.Copy(out, opts) - return out -} diff --git a/apps/daemon/internal/agent/pi/skills_concurrency_test.go b/apps/daemon/internal/agent/pi/skills_concurrency_test.go deleted file mode 100644 index d0864c5ac..000000000 --- a/apps/daemon/internal/agent/pi/skills_concurrency_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package pi - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sync/atomic" - "testing" - "time" -) - -func TestInstallSkillsConcurrentSameRoot(t *testing.T) { - const content = "Complete skill contents.\n" - body := buildZipBytes(t, []zipFile{{Name: "SKILL.md", Body: content}}) - started, release := make(chan struct{}, 8), make(chan struct{}) - var downloads atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - downloads.Add(1) - started <- struct{}{} - select { - case <-release: - _, _ = w.Write(body) - case <-r.Context().Done(): - } - })) - defer srv.Close() - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - root := t.TempDir() - skills := []skillDescriptor{{Name: "fixture", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}} - install := func(ctx context.Context) error { - result, err := installSkills(ctx, discardLogger(), root, skills) - if err != nil { - return err - } - if len(result.SkillDirs) != 1 || len(result.Warnings) != 0 { - return fmt.Errorf("dirs=%v warnings=%v", result.SkillDirs, result.Warnings) - } - data, err := os.ReadFile(filepath.Join(result.SkillDirs[0], "SKILL.md")) - if err != nil { - return err - } - if string(data) != content { - return fmt.Errorf("incomplete skill: %q", data) - } - return nil - } - results := make(chan error, 8) - go func() { results <- install(ctx) }() - select { - case <-started: - case <-ctx.Done(): - t.Fatal("first download did not start") - } - for range 7 { - go func() { results <- install(ctx) }() - } - waiting, cancelWait := context.WithTimeout(ctx, 30*time.Millisecond) - defer cancelWait() - if err := install(waiting); err != context.DeadlineExceeded { - t.Errorf("waiting install error=%v, want deadline exceeded", err) - } - close(release) - for range 8 { - if err := <-results; err != nil { - t.Error(err) - } - } - if err := install(ctx); err != nil { - t.Errorf("subsequent cached install: %v", err) - } - if downloads.Load() != 1 { - t.Errorf("downloads=%d, want one cached installation", downloads.Load()) - } -} diff --git a/apps/daemon/internal/agent/pi/skills_install.go b/apps/daemon/internal/agent/pi/skills_install.go deleted file mode 100644 index d95c73fb6..000000000 --- a/apps/daemon/internal/agent/pi/skills_install.go +++ /dev/null @@ -1,134 +0,0 @@ -package pi - -import ( - "context" - "errors" - "fmt" - "io" - "log/slog" - "os" - "path/filepath" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" - obslog "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" - "github.com/google/uuid" -) - -// installSkills materialises every skill under // and returns -// the local paths. Per skill: -// -// 1. Cache hit (/.cache-key == name@sha256) returns the dir without -// a network round-trip — but still returns it, so --skill is injected -// on every turn. -// 2. Fetch → verify SHA-256 → extract (single wrapping dir stripped, -// __MACOSX/ ignored) → stamp .cache-key. -// -// Errors during fetch/verify/extract demote one skill to a warning and -// continue. A hard error means the root dir itself was uncreatable. -func installSkills( - ctx context.Context, - logger *slog.Logger, - root string, - skills []skillDescriptor, -) (SkillInstallResult, error) { - if logger == nil { - logger = obslog.Bg() - } - if len(skills) == 0 { - return SkillInstallResult{}, nil - } - if strings.TrimSpace(root) == "" { - return SkillInstallResult{}, errors.New("pi skills: root is required") - } - unlock, err := installroot.Lock(ctx, root) - if err != nil { - return SkillInstallResult{}, err - } - defer unlock() - - result := SkillInstallResult{} - for _, s := range skills { - if err := s.validate(); err != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("skip skill (invalid descriptor): %v", err)) - logger.Warn("pi skills: invalid descriptor", "err", err.Error()) - continue - } - - dir := filepath.Join(root, s.Name) - cacheKey := filepath.Join(dir, ".cache-key") - expectedKey := s.cacheKey() - - if existing, err := os.ReadFile(cacheKey); err == nil && string(existing) == expectedKey { - logger.Info("pi skills: cache hit", "name", s.Name, "version", s.Version, "dir", dir) - result.SkillDirs = append(result.SkillDirs, dir) - continue - } - - perCtx, cancel := context.WithTimeout(ctx, skillInstallTimeout) - err := installOneSkill(perCtx, logger, root, dir, cacheKey, expectedKey, s) - cancel() - if err != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("skill %s@%s: %v", s.Name, s.Version, err)) - logger.Warn("pi skills: install failed", "name", s.Name, "version", s.Version, "err", err.Error()) - continue - } - result.SkillDirs = append(result.SkillDirs, dir) - logger.Info("pi skills: installed", "name", s.Name, "version", s.Version, "dir", dir) - } - return result, nil -} - -func installOneSkill( - ctx context.Context, - logger *slog.Logger, - root, dir, cacheKey, expectedKey string, - s skillDescriptor, -) error { - tmpDir := filepath.Join(root, ".tmp") - if err := os.MkdirAll(tmpDir, 0o755); err != nil { - return fmt.Errorf("mkdir tmp: %w", err) - } - - // Per-call uuid so concurrent installs of the same (name, version) - // don't truncate each other's bytes, and nothing on disk between - // verify and extract can be a different file than the one hashed. - zipPath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s-%s.zip", s.Name, s.Version, uuid.NewString())) - defer func() { _ = os.Remove(zipPath) }() - - fd, err := fetchSkillZip(ctx, s.DownloadURL, zipPath) - if err != nil { - return err - } - defer fd.Close() - - // Verify and extract BOTH read through the same FD (not the path): - // Unix file semantics pin the inode, so a swap on disk between - // hashing and extraction cannot change the bytes we use. - if err := verifySHA256FromFD(fd, s.SHA256); err != nil { - return err - } - if _, err := fd.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("seek: %w", err) - } - fi, err := fd.Stat() - if err != nil { - return fmt.Errorf("stat: %w", err) - } - - if err := os.RemoveAll(dir); err != nil { - return fmt.Errorf("rm old dir: %w", err) - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir target: %w", err) - } - if err := extractSkillZipFromFD(fd, fi.Size(), dir); err != nil { - _ = os.RemoveAll(dir) - return err - } - - if err := os.WriteFile(cacheKey, []byte(expectedKey), 0o644); err != nil { - logger.Warn("pi skills: write cache key failed", "path", cacheKey, "err", err.Error()) - } - return nil -} diff --git a/apps/daemon/internal/agent/pi/skills_test.go b/apps/daemon/internal/agent/pi/skills_test.go deleted file mode 100644 index b919897d3..000000000 --- a/apps/daemon/internal/agent/pi/skills_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package pi - -import ( - "archive/zip" - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -type zipFile struct { - Name string - Body string -} - -func buildZipBytes(t *testing.T, files []zipFile) []byte { - t.Helper() - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - for _, f := range files { - w, err := zw.CreateHeader(&zip.FileHeader{Name: f.Name, Method: zip.Deflate}) - if err != nil { - t.Fatalf("zip header %q: %v", f.Name, err) - } - if _, err := w.Write([]byte(f.Body)); err != nil { - t.Fatalf("zip write %q: %v", f.Name, err) - } - } - if err := zw.Close(); err != nil { - t.Fatalf("zip close: %v", err) - } - return buf.Bytes() -} - -func validSkillZip(t *testing.T) []byte { - return buildZipBytes(t, []zipFile{ - {Name: "SKILL.md", Body: "---\nname: code-review\ndescription: Review code\n---\nBody"}, - }) -} - -func sha256Hex(body []byte) string { - h := sha256.Sum256(body) - return hex.EncodeToString(h[:]) -} - -type zipServer struct { - *httptest.Server - hits *int - body []byte - stat int -} - -func startZipServer(t *testing.T, body []byte) *zipServer { - t.Helper() - var hits int - zs := &zipServer{hits: &hits, body: body, stat: http.StatusOK} - zs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - hits++ - w.WriteHeader(zs.stat) - _, _ = w.Write(zs.body) - })) - t.Cleanup(zs.Close) - return zs -} - -func (s *zipServer) Hits() int { return *s.hits } - -func discardLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} - -func TestInstallSkillsHappyPathReturnsDirAndStampsCacheKey(t *testing.T) { - body := validSkillZip(t) - srv := startZipServer(t, body) - root := t.TempDir() - - res, err := installSkills(context.Background(), discardLogger(), root, []skillDescriptor{ - {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if err != nil { - t.Fatalf("installSkills: %v", err) - } - if len(res.Warnings) != 0 { - t.Fatalf("unexpected warnings: %v", res.Warnings) - } - if len(res.SkillDirs) != 1 { - t.Fatalf("SkillDirs = %v, want 1 entry", res.SkillDirs) - } - dir := res.SkillDirs[0] - if filepath.Base(dir) != "code-review" { - t.Fatalf("dir basename = %q, want code-review", filepath.Base(dir)) - } - if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { - t.Fatalf("SKILL.md missing: %v", err) - } - stamped, err := os.ReadFile(filepath.Join(dir, ".cache-key")) - if err != nil { - t.Fatalf("read cache-key: %v", err) - } - if want := "code-review@" + sha256Hex(body); string(stamped) != want { - t.Fatalf("cache-key = %q, want %q", stamped, want) - } -} - -func TestInstallSkillsCacheHitSkipsDownloadButStillReturnsDir(t *testing.T) { - body := validSkillZip(t) - srv := startZipServer(t, body) - root := t.TempDir() - desc := []skillDescriptor{ - {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - } - - first, err := installSkills(context.Background(), discardLogger(), root, desc) - if err != nil { - t.Fatalf("first install: %v", err) - } - if len(first.SkillDirs) != 1 || srv.Hits() != 1 { - t.Fatalf("first install dirs=%v hits=%d", first.SkillDirs, srv.Hits()) - } - - second, err := installSkills(context.Background(), discardLogger(), root, desc) - if err != nil { - t.Fatalf("second install: %v", err) - } - if srv.Hits() != 1 { - t.Fatalf("cache should prevent second download; hits=%d", srv.Hits()) - } - // A cache hit must STILL surface the dir so --skill is injected on - // every turn, not just the first. - if len(second.SkillDirs) != 1 || second.SkillDirs[0] != first.SkillDirs[0] { - t.Fatalf("cache hit must still return the dir; got %v", second.SkillDirs) - } -} - -func TestInstallSkillsSHA256MismatchDemotesToWarning(t *testing.T) { - body := validSkillZip(t) - srv := startZipServer(t, body) - root := t.TempDir() - - res, err := installSkills(context.Background(), discardLogger(), root, []skillDescriptor{ - {Name: "code-review", Version: "1.0.0", DownloadURL: srv.URL, SHA256: strings.Repeat("0", 64)}, - }) - if err != nil { - t.Fatalf("installSkills should not hard-error on sha mismatch: %v", err) - } - if len(res.SkillDirs) != 0 { - t.Fatalf("SkillDirs = %v, want empty after sha mismatch", res.SkillDirs) - } - if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "sha256 mismatch") { - t.Fatalf("want 1 sha-mismatch warning, got %v", res.Warnings) - } - if _, err := os.Stat(filepath.Join(root, "code-review", "SKILL.md")); err == nil { - t.Fatal("SKILL.md should not exist after sha mismatch") - } -} - -func TestInstallSkillsEmptyListIsNoop(t *testing.T) { - res, err := installSkills(context.Background(), discardLogger(), t.TempDir(), nil) - if err != nil { - t.Fatalf("empty install: %v", err) - } - if len(res.SkillDirs) != 0 || len(res.Warnings) != 0 { - t.Fatalf("expected empty result; got %+v", res) - } -} - -func TestInstallSkillsStripsWrappingRoot(t *testing.T) { - body := buildZipBytes(t, []zipFile{ - {Name: "wrapper/SKILL.md", Body: "---\nname: x\n---\nbody"}, - {Name: "wrapper/refs/note.md", Body: "note"}, - }) - srv := startZipServer(t, body) - root := t.TempDir() - - res, err := installSkills(context.Background(), discardLogger(), root, []skillDescriptor{ - {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if err != nil { - t.Fatalf("install: %v", err) - } - if len(res.SkillDirs) != 1 { - t.Fatalf("SkillDirs = %v", res.SkillDirs) - } - dir := res.SkillDirs[0] - if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { - t.Fatalf("SKILL.md at expected path missing (wrapper not stripped?): %v", err) - } - if _, err := os.Stat(filepath.Join(dir, "wrapper")); err == nil { - t.Fatal("wrapper survived the strip") - } -} - -func TestInstallSkillsPathTraversalRejected(t *testing.T) { - body := buildZipBytes(t, []zipFile{ - {Name: "SKILL.md", Body: "---\nname: x\n---"}, - {Name: "../../escape", Body: "should never land outside dir"}, - }) - srv := startZipServer(t, body) - root := t.TempDir() - - res, _ := installSkills(context.Background(), discardLogger(), root, []skillDescriptor{ - {Name: "x", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if len(res.SkillDirs) != 0 { - t.Fatalf("SkillDirs = %v, want empty on path-traversal", res.SkillDirs) - } - if len(res.Warnings) == 0 || !strings.Contains(res.Warnings[0], "escapes") { - t.Fatalf("want escape warning, got %v", res.Warnings) - } -} - -func TestInstallSkillsPartialInstall(t *testing.T) { - bodyOK := validSkillZip(t) - srvOK := startZipServer(t, bodyOK) - srvBAD := startZipServer(t, bodyOK) - root := t.TempDir() - - res, err := installSkills(context.Background(), discardLogger(), root, []skillDescriptor{ - {Name: "good", Version: "1", DownloadURL: srvOK.URL, SHA256: sha256Hex(bodyOK)}, - {Name: "bad", Version: "1", DownloadURL: srvBAD.URL, SHA256: strings.Repeat("0", 64)}, - }) - if err != nil { - t.Fatalf("install: %v", err) - } - if len(res.SkillDirs) != 1 || filepath.Base(res.SkillDirs[0]) != "good" { - t.Fatalf("SkillDirs = %v, want only the good one", res.SkillDirs) - } - if len(res.Warnings) == 0 { - t.Fatal("expected warning for the bad skill") - } -} - -func TestInstallSkillsDescriptorValidatorRejectsBadNames(t *testing.T) { - body := validSkillZip(t) - srv := startZipServer(t, body) - res, _ := installSkills(context.Background(), discardLogger(), t.TempDir(), []skillDescriptor{ - {Name: "../escape", Version: "1", DownloadURL: srv.URL, SHA256: sha256Hex(body)}, - }) - if len(res.SkillDirs) != 0 { - t.Fatalf("SkillDirs = %v; bad name should be rejected", res.SkillDirs) - } - if len(res.Warnings) == 0 { - t.Fatal("expected warning") - } -} - -func TestDecodeSkillDescriptorsArrayShape(t *testing.T) { - raw := []any{ - map[string]any{"name": "a", "version": "1", "download_url": "https://x/a.zip", "sha256": strings.Repeat("a", 64)}, - map[string]any{"name": "", "version": "1", "download_url": "https://x/b.zip", "sha256": strings.Repeat("b", 64)}, - "not an object", - } - got, warns := decodeSkillDescriptors(raw) - if len(got) != 1 || got[0].Name != "a" { - t.Fatalf("got = %v, want 1 valid entry", got) - } - if len(warns) != 2 { - t.Fatalf("warns = %v, want 2", warns) - } -} - -func TestDecodeSkillDescriptorsNilAndWrongType(t *testing.T) { - if got, warns := decodeSkillDescriptors(nil); got != nil || warns != nil { - t.Fatalf("nil raw should be (nil, nil), got (%v, %v)", got, warns) - } - if got, warns := decodeSkillDescriptors("not-array"); got != nil || len(warns) != 1 { - t.Fatalf("string raw should warn, got got=%v warns=%v", got, warns) - } -} - -func TestMergeSkillDirsOverrideWinsAndDedupes(t *testing.T) { - got := mergeSkillDirs([]any{"/a", "/b"}, []string{"/b", "/c"}) - want := []string{"/a", "/b", "/c"} - if strings.Join(got, ",") != strings.Join(want, ",") { - t.Fatalf("got %v, want %v", got, want) - } -} - -func TestMergeSkillDirsNilExisting(t *testing.T) { - got := mergeSkillDirs(nil, []string{"/x"}) - if len(got) != 1 || got[0] != "/x" { - t.Fatalf("got %v, want [/x]", got) - } -} - -func TestResolveSkillsRootConversationScoped(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - got, err := resolveSkillsRoot("conv-abc", "run-1") - if err != nil { - t.Fatalf("resolveSkillsRoot: %v", err) - } - want := filepath.Join(tmp, ".oac", "runtime", "pi", "conv-conv-abc", "skills") - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} - -func TestResolveSkillsRootRunScopedFallback(t *testing.T) { - tmp := t.TempDir() - t.Setenv("HOME", tmp) - got, err := resolveSkillsRoot("", "run-9") - if err != nil { - t.Fatalf("resolveSkillsRoot: %v", err) - } - want := filepath.Join(tmp, ".oac", "runtime", "pi", "run-run-9", "skills") - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} diff --git a/apps/daemon/internal/agent/pi/version.go b/apps/daemon/internal/agent/pi/version.go deleted file mode 100644 index b866cc054..000000000 --- a/apps/daemon/internal/agent/pi/version.go +++ /dev/null @@ -1,36 +0,0 @@ -package pi - -import ( - "context" - "errors" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/binpath" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe" -) - -// InstallURL points operators at the pi documentation when the daemon -// can see the adapter but not the CLI binary. -const InstallURL = "https://github.com/earendil-works/pi" - -// defaultBinary is the executable to probe and spawn: binpath.Pi() -// honours the OAC_RUNTIME_PI_BIN override so a bare-name PATH lookup can be -// bypassed in images where PATH is not under our control. A function -// rather than a const so the env is read at call time. -func defaultBinary() string { return binpath.Pi() } - -// ErrCLINotFound is returned by CheckCLIAvailable when the binary cannot -// be located on PATH. Callers use errors.Is to distinguish an install -// problem from a present-but-broken CLI. -var ErrCLINotFound = errors.New("pi CLI not found") - -// CheckCLIAvailable runs ` --version` and returns the trimmed -// first line. The empty binary name defaults to defaultBinary(). -func CheckCLIAvailable(ctx context.Context, binary string) (string, error) { - return versionprobe.Check(ctx, binary, versionprobe.Config{ - Name: "pi", - DefaultBinary: defaultBinary(), - MissingError: ErrCLINotFound, - TrimBinary: true, - StderrFallback: true, - }) -} diff --git a/apps/daemon/internal/agent/pi/version_test.go b/apps/daemon/internal/agent/pi/version_test.go deleted file mode 100644 index 778d108f7..000000000 --- a/apps/daemon/internal/agent/pi/version_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package pi_test - -import ( - "context" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/versionprobe/testutil" -) - -func TestCheckCLIAvailableContract(t *testing.T) { - testutil.RunContract(t, testutil.Contract{ - Name: "pi", - DefaultBinary: "pi", - MissingError: pi.ErrCLINotFound, - Check: pi.CheckCLIAvailable, - WhitespaceDefaults: true, - }) -} - -func TestCheckCLIAvailableFallsBackToStderr(t *testing.T) { - stub := testutil.WriteStub(t, "stderr-pi", "#!/bin/sh\nprintf ' pi 0.74.2\\nextra line \\n' 1>&2\n") - version, err := pi.CheckCLIAvailable(context.Background(), stub) - if err != nil { - t.Fatalf("check CLI: %v", err) - } - if version != "pi 0.74.2" { - t.Fatalf("version = %q, want %q", version, "pi 0.74.2") - } -} diff --git a/apps/daemon/internal/agent/registry_test.go b/apps/daemon/internal/agent/registry_test.go index 6bf4da382..680e49319 100644 --- a/apps/daemon/internal/agent/registry_test.go +++ b/apps/daemon/internal/agent/registry_test.go @@ -32,13 +32,13 @@ func (stubSession) SubmitPromptForUserChoice(context.Context, string, proto.Prom func TestRegistryResolveReturnsRegisteredFactory(t *testing.T) { reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) - f, err := reg.Resolve("claude_code") + f, err := reg.Resolve("fake_alpha") if err != nil { t.Fatalf("Resolve: %v", err) } - sess, err := f(context.Background(), proto.PromptRequestPayload{AgentKind: "claude_code"}, nil) + sess, err := f(context.Background(), proto.PromptRequestPayload{AgentKind: "fake_alpha"}, nil) if err != nil { t.Fatalf("factory: %v", err) } @@ -50,9 +50,9 @@ func TestRegistryResolveReturnsRegisteredFactory(t *testing.T) { func TestRegistryResolveUnknownKindReturnsTypedError(t *testing.T) { reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) - _, err := reg.Resolve("opencode") + _, err := reg.Resolve("fake_beta") if !errors.Is(err, agent.ErrUnsupportedKind) { t.Errorf("Resolve unknown = %v, want ErrUnsupportedKind chain", err) } @@ -75,12 +75,12 @@ func TestRegistryRegisterOverwrites(t *testing.T) { func TestRegistryKindsReportsRegistered(t *testing.T) { reg := agent.NewRegistry() - reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) - reg.RegisterKind(proto.SupportedAgentKind{Kind: "opencode", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("oc")) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("cc")) + reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_beta", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, stubFactory("oc")) got := reg.Kinds() slices.Sort(got) - want := []string{"claude_code", "opencode"} + want := []string{"fake_alpha", "fake_beta"} if !slices.Equal(got, want) { t.Errorf("Kinds = %v, want %v", got, want) } @@ -107,7 +107,7 @@ func TestRegistryRegisterPanicsOnNilFactory(t *testing.T) { func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { reg := agent.NewRegistry() reg.RegisterKind(proto.SupportedAgentKind{ - Kind: "opencode", + Kind: "fake_beta", Available: false, Version: "missing", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ @@ -115,7 +115,7 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { }), }, harnessconfig.Configuration{}, stubFactory("oc")) reg.RegisterKind(proto.SupportedAgentKind{ - Kind: "claude_code", + Kind: "fake_alpha", Available: true, Version: "1.2.3", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ @@ -130,14 +130,14 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { if len(got) != 2 { t.Fatalf("SupportedAgentKinds len = %d, want 2: %#v", len(got), got) } - if got[0].Kind != "claude_code" || got[1].Kind != "opencode" { - t.Fatalf("SupportedAgentKinds sort = %#v, want claude_code then opencode", got) + if got[0].Kind != "fake_alpha" || got[1].Kind != "fake_beta" { + t.Fatalf("SupportedAgentKinds sort = %#v, want fake_alpha then fake_beta", got) } if !got[0].Available || got[0].Version != "1.2.3" || !got[0].Capabilities.Permissions.IsSupported() || !got[0].Capabilities.Resume.IsSupported() { - t.Fatalf("claude_code descriptor not preserved: %#v", got[0]) + t.Fatalf("fake_alpha descriptor not preserved: %#v", got[0]) } if got[1].Available || got[1].Version != "missing" || !got[1].Capabilities.Streaming.IsSupported() { - t.Fatalf("opencode descriptor not preserved: %#v", got[1]) + t.Fatalf("fake_beta descriptor not preserved: %#v", got[1]) } } diff --git a/apps/daemon/internal/agent/runtime_paths_test.go b/apps/daemon/internal/agent/runtime_paths_test.go index 6aba1d7ed..8cf35e5dc 100644 --- a/apps/daemon/internal/agent/runtime_paths_test.go +++ b/apps/daemon/internal/agent/runtime_paths_test.go @@ -21,11 +21,11 @@ func TestManagedSkillsRootUsesStableAgentState(t *testing.T) { func TestManagedSkillsRootSanitizesFallback(t *testing.T) { home := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", home) - got, err := ManagedSkillsRoot("opencode", "", "../conv name", "ignored") + got, err := ManagedSkillsRoot("fake_beta", "", "../conv name", "ignored") if err != nil { t.Fatalf("ManagedSkillsRoot: %v", err) } - want := filepath.Join(home, "runtime", "opencode", "conv-.._conv_name", "skills") + want := filepath.Join(home, "runtime", "fake_beta", "conv-.._conv_name", "skills") if got != want { t.Fatalf("root = %q, want %q", got, want) } diff --git a/apps/daemon/internal/cli/agent_discovery.go b/apps/daemon/internal/cli/agent_discovery.go index 2f46dc32a..d29b85acc 100644 --- a/apps/daemon/internal/cli/agent_discovery.go +++ b/apps/daemon/internal/cli/agent_discovery.go @@ -2,298 +2,43 @@ package cli import ( "context" - "errors" "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" - opencodeagent "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -// agentCLIDiscovery is the daemon startup snapshot advertised in heartbeat. -type agentCLIDiscovery struct { - installedKinds map[string]bool - MCodeWorkspace *mcode.WorkspaceConfig - ClaudeSDK *claudeSDKDiscovery - ClaudeCode proto.SupportedAgentKind - OpenCode proto.SupportedAgentKind - Codex proto.SupportedAgentKind - Pi proto.SupportedAgentKind - MCode proto.SupportedAgentKind -} - -func (d agentCLIDiscovery) permits(kind string) bool { - return d.installedKinds == nil || d.installedKinds[kind] -} +var harnessDeclarations = []agent.Declaration{codex.Declaration, mcode.Declaration, claudesdk.Declaration} -type agentCLIChecks struct { - ClaudeSDK func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) - ClaudeCode func(context.Context, string) (string, error) - OpenCode func(context.Context, string) (string, error) - Codex func(context.Context, string) (string, error) - Pi func(context.Context, string) (string, error) - MCode func(context.Context, string) (string, error) -} - -func defaultAgentCLIChecks() agentCLIChecks { - return agentCLIChecks{ - ClaudeCode: claudecode.CheckCLIAvailable, - OpenCode: opencodeagent.CheckCLIAvailable, - Codex: codex.CheckCLIAvailable, - Pi: pi.CheckCLIAvailable, - MCode: mcode.CheckCLIAvailable, - } +type discoveredHarness struct { + declaration agent.Declaration + runtime agent.Runtime } +type agentCLIDiscovery []discoveredHarness func preflightAgentCLIs(parent context.Context, rc *runContext, profile string) (agentCLIDiscovery, error) { - return discoverAgentCLIs(parent, rc, profile, defaultAgentCLIChecks()) + return discoverAgentCLIs(parent, rc, profile, harnessDeclarations) } - -func discoverAgentCLIs(parent context.Context, rc *runContext, profile string, checks agentCLIChecks) (agentCLIDiscovery, error) { - if checks.ClaudeCode == nil { - checks.ClaudeCode = claudecode.CheckCLIAvailable - } - if checks.OpenCode == nil { - checks.OpenCode = opencodeagent.CheckCLIAvailable - } - if checks.Codex == nil { - checks.Codex = codex.CheckCLIAvailable - } - if checks.Pi == nil { - checks.Pi = pi.CheckCLIAvailable - } - out := agentCLIDiscovery{ - installedKinds: rc.installedKinds, - ClaudeCode: proto.SupportedAgentKind{ - Kind: "claude_code", - Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilityUnsupported, - Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilitySupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, - NativeSessionRecovery: proto.CapabilityUnsupported, - WorkspaceAuthoring: proto.CapabilityUnsupported, - Steering: proto.CapabilityUnsupported, - MessageItems: proto.CapabilityUnsupported, - ToolObservations: proto.CapabilityUnsupported, - EnvironmentNone: proto.CapabilityUnsupported, - LocalEnvironment: proto.CapabilityUnsupported, - Preparation: proto.CapabilityUnsupported, - WorkspaceReadPreparation: proto.CapabilityUnsupported, - WorkspaceOutputExport: proto.CapabilityUnsupported, - ProgrammaticToolCallingDisable: proto.CapabilityUnsupported, - WebSearchControl: proto.CapabilityUnsupported, - ExecutionControls: proto.CapabilityUnsupported, - TextVerbosity: proto.CapabilityUnsupported, - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - SubagentControl: proto.CapabilityUnsupported, - DurableInputReceipts: proto.CapabilityUnsupported, - DurableTurns: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - MCPHTTPTools: proto.CapabilityUnsupported, - MCPHTTPRequired: proto.CapabilityUnsupported, - MCPHTTPBearerAuth: proto.CapabilityUnsupported, - }, - }, - OpenCode: proto.SupportedAgentKind{ - Kind: "opencode", - Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilityUnsupported, - Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilityUnsupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilityUnsupported, - NativeSessionRecovery: proto.CapabilityUnsupported, - WorkspaceAuthoring: proto.CapabilityUnsupported, - Steering: proto.CapabilityUnsupported, - MessageItems: proto.CapabilityUnsupported, - ToolObservations: proto.CapabilityUnsupported, - EnvironmentNone: proto.CapabilityUnsupported, - LocalEnvironment: proto.CapabilityUnsupported, - Preparation: proto.CapabilityUnsupported, - WorkspaceReadPreparation: proto.CapabilityUnsupported, - WorkspaceOutputExport: proto.CapabilityUnsupported, - ProgrammaticToolCallingDisable: proto.CapabilityUnsupported, - WebSearchControl: proto.CapabilityUnsupported, - ExecutionControls: proto.CapabilityUnsupported, - TextVerbosity: proto.CapabilityUnsupported, - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - SubagentControl: proto.CapabilityUnsupported, - DurableInputReceipts: proto.CapabilityUnsupported, - DurableTurns: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - MCPHTTPTools: proto.CapabilityUnsupported, - MCPHTTPRequired: proto.CapabilityUnsupported, - MCPHTTPBearerAuth: proto.CapabilityUnsupported, - }, - }, - Codex: proto.SupportedAgentKind{ - Kind: "codex", - Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilitySupported, - Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilitySupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, - NativeSessionRecovery: proto.CapabilityUnsupported, - WorkspaceAuthoring: proto.CapabilityUnsupported, - Steering: proto.CapabilitySupported, - MessageItems: proto.CapabilitySupported, - ToolObservations: proto.CapabilitySupported, - EnvironmentNone: proto.CapabilitySupported, - LocalEnvironment: proto.CapabilityUnsupported, - Preparation: proto.CapabilityUnsupported, - WorkspaceReadPreparation: proto.CapabilityUnsupported, - WorkspaceOutputExport: proto.CapabilityUnsupported, - ProgrammaticToolCallingDisable: proto.CapabilitySupported, - WebSearchControl: proto.CapabilitySupported, - ExecutionControls: proto.CapabilityFromBool(codex.SupportsTextVerbosity), - TextVerbosity: proto.CapabilityFromBool(codex.SupportsTextVerbosity), - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilitySupported, - FunctionResultImages: proto.CapabilitySupported, - SubagentControl: proto.CapabilitySupported, - DurableInputReceipts: proto.CapabilitySupported, - DurableTurns: proto.CapabilitySupported, - FunctionTools: proto.CapabilitySupported, - MCPHTTPTools: proto.CapabilitySupported, - MCPHTTPRequired: proto.CapabilityUnsupported, - MCPHTTPBearerAuth: proto.CapabilitySupported, - }, - }, - Pi: proto.SupportedAgentKind{ - Kind: "pi", - Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilityUnsupported, - Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilityUnsupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, - NativeSessionRecovery: proto.CapabilityUnsupported, - WorkspaceAuthoring: proto.CapabilityUnsupported, - Steering: proto.CapabilityUnsupported, - MessageItems: proto.CapabilityUnsupported, - ToolObservations: proto.CapabilityUnsupported, - EnvironmentNone: proto.CapabilityUnsupported, - LocalEnvironment: proto.CapabilityUnsupported, - Preparation: proto.CapabilityUnsupported, - WorkspaceReadPreparation: proto.CapabilityUnsupported, - WorkspaceOutputExport: proto.CapabilityUnsupported, - ProgrammaticToolCallingDisable: proto.CapabilityUnsupported, - WebSearchControl: proto.CapabilityUnsupported, - ExecutionControls: proto.CapabilityUnsupported, - TextVerbosity: proto.CapabilityUnsupported, - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - SubagentControl: proto.CapabilityUnsupported, - DurableInputReceipts: proto.CapabilityUnsupported, - DurableTurns: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - MCPHTTPTools: proto.CapabilityUnsupported, - MCPHTTPRequired: proto.CapabilityUnsupported, - MCPHTTPBearerAuth: proto.CapabilityUnsupported, - }, - }, - } - - if out.permits("claude_code") { - claudeCtx, cancelClaude := context.WithTimeout(parent, cliVersionTimeout) - claudeVersion, claudeErr := checks.ClaudeCode(claudeCtx, "") - cancelClaude() - if claudeErr == nil { - out.ClaudeCode.Available = true - out.ClaudeCode.Version = claudeVersion - fmt.Fprintf(rc.stdout, "Claude Code preflight ok (%s)\n", claudeVersion) - } else if errors.Is(claudeErr, claudecode.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "oac-daemon: Claude Code CLI not found on PATH; claude_code unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", claudecode.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "oac-daemon: `claude --version` failed; claude_code unavailable: %v\n", claudeErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", claudecode.InstallURL) +func discoverAgentCLIs(parent context.Context, rc *runContext, profile string, declarations []agent.Declaration) (agentCLIDiscovery, error) { + var out agentCLIDiscovery + available := false + for _, declaration := range declarations { + if rc.installedKinds != nil && !rc.installedKinds[declaration.Info.Kind] { + continue } - } - - if out.permits("opencode") { - opencodeCtx, cancelOpenCode := context.WithTimeout(parent, cliVersionTimeout) - opencodeVersion, opencodeErr := checks.OpenCode(opencodeCtx, "") - cancelOpenCode() - if opencodeErr == nil { - out.OpenCode.Available = true - out.OpenCode.Version = opencodeVersion - fmt.Fprintf(rc.stdout, "OpenCode preflight ok (%s)\n", opencodeVersion) - } else if errors.Is(opencodeErr, opencodeagent.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "oac-daemon: OpenCode CLI not found on PATH; opencode unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", opencodeagent.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "oac-daemon: `opencode --version` failed; opencode unavailable: %v\n", opencodeErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", opencodeagent.InstallURL) + runtime := declaration.Discover(parent, agent.DiscoveryOptions{Profile: profile, Stdout: rc.stdout, Stderr: rc.stderr}, declaration.Info) + if runtime == nil { + continue } + out = append(out, discoveredHarness{declaration, *runtime}) + available = available || runtime.Info.Available } - - if out.permits("codex") { - codexCtx, cancelCodex := context.WithTimeout(parent, cliVersionTimeout) - codexVersion, codexErr := checks.Codex(codexCtx, "") - cancelCodex() - if codexErr == nil { - out.Codex.Available = true - out.Codex.Version = codexVersion - out.Codex.Capabilities.NativeSessionRecovery = proto.CapabilityFromBool(codex.SupportsNativeSessionRecovery(codexVersion)) - out.Codex.Capabilities.LocalEnvironment = proto.CapabilityFromBool(codex.SupportsLocalEnvironment(codexVersion)) - out.Codex.Capabilities.MCPHTTPRequired = proto.CapabilityFromBool(codex.SupportsNativeSessionRecovery(codexVersion)) - fmt.Fprintf(rc.stdout, "Codex preflight ok (%s)\n", codexVersion) - } else if errors.Is(codexErr, codex.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "oac-daemon: Codex CLI not found on PATH; codex unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", codex.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "oac-daemon: `codex --version` failed; codex unavailable: %v\n", codexErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", codex.InstallURL) - } - } - - if out.permits("pi") { - piCtx, cancelPi := context.WithTimeout(parent, cliVersionTimeout) - piVersion, piErr := checks.Pi(piCtx, "") - cancelPi() - if piErr == nil { - out.Pi.Available = true - out.Pi.Version = piVersion - fmt.Fprintf(rc.stdout, "pi preflight ok (%s)\n", piVersion) - } else if errors.Is(piErr, pi.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "oac-daemon: pi CLI not found on PATH; pi unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", pi.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "oac-daemon: `pi --version` failed; pi unavailable: %v\n", piErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", pi.InstallURL) - } - } - - if out.permits("mcode") { - out.MCode = discoverMCode(parent, rc, checks.MCode) - discoverMCodeWorkspace(parent, rc, &out) - } - if out.permits("claude_sdk") { - out.ClaudeSDK = discoverClaudeSDK(parent, rc, profile, checks.ClaudeSDK) - } - if err := parent.Err(); err != nil { return out, err } - if !out.ClaudeCode.Available && !out.OpenCode.Available && !out.Codex.Available && !out.Pi.Available && !out.MCode.Available && (out.ClaudeSDK == nil || !out.ClaudeSDK.Info.Available) { - return out, fmt.Errorf("connect: no supported agent CLI available (install Claude Code, OpenCode, Codex, pi, or mcode, or configure a Claude SDK runtime)") + if !available { + return out, fmt.Errorf("connect: no supported agent CLI available (install a supported Harness runtime)") } return out, nil } diff --git a/apps/daemon/internal/cli/agent_registration.go b/apps/daemon/internal/cli/agent_registration.go index f80d1c1b1..f2911f217 100644 --- a/apps/daemon/internal/cli/agent_registration.go +++ b/apps/daemon/internal/cli/agent_registration.go @@ -1,56 +1,16 @@ package cli -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" +import "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -import ( - "context" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" - opencodeagent "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery, serverURL string) { - registerProduct := func(info proto.SupportedAgentKind, configuration harnessconfig.Configuration, factory agent.Factory) { - if agentCLIs.permits(info.Kind) { - registerProductAgentKind(registry, info, configuration, factory) +func registerAgentKinds(registry *agent.Registry, discovery agentCLIDiscovery, serverURL string) { + for _, discovered := range discovery { + runtime := discovered.runtime + if runtime.SessionCapabilityContext { + runtime.Session = withSkillUploadServer(withCapabilityDownloads(runtime.Session, serverURL), serverURL) } + if runtime.Executor != nil && runtime.ExecutorCapabilityContext { + runtime.Executor = withExecutorCapabilities(runtime.Executor, serverURL) + } + registry.Register(discovered.declaration, runtime) } - - registerProduct(agentCLIs.ClaudeCode, harnessconfig.Configuration{}, withSkillUploadServer(withCapabilityDownloads(claudecode.Factory, serverURL), serverURL)) - registerProduct(agentCLIs.OpenCode, harnessconfig.Configuration{}, withSkillUploadServer(withCapabilityDownloads(opencodeagent.Factory, serverURL), serverURL)) - registerProduct(agentCLIs.Codex, builtin.Configuration("codex"), withSkillUploadServer(withCapabilityDownloads(codex.Factory, serverURL), serverURL)) - if agentCLIs.Codex.Available { - registry.RegisterExecutor("codex", withExecutorCapabilities(codex.NewExecutorFactory(), serverURL)) - } - if agentCLIs.Codex.Available && agentCLIs.Codex.Capabilities.LocalEnvironment.IsSupported() { - registry.RegisterPreparation("codex", true, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - prepared, err := codex.Prepare(ctx, req) - if prepared == nil { - return nil, err - } - return prepared, err - }) - } - registerProduct(agentCLIs.Pi, harnessconfig.Configuration{}, withSkillUploadServer(withCapabilityDownloads(pi.Factory, serverURL), serverURL)) - if agentCLIs.MCodeWorkspace != nil { - registry.RegisterKind(agentCLIs.MCode, builtin.Configuration("mcode"), mcode.Factory) - registry.RegisterPreparation("mcode", true, mcode.NewPreparationFactory(*agentCLIs.MCodeWorkspace)) - } else { - registerProduct(agentCLIs.MCode, builtin.Configuration("mcode"), withSkillUploadServer(withCapabilityDownloads(mcode.Factory, serverURL), serverURL)) - } - if agentCLIs.MCode.Available { - registry.RegisterExecutor("mcode", withExecutorCapabilities(mcode.NewExecutorFactory(agentCLIs.MCodeWorkspace), serverURL)) - } - registerClaudeSDK(registry, agentCLIs.ClaudeSDK) -} - -func registerProductAgentKind(registry *agent.Registry, info proto.SupportedAgentKind, configuration harnessconfig.Configuration, factory agent.Factory) { - info.Capabilities.WorkspaceAuthoring = proto.CapabilitySupported - registry.RegisterKind(info, configuration, factory) } diff --git a/apps/daemon/internal/cli/capability_downloads_test.go b/apps/daemon/internal/cli/capability_downloads_test.go index 59269b64a..ecdcbc22f 100644 --- a/apps/daemon/internal/cli/capability_downloads_test.go +++ b/apps/daemon/internal/cli/capability_downloads_test.go @@ -14,7 +14,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/managedskills" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) @@ -75,7 +75,7 @@ func TestCapabilityDownloadsInstallZIPThroughPairedServer(t *testing.T) { if plugin["download_url"] != skill["download_url"] { t.Fatal("plugin and skill routing differs") } - result, err := claudecode.InstallManagedSkills(ctx, nil, root, req.AgentOptions["skills"]) + result, err := managedskills.InstallManagedSkills(ctx, nil, root, req.AgentOptions["skills"]) if err == nil && len(result.Warnings) > 0 { err = fmt.Errorf("installation warnings: %v", result.Warnings) } diff --git a/apps/daemon/internal/cli/claude_sdk.go b/apps/daemon/internal/cli/claude_sdk.go deleted file mode 100644 index 5a6567526..000000000 --- a/apps/daemon/internal/cli/claude_sdk.go +++ /dev/null @@ -1,165 +0,0 @@ -package cli - -import "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -const claudeSDKEntrypointEnv = "OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT" -const claudeSDKNodeEnv = "OAC_RUNTIME_CLAUDE_SDK_NODE" - -type claudeSDKDiscovery struct { - Info proto.SupportedAgentKind - Config claudesdk.Config -} - -func discoverClaudeSDK(parent context.Context, rc *runContext, profile string, check func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error)) *claudeSDKDiscovery { - entrypoint := os.Getenv(claudeSDKEntrypointEnv) - if entrypoint == "" { - return nil - } - out := &claudeSDKDiscovery{Info: proto.SupportedAgentKind{Kind: "claude_sdk", Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilityUnsupported, - Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilityUnsupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, - NativeSessionRecovery: proto.CapabilityUnsupported, - WorkspaceAuthoring: proto.CapabilityUnsupported, - Steering: proto.CapabilitySupported, - MessageItems: proto.CapabilitySupported, - ToolObservations: proto.CapabilitySupported, - EnvironmentNone: proto.CapabilitySupported, - LocalEnvironment: proto.CapabilityUnsupported, - Preparation: proto.CapabilityUnsupported, - WorkspaceReadPreparation: proto.CapabilityUnsupported, - WorkspaceOutputExport: proto.CapabilityUnsupported, - ProgrammaticToolCallingDisable: proto.CapabilitySupported, - WebSearchControl: proto.CapabilityUnsupported, - ExecutionControls: proto.CapabilitySupported, - TextVerbosity: proto.CapabilityUnsupported, - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - SubagentControl: proto.CapabilitySupported, - DurableInputReceipts: proto.CapabilitySupported, - DurableTurns: proto.CapabilitySupported, - FunctionTools: proto.CapabilitySupported, - MCPHTTPTools: proto.CapabilityUnsupported, - MCPHTTPRequired: proto.CapabilityUnsupported, - MCPHTTPBearerAuth: proto.CapabilityUnsupported, - }}} - fail := func(err error) *claudeSDKDiscovery { - fmt.Fprintf(rc.stderr, "oac-daemon: configured Claude SDK runtime unavailable: %v\n", err) - return out - } - if !filepath.IsAbs(entrypoint) { - return fail(fmt.Errorf("%s must be absolute", claudeSDKEntrypointEnv)) - } - profileDir, err := paths.ProfileDir(profile) - if err != nil { - return fail(err) - } - if !filepath.IsAbs(profileDir) { - return fail(fmt.Errorf("Claude SDK state requires an absolute OAC_RUNTIME_HOME")) - } - node := os.Getenv(claudeSDKNodeEnv) - if node == "" { - node = "node" - } - node, err = exec.LookPath(node) - if err != nil { - return fail(fmt.Errorf("Claude SDK Node executable is unavailable")) - } - node, err = filepath.Abs(node) - if err != nil { - return fail(err) - } - out.Config = claudesdk.Config{Node: node, Entrypoint: entrypoint, StateDir: filepath.Join(profileDir, "runtime", "claude-sdk")} - binding, err := localworkspace.Load() - if err != nil { - return fail(err) - } - if binding != nil { - root, err := paths.Root() - if err != nil { - return fail(err) - } - out.Config.Node, err = filepath.EvalSymlinks(node) - if err != nil { - return fail(err) - } - out.Config, err = claudesdk.ConfigureLocal(out.Config, root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) - if err != nil { - return fail(err) - } - } - if check == nil { - check = claudesdk.CheckRuntime - } - info, err := check(parent, out.Config) - if err != nil { - return fail(err) - } - if out.Config.Workspace != nil { - if !info.SupportsLocalRuntime() { - return fail(fmt.Errorf("Claude SDK bundle does not support the local Runtime contract")) - } - caps := &out.Info.Capabilities - caps.EnvironmentNone, caps.FunctionTools = proto.CapabilityUnsupported, proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()) - caps.Preparation, caps.LocalEnvironment = proto.CapabilitySupported, proto.CapabilitySupported - caps.WorkspaceReadPreparation, caps.NativeSessionRecovery = proto.CapabilitySupported, proto.CapabilitySupported - } - out.Info.Available, out.Info.Version = true, info.SDK - out.Info.Capabilities.MessageImages = proto.CapabilityFromBool(info.SupportsMessageImages()) - out.Info.Capabilities.FunctionResultImages = proto.CapabilityFromBool(info.SupportsFunctionResultImages()) - out.Info.Capabilities.ToolSearch = proto.CapabilityFromBool(info.SupportsToolSearch()) - if out.Config.Workspace != nil { - out.Info.Capabilities.ToolSearch = proto.CapabilityFromBool(info.SupportsWorkspaceToolSearch()) - } - out.Info.Capabilities.StructuredOutput = proto.CapabilityFromBool(info.SupportsStructuredOutput()) - if out.Config.Workspace != nil { - out.Info.Capabilities.StructuredOutput = proto.CapabilityFromBool(info.SupportsWorkspaceStructuredOutput()) - } - out.Info.Capabilities.SubagentObservations = proto.CapabilityFromBool(info.SupportsSubagents()) - out.Info.Capabilities.MCPHTTPTools = proto.CapabilityFromBool(info.SupportsHTTPMCP()) - out.Info.Capabilities.MCPHTTPBearerAuth = proto.CapabilityFromBool(info.SupportsHTTPMCPBearer()) - out.Info.Capabilities.MCPHTTPRequired = proto.CapabilityFromBool(info.SupportsHTTPMCPRequired()) - if out.Config.Workspace != nil && !info.SupportsWorkspaceMCP() { - out.Info.Capabilities.MCPHTTPTools, out.Info.Capabilities.MCPHTTPBearerAuth = proto.CapabilityUnsupported, proto.CapabilityUnsupported - out.Info.Capabilities.MCPHTTPRequired = proto.CapabilityUnsupported - } - fmt.Fprintf(rc.stdout, "Claude SDK preflight ok (SDK %s, %s)\n", info.SDK, info.Native) - return out -} - -func registerClaudeSDK(registry *agent.Registry, discovery *claudeSDKDiscovery) { - if discovery == nil { - return - } - factory := claudesdk.NewFactory(discovery.Config) - if !discovery.Info.Available { - factory = func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { - return nil, fmt.Errorf("claude_sdk: configured runtime is unavailable") - } - } - registry.RegisterKind(discovery.Info, builtin.Configuration("claude_sdk"), factory) - if discovery.Info.Available { - registry.RegisterExecutor("claude_sdk", claudesdk.NewExecutorFactory(discovery.Config)) - } - if discovery.Info.Available && discovery.Info.Capabilities.LocalEnvironment.IsSupported() { - registry.RegisterPreparation("claude_sdk", true, claudesdk.NewPreparationFactory(discovery.Config)) - } -} diff --git a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go index 9b02e7152..7c4899315 100644 --- a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go +++ b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go @@ -31,7 +31,7 @@ func (s registeredSDKSender) Send(ctx context.Context, env proto.Envelope) error } func TestLiveRegisteredClaudeSDK(t *testing.T) { - entrypoint, keyFile := os.Getenv(claudeSDKEntrypointEnv), os.Getenv("OAC_TEST_CLAUDE_SDK_MINIMAX_KEY_FILE") + entrypoint, keyFile := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT"), os.Getenv("OAC_TEST_CLAUDE_SDK_MINIMAX_KEY_FILE") if entrypoint == "" || keyFile == "" { t.Skip("requires explicit SDK runtime and real provider key file") } @@ -57,8 +57,8 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { t.Setenv(name, value) } stdout, stderr := &strings.Builder{}, &strings.Builder{} - discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: stdout, stderr: stderr}, "acceptance", unavailableCLIChecks()) - if err != nil || discovery.ClaudeSDK == nil || !discovery.ClaudeSDK.Info.Available { + discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: stdout, stderr: stderr, installedKinds: map[string]bool{"claude_sdk": true}}, "acceptance", harnessDeclarations) + if err != nil || len(discovery) != 1 || !discovery[0].runtime.Info.Available { t.Fatal("SDK-only discovery failed", err) } type execution struct { @@ -182,7 +182,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { if third.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(third.Outcome.Content, nonce) { t.Fatal("registered cold continuation lost identity or history") } - data, _ := json.MarshalIndent(map[string]any{"scope": "SDK-only readiness and production registration/authoring registry -> daemon router -> pinned SDK/native -> real MiniMax; function receipt, cancellation and cold continuation; public API admission remains separate", "descriptor": discovery.ClaudeSDK.Info, "node": discovery.ClaudeSDK.Config.Node, "entrypoint": discovery.ClaudeSDK.Config.Entrypoint, "state_dir": discovery.ClaudeSDK.Config.StateDir, "verification_value": nonce, "executions": []execution{first, second, third}}, "", " ") + data, _ := json.MarshalIndent(map[string]any{"scope": "SDK-only readiness and production registration/authoring registry -> daemon router -> pinned SDK/native -> real MiniMax; function receipt, cancellation and cold continuation; public API admission remains separate", "descriptor": discovery[0].runtime.Info, "entrypoint": entrypoint, "verification_value": nonce, "executions": []execution{first, second, third}}, "", " ") if err := os.WriteFile(filepath.Join(root, "proof.json"), data, 0o600); err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/cli/claude_sdk_test.go b/apps/daemon/internal/cli/claude_sdk_test.go deleted file mode 100644 index aaa930843..000000000 --- a/apps/daemon/internal/cli/claude_sdk_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package cli - -import ( - "context" - "errors" - "os" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/authoring" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func unavailableCLIChecks() agentCLIChecks { - missing := func(context.Context, string) (string, error) { return "", errors.New("unavailable test CLI") } - return agentCLIChecks{ClaudeCode: missing, OpenCode: missing, Codex: missing, Pi: missing, MCode: missing} -} - -func TestClaudeSDKDiscoveryAndRegistration(t *testing.T) { - for _, tc := range []struct { - name string - configured, ready, legacy bool - }{ - {"unconfigured", false, false, true}, {"SDK-only", true, true, false}, - {"failed-with-legacy", true, false, true}, {"none-ready", true, false, false}, - } { - t.Run(tc.name, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - entrypoint := "" - if tc.configured { - entrypoint = filepath.Join(root, "replaceable bundle", "dist", "main.js") - } - t.Setenv(claudeSDKEntrypointEnv, entrypoint) - node, err := os.Executable() - if err != nil { - t.Fatal(err) - } - t.Setenv(claudeSDKNodeEnv, node) - calls := 0 - checks := unavailableCLIChecks() - checks.ClaudeSDK = func(_ context.Context, config claudesdk.Config) (claudesdk.RuntimeInfo, error) { - calls++ - if config.Node != node || config.Entrypoint != entrypoint || config.StateDir != filepath.Join(root, "daemon", "test", "runtime", "claude-sdk") || config.Env != nil { - t.Fatalf("readiness configuration differs from operator configuration: %+v", config) - } - if !tc.ready { - return claudesdk.RuntimeInfo{}, errors.New("controlled readiness failure") - } - return claudesdk.RuntimeInfo{SDK: "test-sdk", Native: "test-native"}, nil - } - if tc.legacy { - checks.Pi = func(context.Context, string) (string, error) { return "test-pi", nil } - } - stdout, stderr := &strings.Builder{}, &strings.Builder{} - discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: stdout, stderr: stderr}, "test", checks) - if (err == nil) != (tc.ready || tc.legacy) { - t.Fatalf("startup readiness: %v", err) - } - if (calls == 1) != tc.configured { - t.Fatalf("SDK readiness calls: %d", calls) - } - reg := agent.NewRegistry() - registerAgentKinds(reg, discovery, "https://product.invalid") - reg = authoringRegistry(reg, authoring.New(nil)) - factory, err := reg.Resolve("claude_sdk") - if !tc.configured { - if discovery.ClaudeSDK != nil || err == nil { - t.Fatal("unconfigured SDK was registered") - } - return - } - if err != nil { - t.Fatal(err) - } - info := discovery.ClaudeSDK.Info - if info.Available { - info.Capabilities.Preparation = proto.CapabilitySupported - } - if info.Available != tc.ready { - t.Fatal(info) - } - for _, registered := range reg.SupportedAgentKinds() { - if registered.Kind == "claude_sdk" && registered != info { - t.Fatalf("SDK descriptor changed: %+v", registered) - } - if registered.Kind != "claude_sdk" && !registered.Capabilities.WorkspaceAuthoring.IsSupported() { - t.Fatalf("product authoring lost: %+v", registered) - } - } - caps := info.Capabilities - if caps.WorkspaceAuthoring.IsSupported() || caps.Permissions.IsSupported() || caps.WebSearchControl.IsSupported() || caps.TextVerbosity.IsSupported() || !caps.DurableTurns.IsSupported() || !caps.DurableInputReceipts.IsSupported() || !caps.FunctionTools.IsSupported() || !caps.EnvironmentNone.IsSupported() { - t.Fatalf("incorrect SDK capability scope: %+v", caps) - } - // Even a ready SDK must not acquire product write access through the wrapper. - _, err = factory(t.Context(), proto.PromptRequestPayload{RunID: "sdk", Input: proto.TextInput("hello"), WorkspaceAuthoring: true}, make(chan proto.Envelope, 1)) - if err == nil || (!tc.ready && !strings.Contains(err.Error(), "runtime is unavailable")) { - t.Fatalf("SDK request did not fail closed: %v", err) - } - }) - } -} - -func TestClaudeSDKInvalidPathsFailBeforeProbe(t *testing.T) { - for _, relative := range []string{"entrypoint", "home"} { - t.Run(relative, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "main.js")) - if relative == "entrypoint" { - t.Setenv(claudeSDKEntrypointEnv, "main.js") - } else { - t.Setenv("OAC_RUNTIME_HOME", "relative-home") - } - out := discoverClaudeSDK(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { - t.Fatal("invalid paths reached runtime probe") - return claudesdk.RuntimeInfo{}, nil - }) - if out == nil || out.Info.Available { - t.Fatal("invalid runtime advertised as ready") - } - }) - } -} - -func TestClaudeSDKFeatureDiscovery(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "main.js")) - node, err := os.Executable() - if err != nil { - t.Fatal(err) - } - t.Setenv(claudeSDKNodeEnv, node) - for _, features := range [][]string{nil, {"mcp_http_tools"}, {"mcp_http_bearer_auth"}, {"mcp_http_tools", "mcp_http_bearer_auth"}, {"mcp_http_required"}, {"mcp_http_tools", "mcp_http_required"}, {"subagent_resources"}, {"structured_output"}} { - out := discoverClaudeSDK(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { - info := claudesdk.RuntimeInfo{SDK: "0.3.269", Native: "2.1.269 (Claude Code)", Features: features} - return info, nil - }) - supported := len(features) > 0 && features[0] == "mcp_http_tools" - if out == nil || !out.Info.Available || out.Info.Capabilities.MCPHTTPTools.IsSupported() != supported || out.Info.Capabilities.MCPHTTPBearerAuth.IsSupported() != (supported && slices.Contains(features, "mcp_http_bearer_auth")) || out.Info.Capabilities.MCPHTTPRequired.IsSupported() != (supported && slices.Contains(features, "mcp_http_required")) { - t.Fatal("MCP feature discovery widened the runtime profile") - } - if out.Info.Capabilities.StructuredOutput.IsSupported() != slices.Contains(features, "structured_output") { - t.Fatal("structured output feature does not match the installed runtime") - } - if out.Info.Capabilities.SubagentObservations.IsSupported() != slices.Contains(features, "subagent_resources") { - t.Fatal("Subagent feature discovery does not match the runtime contract") - } - } -} diff --git a/apps/daemon/internal/cli/connect.go b/apps/daemon/internal/cli/connect.go index ebd412693..7fb6e82f8 100644 --- a/apps/daemon/internal/cli/connect.go +++ b/apps/daemon/internal/cli/connect.go @@ -24,10 +24,6 @@ import ( ) const ( - // cliVersionTimeout caps CLI `--version` preflights so a hung agent - // binary can't keep `oac-daemon connect` blocked at startup. - cliVersionTimeout = 15 * time.Second - bootstrapTimeout = 10 * time.Second // Allow the native process grace period and subsequent owner/pipe cleanup. diff --git a/apps/daemon/internal/cli/connect_test.go b/apps/daemon/internal/cli/connect_test.go index 51ec733c3..8a5daeb03 100644 --- a/apps/daemon/internal/cli/connect_test.go +++ b/apps/daemon/internal/cli/connect_test.go @@ -1,20 +1,10 @@ package cli import ( - "context" "os" "reflect" "strings" "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudecode" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" - opencodeagent "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/opencode" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/pi" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" ) func TestScrubInlineConnectArgsRemovesTokenURLAndDeviceName(t *testing.T) { @@ -68,207 +58,3 @@ func TestLoadInlineConnectEnvHydratesParentProcessFlags(t *testing.T) { t.Fatalf("inlinePair=false after env hydration; serverURL=%q token=%q", serverURL, token) } } - -func TestDiscoverAgentCLIsAllowsOpenCodeWithoutClaude(t *testing.T) { - t.Setenv(claudeSDKEntrypointEnv, "") - stdout, stderr := &strings.Builder{}, &strings.Builder{} - rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(t.Context(), rc, "default", agentCLIChecks{ - MCode: func(context.Context, string) (string, error) { return "", mcode.ErrCLINotFound }, - ClaudeCode: func(context.Context, string) (string, error) { - return "", claudecode.ErrCLINotFound - }, - OpenCode: func(context.Context, string) (string, error) { - return "opencode 1.4.3", nil - }, - Codex: func(context.Context, string) (string, error) { - return "", codex.ErrCLINotFound - }, - Pi: func(context.Context, string) (string, error) { - return "", pi.ErrCLINotFound - }, - }) - if err != nil { - t.Fatalf("discoverAgentCLIs: %v", err) - } - if got.ClaudeCode.Available { - t.Fatalf("ClaudeCode.Available = true, want false: %#v", got.ClaudeCode) - } - if !got.OpenCode.Available || got.OpenCode.Version != "opencode 1.4.3" { - t.Fatalf("OpenCode descriptor = %#v", got.OpenCode) - } - if got.Codex.Available { - t.Fatalf("Codex.Available = true, want false: %#v", got.Codex) - } - if got.Pi.Available { - t.Fatalf("Pi.Available = true, want false: %#v", got.Pi) - } - if !got.OpenCode.Capabilities.Streaming.IsSupported() || !got.OpenCode.Capabilities.Usage.IsSupported() || got.OpenCode.Capabilities.Permissions.IsSupported() { - t.Fatalf("OpenCode capabilities = %#v", got.OpenCode.Capabilities) - } - if !strings.Contains(stdout.String(), "OpenCode preflight ok") { - t.Fatalf("stdout missing OpenCode ok line: %q", stdout.String()) - } - if !strings.Contains(stderr.String(), "claude_code unavailable") { - t.Fatalf("stderr missing Claude unavailable line: %q", stderr.String()) - } -} - -func TestDiscoverAgentCLIsBothMissingFails(t *testing.T) { - t.Setenv(claudeSDKEntrypointEnv, "") - stdout, stderr := &strings.Builder{}, &strings.Builder{} - rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(t.Context(), rc, "default", agentCLIChecks{ - MCode: func(context.Context, string) (string, error) { return "", mcode.ErrCLINotFound }, - ClaudeCode: func(context.Context, string) (string, error) { - return "", claudecode.ErrCLINotFound - }, - OpenCode: func(context.Context, string) (string, error) { - return "", opencodeagent.ErrCLINotFound - }, - Codex: func(context.Context, string) (string, error) { - return "", codex.ErrCLINotFound - }, - Pi: func(context.Context, string) (string, error) { - return "", pi.ErrCLINotFound - }, - }) - if err == nil { - t.Fatalf("expected error when all CLIs missing, got descriptors %#v", got) - } - if !strings.Contains(err.Error(), "no supported agent CLI") { - t.Fatalf("unexpected error: %v", err) - } - if got.ClaudeCode.Available || got.OpenCode.Available || got.Codex.Available || got.Pi.Available { - t.Fatalf("available descriptors after missing CLIs: %#v", got) - } -} - -func TestDiscoverAgentCLIsBothAvailable(t *testing.T) { - t.Setenv(claudeSDKEntrypointEnv, "") - stdout, stderr := &strings.Builder{}, &strings.Builder{} - rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(t.Context(), rc, "default", agentCLIChecks{ - MCode: func(context.Context, string) (string, error) { return "0.4.12", nil }, - ClaudeCode: func(context.Context, string) (string, error) { - return "claude 2.0.0", nil - }, - OpenCode: func(context.Context, string) (string, error) { - return "opencode 1.4.3", nil - }, - Codex: func(context.Context, string) (string, error) { - return "codex 0.141.0", nil - }, - Pi: func(context.Context, string) (string, error) { - return "pi 0.1.0", nil - }, - }) - if err != nil { - t.Fatalf("discoverAgentCLIs: %v", err) - } - if !got.ClaudeCode.Available || got.ClaudeCode.Version != "claude 2.0.0" { - t.Fatalf("ClaudeCode descriptor = %#v", got.ClaudeCode) - } - if !got.OpenCode.Available || got.OpenCode.Version != "opencode 1.4.3" { - t.Fatalf("OpenCode descriptor = %#v", got.OpenCode) - } - if !got.Codex.Available || got.Codex.Version != "codex 0.141.0" { - t.Fatalf("Codex descriptor = %#v", got.Codex) - } - if !got.ClaudeCode.Capabilities.Permissions.IsSupported() || !got.ClaudeCode.Capabilities.Resume.IsSupported() { - t.Fatalf("ClaudeCode capabilities = %#v", got.ClaudeCode.Capabilities) - } - if got.Codex.Capabilities.ExecutionControls.IsSupported() != codex.SupportsTextVerbosity || got.ClaudeCode.Capabilities.ExecutionControls.IsSupported() || got.OpenCode.Capabilities.ExecutionControls.IsSupported() || !got.Codex.Capabilities.ToolObservations.IsSupported() || !got.Codex.Capabilities.SubagentControl.IsSupported() || got.Codex.Capabilities.TextVerbosity.IsSupported() != codex.SupportsTextVerbosity || !got.Codex.Capabilities.WebSearchControl.IsSupported() || !got.Codex.Capabilities.EnvironmentNone.IsSupported() || !got.Codex.Capabilities.MessageItems.IsSupported() || !got.Codex.Capabilities.Streaming.IsSupported() || !got.Codex.Capabilities.Permissions.IsSupported() || !got.Codex.Capabilities.Resume.IsSupported() { - t.Fatalf("Codex capabilities = %#v (want Streaming+Permissions+Resume)", got.Codex.Capabilities) - } - if !got.Pi.Available || got.Pi.Version != "pi 0.1.0" { - t.Fatalf("Pi descriptor = %#v", got.Pi) - } - if !got.Pi.Capabilities.Streaming.IsSupported() || !got.Pi.Capabilities.Usage.IsSupported() || !got.Pi.Capabilities.Resume.IsSupported() || got.Pi.Capabilities.Permissions.IsSupported() { - t.Fatalf("Pi capabilities = %#v (want Streaming+Usage+Resume, no Permissions)", got.Pi.Capabilities) - } - if !got.MCode.Available || !got.MCode.Capabilities.Resume.IsSupported() || got.MCode.Capabilities.Usage.IsSupported() { - t.Fatalf("mcode descriptor = %#v", got.MCode) - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } -} - -func TestRegisterAgentKindsPreservesDescriptors(t *testing.T) { - reg := agent.NewRegistry() - registerAgentKinds(reg, agentCLIDiscovery{ - MCode: proto.SupportedAgentKind{Kind: "mcode", Available: true, Version: "0.4.12", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Streaming: proto.CapabilitySupported, Permissions: proto.CapabilitySupported, Resume: proto.CapabilitySupported})}, - ClaudeCode: proto.SupportedAgentKind{ - Kind: "claude_code", - Available: true, - Version: "claude 2.0.0", - Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilitySupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, - }), - }, - OpenCode: proto.SupportedAgentKind{ - Kind: "opencode", - Available: false, - Version: "missing", - Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - Streaming: proto.CapabilitySupported, - Usage: proto.CapabilitySupported, - }), - }, - Codex: proto.SupportedAgentKind{ - Kind: "codex", - Available: true, - Version: "codex 0.141.0", - Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilitySupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, - }), - }, - Pi: proto.SupportedAgentKind{ - Kind: "pi", - Available: true, - Version: "pi 0.1.0", - Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - Streaming: proto.CapabilitySupported, - Usage: proto.CapabilitySupported, - Resume: proto.CapabilitySupported, - }), - }, - }, "https://core.example.test") - - kinds := reg.SupportedAgentKinds() - if len(kinds) != 5 { - t.Fatalf("SupportedAgentKinds len = %d, want 5: %#v", len(kinds), kinds) - } - // Sorted: claude_code, codex, mcode, opencode, pi. - if kinds[0].Kind != "claude_code" || kinds[1].Kind != "codex" || kinds[2].Kind != "mcode" || kinds[3].Kind != "opencode" || kinds[4].Kind != "pi" { - t.Fatalf("SupportedAgentKinds sort = %#v", kinds) - } - if !kinds[0].Available || kinds[0].Version != "claude 2.0.0" || !kinds[0].Capabilities.Permissions.IsSupported() { - t.Fatalf("claude descriptor not preserved: %#v", kinds[0]) - } - if !kinds[1].Available || kinds[1].Version != "codex 0.141.0" || !kinds[1].Capabilities.Resume.IsSupported() { - t.Fatalf("codex descriptor not preserved: %#v", kinds[1]) - } - if kinds[3].Available || kinds[3].Version != "missing" || !kinds[3].Capabilities.Streaming.IsSupported() || !kinds[3].Capabilities.Usage.IsSupported() { - t.Fatalf("opencode descriptor not preserved: %#v", kinds[3]) - } - if !kinds[4].Available || kinds[4].Version != "pi 0.1.0" || !kinds[4].Capabilities.Resume.IsSupported() || kinds[4].Capabilities.Permissions.IsSupported() { - t.Fatalf("pi descriptor not preserved: %#v", kinds[4]) - } - if _, err := reg.Resolve("opencode"); err != nil { - t.Fatalf("opencode factory not registered: %v", err) - } - if _, err := reg.Resolve("codex"); err != nil { - t.Fatalf("codex factory not registered: %v", err) - } - if _, err := reg.Resolve("pi"); err != nil { - t.Fatalf("pi factory not registered: %v", err) - } -} diff --git a/apps/daemon/internal/cli/mcode.go b/apps/daemon/internal/cli/mcode.go deleted file mode 100644 index 38774e77d..000000000 --- a/apps/daemon/internal/cli/mcode.go +++ /dev/null @@ -1,71 +0,0 @@ -package cli - -import ( - "context" - "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" -) - -func discoverMCode(parent context.Context, rc *runContext, check func(context.Context, string) (string, error)) proto.SupportedAgentKind { - if check == nil { - check = mcode.CheckCLIAvailable - } - result := proto.SupportedAgentKind{Kind: "mcode", Capabilities: proto.AgentKindCapabilities{ - SubagentObservations: proto.CapabilityUnsupported, - Streaming: proto.CapabilitySupported, - Permissions: proto.CapabilitySupported, - Usage: proto.CapabilityUnsupported, - Resume: proto.CapabilitySupported, - NativeSessionRecovery: proto.CapabilityUnsupported, - WorkspaceAuthoring: proto.CapabilityUnsupported, - Steering: proto.CapabilityUnsupported, - MessageItems: proto.CapabilityUnsupported, - ToolObservations: proto.CapabilityUnsupported, - EnvironmentNone: proto.CapabilityUnsupported, - LocalEnvironment: proto.CapabilityUnsupported, - Preparation: proto.CapabilityUnsupported, - WorkspaceReadPreparation: proto.CapabilityUnsupported, - WorkspaceOutputExport: proto.CapabilityUnsupported, - ProgrammaticToolCallingDisable: proto.CapabilityUnsupported, - WebSearchControl: proto.CapabilityUnsupported, - ExecutionControls: proto.CapabilityUnsupported, - TextVerbosity: proto.CapabilityUnsupported, - StructuredOutput: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, - MessageImages: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - SubagentControl: proto.CapabilityUnsupported, - DurableInputReceipts: proto.CapabilityUnsupported, - DurableTurns: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - MCPHTTPTools: proto.CapabilityUnsupported, - MCPHTTPRequired: proto.CapabilityUnsupported, - MCPHTTPBearerAuth: proto.CapabilityUnsupported, - }} - ctx, cancel := context.WithTimeout(parent, cliVersionTimeout) - defer cancel() - version, err := check(ctx, "") - if err != nil { - fmt.Fprintf(rc.stderr, "oac-daemon: mcode unavailable: %v\n Install: npm install -g @minimax-ai/code@0.4.12\n", err) - return result - } - result.Available, result.Version = true, version - if mcode.SupportsExecution(version) { - result.Capabilities.Steering = proto.CapabilitySupported - result.Capabilities.DurableTurns = proto.CapabilitySupported - result.Capabilities.DurableInputReceipts = proto.CapabilitySupported - result.Capabilities.ExecutionControls = proto.CapabilitySupported - result.Capabilities.ProgrammaticToolCallingDisable = proto.CapabilitySupported - result.Capabilities.ToolObservations = proto.CapabilitySupported - result.Capabilities.SubagentControl = proto.CapabilitySupported - // Native preparation verifies the applied admission/tool profile before input. - result.Capabilities.SubagentObservations = proto.CapabilitySupported - result.Capabilities.EnvironmentNone = proto.CapabilitySupported - result.Capabilities.MCPHTTPTools = proto.CapabilitySupported - result.Capabilities.MCPHTTPBearerAuth = proto.CapabilitySupported - } - fmt.Fprintf(rc.stdout, "mcode preflight ok (%s)\n", version) - return result -} diff --git a/apps/daemon/internal/cli/mcode_execution_test.go b/apps/daemon/internal/cli/mcode_execution_test.go deleted file mode 100644 index cbf8bab97..000000000 --- a/apps/daemon/internal/cli/mcode_execution_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package cli - -import ( - "context" - "io" - "testing" -) - -func TestMCodeExecutionOptInIsVersionBound(t *testing.T) { - for _, tc := range []struct { - enabled, version string - qualified bool - }{{"", "0.4.12", false}, {"1", "0.3.11", false}, {"1", "0.4.12", true}} { - t.Run(tc.enabled+"/"+tc.version, func(t *testing.T) { - t.Setenv("OAC_RUNTIME_MCODE_AGENTS_API", tc.enabled) - rc := &runContext{stdout: io.Discard, stderr: io.Discard} - info := discoverMCode(t.Context(), rc, func(context.Context, string) (string, error) { return tc.version, nil }) - if !info.Available || info.Capabilities.EnvironmentNone.IsSupported() != tc.qualified || info.Capabilities.DurableInputReceipts.IsSupported() != tc.qualified || info.Capabilities.SubagentObservations.IsSupported() != tc.qualified { - t.Fatalf("capabilities=%+v", info.Capabilities) - } - if info.Capabilities.NativeSessionRecovery.IsSupported() || info.Capabilities.LocalEnvironment.IsSupported() || info.Capabilities.FunctionTools.IsSupported() { - t.Fatal("unqualified capability advertised") - } - }) - } -} diff --git a/apps/daemon/internal/cli/mcp_test.go b/apps/daemon/internal/cli/mcp_test.go deleted file mode 100644 index e66412799..000000000 --- a/apps/daemon/internal/cli/mcp_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package cli - -import ( - "context" - "strings" - "testing" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" -) - -func TestMCPHTTPBearerDiscoveryExcludesUnconfiguredSDK(t *testing.T) { - t.Setenv(claudeSDKEntrypointEnv, "") - checks := unavailableCLIChecks() - checks.Codex = func(context.Context, string) (string, error) { return "codex 0.153.4", nil } - discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) - if err != nil { - t.Fatal(err) - } - registry := agent.NewRegistry() - registerAgentKinds(registry, discovery, "https://service.example") - for _, kind := range registry.SupportedAgentKinds() { - if kind.Capabilities.MCPHTTPBearerAuth.IsSupported() != (kind.Kind == "codex") { - t.Fatal("bearer capability missing or advertised for another adapter") - } - if kind.Kind == "codex" && (!kind.Available || !kind.Capabilities.MCPHTTPTools.IsSupported() || !kind.Capabilities.EnvironmentNone.IsSupported()) { - t.Fatal("bearer capability lacks prerequisite profile") - } - } -} - -func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { - for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3", "codex-cli 0.154.0"} { - checks := unavailableCLIChecks() - checks.Codex = func(context.Context, string) (string, error) { return version, nil } - got, err := discoverAgentCLIs(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) - if err != nil { - t.Fatal(err) - } - if got.Codex.Capabilities.MCPHTTPRequired.IsSupported() != (version == "codex-cli 0.153.4") { - t.Fatal("unverified native combination advertised") - } - if got.Codex.Capabilities.NativeSessionRecovery.IsSupported() != (version == "codex-cli 0.153.4") { - t.Fatal("unverified native recovery advertised") - } - if got.ClaudeCode.Capabilities.MCPHTTPRequired.IsSupported() || got.OpenCode.Capabilities.MCPHTTPRequired.IsSupported() || got.Pi.Capabilities.MCPHTTPRequired.IsSupported() { - t.Fatal("other engine advertised combination") - } - } -} diff --git a/apps/daemon/internal/cli/native_discovery_test.go b/apps/daemon/internal/cli/native_discovery_test.go index cedf1c5a2..8063e0dbd 100644 --- a/apps/daemon/internal/cli/native_discovery_test.go +++ b/apps/daemon/internal/cli/native_discovery_test.go @@ -2,61 +2,75 @@ package cli import ( "context" + "errors" "io" - "os" - "path/filepath" "reflect" "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestNativeInstallationDiscoversAndRegistersOnlySelectedHarness(t *testing.T) { - for _, name := range []string{"codex", "claude", "minimax"} { - t.Run(name, func(t *testing.T) { - root := t.TempDir() - t.Setenv("OAC_RUNTIME_HOME", root) - t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "host-claude", "main.js")) - binary, err := os.Executable() - if err != nil { - t.Fatal(err) +func TestDiscoveryAndRegistration(t *testing.T) { + for _, selected := range []string{"codex", "mcode", "claude_sdk", ""} { + t.Run(selected, func(t *testing.T) { + called := []string{} + declarations := append([]agent.Declaration(nil), harnessDeclarations...) + for i := range declarations { + declarations[i].Discover = func(_ context.Context, _ agent.DiscoveryOptions, info proto.SupportedAgentKind) *agent.Runtime { + called = append(called, info.Kind) + info.Available = true + info.Version = "test" + return &agent.Runtime{Info: info, Session: func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("test") + }} + } } - t.Setenv(claudeSDKNodeEnv, binary) - called := make(map[string]bool) - check := func(kind, version string) func(context.Context, string) (string, error) { - return func(context.Context, string) (string, error) { called[kind] = true; return version, nil } + rc := &runContext{stdout: io.Discard, stderr: io.Discard} + expected := []string{"codex", "mcode", "claude_sdk"} + if selected != "" { + rc.installedKinds = map[string]bool{selected: true} + expected = []string{selected} } - checks := agentCLIChecks{ - ClaudeCode: check("claude_code", "2.1.269"), - OpenCode: check("opencode", "1.0.0"), - Codex: check("codex", "codex-cli 0.153.4"), - Pi: check("pi", "0.1.0"), - MCode: check("mcode", "0.4.12"), - ClaudeSDK: func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { - called["claude_sdk"] = true - return claudesdk.RuntimeInfo{SDK: "0.3.269"}, nil - }, - } - kinds := nativeInstallationKinds([]string{name}) - rc := &runContext{stdout: io.Discard, stderr: io.Discard, installedKinds: kinds} - path := os.Getenv("PATH") - discovery, err := discoverAgentCLIs(t.Context(), rc, "default", checks) + discovery, err := discoverAgentCLIs(t.Context(), rc, "default", declarations) if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(called, kinds) { - t.Fatalf("probed unselected host Harness: got %v, want %v", called, kinds) + if !reflect.DeepEqual(called, expected) { + t.Fatalf("probes %v, want %v", called, expected) } registry := agent.NewRegistry() registerAgentKinds(registry, discovery, "") - expected := []string{nativeHarnesses[name].AgentKind} - if !reflect.DeepEqual(registry.Kinds(), expected) { - t.Fatalf("registered unselected host Harness: %v", registry.Kinds()) + if len(registry.Kinds()) != len(expected) { + t.Fatal(registry.Kinds()) } - if os.Getenv("PATH") != path { - t.Fatal("installation discovery removed the tool PATH") + for _, info := range registry.SupportedAgentKinds() { + if !info.Available || info.Version != "test" { + t.Fatal(info) + } } }) } } +func TestDiscoveryUnavailableAndCancelled(t *testing.T) { + declarations := append([]agent.Declaration(nil), harnessDeclarations...) + for i := range declarations { + declarations[i].Discover = func(_ context.Context, _ agent.DiscoveryOptions, info proto.SupportedAgentKind) *agent.Runtime { + return &agent.Runtime{Info: info} + } + } + rc := &runContext{stdout: io.Discard, stderr: io.Discard} + if _, err := discoverAgentCLIs(t.Context(), rc, "default", declarations); err == nil { + t.Fatal("unavailable runtimes admitted") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := discoverAgentCLIs(ctx, rc, "default", declarations); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + declarations[0].Discover = func(context.Context, agent.DiscoveryOptions, proto.SupportedAgentKind) *agent.Runtime { return nil } + discovery, _ := discoverAgentCLIs(t.Context(), rc, "default", declarations) + if len(discovery) != 2 { + t.Fatal("unconfigured adapter retained") + } +} diff --git a/apps/daemon/internal/cli/preparation_test.go b/apps/daemon/internal/cli/preparation_test.go index 6d26eff60..2fdb8a189 100644 --- a/apps/daemon/internal/cli/preparation_test.go +++ b/apps/daemon/internal/cli/preparation_test.go @@ -16,7 +16,16 @@ import ( func TestPreparationRegistrationBypassesProductWrappers(t *testing.T) { for _, supported := range []bool{false, true} { reg := agent.NewRegistry() - registerAgentKinds(reg, agentCLIDiscovery{Codex: proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilityFromBool(supported)})}, ClaudeCode: proto.SupportedAgentKind{Kind: "claude_code", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, OpenCode: proto.SupportedAgentKind{Kind: "opencode", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, Pi: proto.SupportedAgentKind{Kind: "pi", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, MCode: proto.SupportedAgentKind{Kind: "mcode", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}}, "http://unreachable.invalid") + info := proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{LocalEnvironment: proto.CapabilityFromBool(supported)})} + runtime := agent.Runtime{Info: info, Session: func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, nil + }} + if supported { + runtime.Preparation = func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return nil, nil } + runtime.WorkspaceReadPreparation = true + } + registerAgentKinds(reg, agentCLIDiscovery{{declaration: agent.Declaration{Info: info}, runtime: runtime}}, "http://unreachable.invalid") + _, err := reg.ResolvePreparation("codex") if (err == nil) != supported { t.Fatal("unverified native version advertised preparation") diff --git a/apps/daemon/internal/dispatch/cancellation_test.go b/apps/daemon/internal/dispatch/cancellation_test.go index d058d8bfb..0b0f008d9 100644 --- a/apps/daemon/internal/dispatch/cancellation_test.go +++ b/apps/daemon/internal/dispatch/cancellation_test.go @@ -111,7 +111,7 @@ func TestCancellationReceiptFollowsAdapterOutcome(t *testing.T) { func TestLegacyCancellationDoesNotEmitNewFrames(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "legacy", proto.PromptRequestPayload{AgentKind: "claude_code"})); err != nil { + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "legacy", proto.PromptRequestPayload{AgentKind: "fake_alpha"})); err != nil { t.Fatal(err) } sess := <-h.gotSess diff --git a/apps/daemon/internal/dispatch/environment_test.go b/apps/daemon/internal/dispatch/environment_test.go index 982f4a0af..0e72f9020 100644 --- a/apps/daemon/internal/dispatch/environment_test.go +++ b/apps/daemon/internal/dispatch/environment_test.go @@ -16,11 +16,11 @@ func TestNoEnvironmentRejectsOtherEngineBeforeFactory(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) called := false - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{})}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { called = true return nil, nil }) - err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "none", proto.PromptRequestPayload{AgentKind: "claude_code", DisableExecutionEnvironment: true})) + err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "none", proto.PromptRequestPayload{AgentKind: "fake_alpha", DisableExecutionEnvironment: true})) if err == nil || called { t.Fatal("unsupported engine was started", err) } diff --git a/apps/daemon/internal/dispatch/router_test.go b/apps/daemon/internal/dispatch/router_test.go index 71f4a01d6..29b4af433 100644 --- a/apps/daemon/internal/dispatch/router_test.go +++ b/apps/daemon/internal/dispatch/router_test.go @@ -132,7 +132,7 @@ func (s *fakeSession) submissions() []permCall { // --------------------------------------------------------------------- // newHarness builds a Router whose registry exposes a single -// claude_code factory that records inputs and exposes the in-flight +// fake_alpha factory that records inputs and exposes the in-flight // session. type harness struct { router *dispatch.Router @@ -154,7 +154,7 @@ func newHarnessWithIdleTimeout(t *testing.T, idleTimeout time.Duration) *harness gotReq: make(chan proto.PromptRequestPayload, 16), gotSess: make(chan *fakeSession, 16), } - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "claude_code", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Permissions: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "fake_alpha", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{Permissions: proto.CapabilitySupported})}, harnessconfig.Configuration{}, func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { sess := &fakeSession{out: out, ctx: ctx} h.gotReq <- req h.gotSess <- sess @@ -173,7 +173,7 @@ func TestCompletedSessionCancelsAfterIdleTimeout(t *testing.T) { defer h.router.Shutdown(context.Background()) env := mustEnv(t, proto.TypePromptRequest, "run_idle", proto.PromptRequestPayload{ - AgentKind: "claude_code", ConversationID: "conv-idle", AgentStateKey: "conv-idle/agent/claude_code", + AgentKind: "fake_alpha", ConversationID: "conv-idle", AgentStateKey: "conv-idle/agent/fake_alpha", }) if err := h.router.Handle(context.Background(), env); err != nil { t.Fatalf("Handle prompt_request: %v", err) @@ -194,9 +194,9 @@ func TestNewPromptResetsCompletedSessionIdleTimeout(t *testing.T) { h := newHarnessWithIdleTimeout(t, 80*time.Millisecond) defer h.router.Shutdown(context.Background()) - stateKey := "conv-renew/agent/claude_code" + stateKey := "conv-renew/agent/fake_alpha" first := mustEnv(t, proto.TypePromptRequest, "run_first", proto.PromptRequestPayload{ - AgentKind: "claude_code", ConversationID: "conv-renew", AgentStateKey: stateKey, + AgentKind: "fake_alpha", ConversationID: "conv-renew", AgentStateKey: stateKey, }) if err := h.router.Handle(context.Background(), first); err != nil { t.Fatalf("Handle first prompt: %v", err) @@ -207,7 +207,7 @@ func TestNewPromptResetsCompletedSessionIdleTimeout(t *testing.T) { time.Sleep(50 * time.Millisecond) second := mustEnv(t, proto.TypePromptRequest, "run_second", proto.PromptRequestPayload{ - AgentKind: "claude_code", ConversationID: "conv-renew", AgentStateKey: stateKey, + AgentKind: "fake_alpha", ConversationID: "conv-renew", AgentStateKey: stateKey, }) if err := h.router.Handle(context.Background(), second); err != nil { t.Fatalf("Handle second prompt: %v", err) @@ -240,15 +240,15 @@ func TestHandlePromptRequestInvokesFactoryAndForwardsOutput(t *testing.T) { defer h.router.Shutdown(context.Background()) env := mustEnv(t, proto.TypePromptRequest, "run_1", proto.PromptRequestPayload{ - AgentKind: "claude_code", Input: proto.TextInput("hi"), ConversationID: "c1", + AgentKind: "fake_alpha", Input: proto.TextInput("hi"), ConversationID: "c1", }) if err := h.router.Handle(context.Background(), env); err != nil { t.Fatalf("Handle prompt_request: %v", err) } req := <-h.gotReq - if req.RunID != "run_1" || req.AgentKind != "claude_code" || *req.Input[0].Content[0].Text != "hi" { - t.Errorf("factory got %+v, want run_1/claude_code/hi", req) + if req.RunID != "run_1" || req.AgentKind != "fake_alpha" || *req.Input[0].Content[0].Text != "hi" { + t.Errorf("factory got %+v, want run_1/fake_alpha/hi", req) } sess := <-h.gotSess @@ -267,7 +267,7 @@ func TestHandlePromptRequestRejectsDuplicateRunID(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_dup", proto.PromptRequestPayload{AgentKind: "claude_code"}) + env := mustEnv(t, proto.TypePromptRequest, "run_dup", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) if err := h.router.Handle(context.Background(), env); err != nil { t.Fatalf("first Handle: %v", err) } @@ -291,7 +291,7 @@ func TestHandlePromptRequestUnsupportedKindEmitsErrorDone(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_x", proto.PromptRequestPayload{AgentKind: "opencode"}) + env := mustEnv(t, proto.TypePromptRequest, "run_x", proto.PromptRequestPayload{AgentKind: "fake_beta"}) err := h.router.Handle(context.Background(), env) if !errors.Is(err, agent.ErrUnsupportedKind) { t.Errorf("Handle unsupported = %v, want ErrUnsupportedKind", err) @@ -307,7 +307,7 @@ func TestHandlePromptRequestMissingRunIDIsError(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "", proto.PromptRequestPayload{AgentKind: "claude_code"}) + env := mustEnv(t, proto.TypePromptRequest, "", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) if err := h.router.Handle(context.Background(), env); err == nil { t.Fatal("expected error on missing run id") } @@ -317,7 +317,7 @@ func TestHandlePromptCancelInvokesSessionCancel(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_2", proto.PromptRequestPayload{AgentKind: "claude_code"}) + env := mustEnv(t, proto.TypePromptRequest, "run_2", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) if err := h.router.Handle(context.Background(), env); err != nil { t.Fatalf("prompt_request: %v", err) } @@ -346,7 +346,7 @@ func TestPermissionRequestIsIndexedAndDecisionRoutes(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_p", proto.PromptRequestPayload{AgentKind: "claude_code"}) + env := mustEnv(t, proto.TypePromptRequest, "run_p", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) if err := h.router.Handle(context.Background(), env); err != nil { t.Fatalf("prompt_request: %v", err) } @@ -391,7 +391,7 @@ func TestPermissionCancelDeindexes(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_p2", proto.PromptRequestPayload{AgentKind: "claude_code"}) + env := mustEnv(t, proto.TypePromptRequest, "run_p2", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) _ = h.router.Handle(context.Background(), env) <-h.gotReq sess := <-h.gotSess @@ -427,7 +427,7 @@ func TestPromptForUserChoiceDecisionRoutesToSession(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_ask", proto.PromptRequestPayload{AgentKind: "claude_code"}) + env := mustEnv(t, proto.TypePromptRequest, "run_ask", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) if err := h.router.Handle(context.Background(), env); err != nil { t.Fatalf("prompt_request: %v", err) } @@ -484,7 +484,7 @@ func TestPromptForUserChoiceDecisionClearsIndexOnAgentUnknown(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_ask_u", proto.PromptRequestPayload{AgentKind: "claude_code"}) + env := mustEnv(t, proto.TypePromptRequest, "run_ask_u", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) if err := h.router.Handle(context.Background(), env); err != nil { t.Fatalf("prompt_request: %v", err) } @@ -522,7 +522,7 @@ func TestPromptForUserChoiceDecisionKeepsIndexOnTransientAgentError(t *testing.T h := newHarness(t) defer h.router.Shutdown(context.Background()) - env := mustEnv(t, proto.TypePromptRequest, "run_ask_retry", proto.PromptRequestPayload{AgentKind: "claude_code"}) + env := mustEnv(t, proto.TypePromptRequest, "run_ask_retry", proto.PromptRequestPayload{AgentKind: "fake_alpha"}) if err := h.router.Handle(context.Background(), env); err != nil { t.Fatalf("prompt_request: %v", err) } @@ -596,7 +596,7 @@ func TestHandleDeviceShutdownCancelsAllSessions(t *testing.T) { defer h.router.Shutdown(context.Background()) for _, rid := range []string{"r1", "r2", "r3"} { - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, rid, proto.PromptRequestPayload{AgentKind: "claude_code"})); err != nil { + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, rid, proto.PromptRequestPayload{AgentKind: "fake_alpha"})); err != nil { t.Fatalf("start %s: %v", rid, err) } <-h.gotReq @@ -629,7 +629,7 @@ func TestHandleAfterShutdownReturnsErrRouterClosed(t *testing.T) { if err := h.router.Shutdown(context.Background()); err != nil { t.Fatalf("Shutdown: %v", err) } - err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "r", proto.PromptRequestPayload{AgentKind: "claude_code"})) + err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "r", proto.PromptRequestPayload{AgentKind: "fake_alpha"})) if !errors.Is(err, dispatch.ErrRouterClosed) { t.Errorf("post-shutdown Handle = %v, want ErrRouterClosed", err) } @@ -638,7 +638,7 @@ func TestHandleAfterShutdownReturnsErrRouterClosed(t *testing.T) { func TestShutdownWaitsForPumpDrain(t *testing.T) { h := newHarness(t) - if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "rs", proto.PromptRequestPayload{AgentKind: "claude_code"})); err != nil { + if err := h.router.Handle(context.Background(), mustEnv(t, proto.TypePromptRequest, "rs", proto.PromptRequestPayload{AgentKind: "fake_alpha"})); err != nil { t.Fatalf("prompt_request: %v", err) } <-h.gotReq diff --git a/apps/daemon/internal/dispatch/steering_test.go b/apps/daemon/internal/dispatch/steering_test.go index 4f4158a70..c989afd7c 100644 --- a/apps/daemon/internal/dispatch/steering_test.go +++ b/apps/daemon/internal/dispatch/steering_test.go @@ -139,7 +139,7 @@ func TestSteeringReadinessAndUnsupportedRuns(t *testing.T) { if ack := lastSteeringAck(t, h.sender, "run-1", "input-1"); ack.ErrorCode != "run_inactive" { t.Fatalf("inactive: %+v", ack) } - if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-2", proto.PromptRequestPayload{AgentKind: "claude_code"})); err != nil { + if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, "run-2", proto.PromptRequestPayload{AgentKind: "fake_alpha"})); err != nil { t.Fatal(err) } session := <-h.gotSess @@ -183,7 +183,7 @@ func TestSteeringDoesNotBlockOtherRunCancellation(t *testing.T) { }, nil }) ctx := context.Background() - for _, run := range []struct{ id, engine string }{{"run-1", "codex"}, {"run-2", "claude_code"}} { + for _, run := range []struct{ id, engine string }{{"run-1", "codex"}, {"run-2", "fake_alpha"}} { if err := h.router.Handle(ctx, mustEnv(t, proto.TypePromptRequest, run.id, proto.PromptRequestPayload{AgentKind: run.engine})); err != nil { t.Fatal(err) } diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 19b42f6da..dff3148ac 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -28,7 +28,7 @@ Runtime: Executor preparation, reuse, idle expiry, recovery | Adapter | Native configuration, resources, API calls, event translation and restrictions | `apps/daemon/internal/agent/` | | Harness | Native model and tool loop and history | Pinned SDK or executable | | Service profile | Pure validation of qualified operations and placements | `services/core/internal/engine` | -| Registration | Installed factories and verified capability declarations | `apps/daemon/internal/cli` | +| Registration | Adapter declarations, installed factories and verified capabilities | `apps/daemon/internal/agent//declaration.go`; static list in `apps/daemon/internal/cli/agent_discovery.go` | An Environment supplies execution resources. Managed E2B, Docker and microsandbox machines and application-owned machines differ in provisioning and connection; the connected Runtime uses this same contract. The daemon runs on Linux, macOS and Windows, managed Providers are Linux-only, and each adapter qualifies its own platforms ([self-hosted platforms](../../docs/getting-started/self-hosted.md#platforms)). Native factories receive capabilities only after the Runtime has loaded the bound installed snapshot ([capability preparation](environments.md#runtime-capability-preparation)). Model providers supply model communication settings, not Turn scheduling or native process ownership. @@ -36,7 +36,7 @@ An Environment supplies execution resources. Managed E2B, Docker and microsandbo 1. **Pin the native source.** Record the upstream package version and source revision and document the native entry point next to the adapter. 2. **Implement the adapter** in `apps/daemon/internal/agent/`: an `ExecutorFactory`, an `Executor` and a `Turn` ([required interfaces](#required-adapter-interfaces), [lifetimes](#executor-and-turn-lifetimes)). Reuse the shared process, credential, configuration and local workspace helpers. -3. **Register the kind in the Runtime** in `apps/daemon/internal/cli` ([register the adapter](#register-the-adapter)). +3. **Declare the kind in the adapter** and add its declaration to the Runtime’s static list in `apps/daemon/internal/cli/agent_discovery.go` ([register the adapter](#register-the-adapter)). 4. **Add the service profile and one catalog entry** ([add the engine to Core](#add-the-engine-to-core)). 5. **Package native prerequisites.** Add a Runtime image under `services/core/deploy/` and, optionally, [native installer participation](#native-installer-participation). 6. **Enable and select the engine** with the `core.harnesses` setting and [Harness selection](model-execution.md#harness-selection). @@ -144,7 +144,11 @@ A Harness that supports the Subagent reads implements the [neutral observation c ## Register the adapter -Registration is static and requires a build. The methods live in `agent/harness.go`, and the built-in adapters call them from [`cli/agent_registration.go`](../../apps/daemon/internal/cli/agent_registration.go) (Codex, MiniMax Code) and [`cli/claude_sdk.go`](../../apps/daemon/internal/cli/claude_sdk.go) (Claude Code). +Registration is static and requires a build. Export one `agent.Declaration` from `apps/daemon/internal/agent//declaration.go`, then add it to `harnessDeclarations` in [`cli/agent_discovery.go`](../../apps/daemon/internal/cli/agent_discovery.go). The declaration contains the kind and complete capability descriptor, the shared model `Configuration` and a `Discover` function. Discovery receives the profile and diagnostic writers, owns native configuration and availability checks, and returns the installed `agent.Runtime` with its descriptor and session, preparation and Executor factories. Return nil when the adapter is not configured; return an unavailable descriptor with a session factory when configured prerequisites fail. Keep version gates and factory-selection conditions inside the adapter. + +`Runtime.SessionCapabilityContext` and `Runtime.ExecutorCapabilityContext` explicitly request capability-download URL resolution and scoped product-upload context for the corresponding execution factory. Preparation never receives those effects. An adapter that supports product workspace authoring declares `WorkspaceAuthoring` itself; common registration does not grant it. + +[`cli/agent_registration.go`](../../apps/daemon/internal/cli/agent_registration.go) iterates the discovered runtimes and calls `Registry.Register` from `agent/harness.go`. It verifies that discovery retained the declared kind and installs factories in this order: | Order | Method | Registers | | --- | --- | --- | @@ -170,7 +174,7 @@ Core recognizes the [built-in Harness registrations](harness-catalog.md). Add on Implement the profile constructor, then run `make generate-harness-catalog`. It generates the model configuration registry, Core profile catalog, client identifiers and display names and the registration reference; public input validators read the generated registry. `make openapi` derives Harness enums from the same catalog, so do not add handwritten enums to DTO tags or route annotations. `make check-harness-catalog` rejects stale projections. -The Runtime registers public Harnesses with `builtin.Configuration(kind)` and separately registers native factories, probes and installed capability evidence. The catalog cannot declare a machine's availability, and there is no dynamic plugin loader. +Each Runtime declaration references the same `internal/harnessconfig/.Configuration()` used by the generated catalog and owns its native factories, probes and installed capability evidence. The catalog cannot declare a machine's availability, and there is no dynamic plugin loader. The profile is pure: it declares supported placements, public configuration and result limits and required Runtime controls, using existing public and protocol types. Profile callbacks cannot query business data, decrypt credentials or control native processes. Shared dispatch checks capability combinations, not a whitelist of engine names. diff --git a/internal/agentdaemon/gateway/session_test.go b/internal/agentdaemon/gateway/session_test.go index 8fa921a0e..b7834bfd6 100644 --- a/internal/agentdaemon/gateway/session_test.go +++ b/internal/agentdaemon/gateway/session_test.go @@ -321,7 +321,7 @@ func TestSession_CloseReportsUnknownWithoutExecutionEvents(t *testing.T) { func TestSession_NoCapabilitiesBeforeHeartbeat(t *testing.T) { sess := NewSession(newFakeConn(), "device", "tenant", proto.Version, NewRegistry(), nil) defer sess.Close("test done") - for _, kind := range []string{"claude_code", "codex", "contract"} { + for _, kind := range []string{"fake_alpha", "codex", "contract"} { if info, found, known := sess.AgentKindStatus(kind); found || known || info.Available { t.Fatalf("unadvertised engine was available: %+v", info) } @@ -347,7 +347,7 @@ func TestSession_SendWritesToWire(t *testing.T) { defer sess.Close("test done") env, _ := proto.NewEnvelope(proto.TypePromptRequest, "run-1", proto.PromptRequestPayload{ - AgentKind: "claude_code", + AgentKind: "fake_alpha", RunID: "run-1", Input: proto.TextInput("hello"), }) @@ -381,7 +381,7 @@ func TestSession_HeartbeatPersistsSupportedAgentKinds(t *testing.T) { DaemonVersion: "0.2.0-test", SupportedAgentKinds: []proto.SupportedAgentKind{ { - Kind: "opencode", + Kind: "fake_beta", Available: false, Version: "missing", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ @@ -389,7 +389,7 @@ func TestSession_HeartbeatPersistsSupportedAgentKinds(t *testing.T) { }), }, { - Kind: "claude_code", + Kind: "fake_alpha", Available: true, Version: "1.2.3", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ @@ -420,18 +420,18 @@ func TestSession_HeartbeatPersistsSupportedAgentKinds(t *testing.T) { for _, info := range got.SupportedAgentKinds { byKind[info.Kind] = info } - claude := byKind["claude_code"] + claude := byKind["fake_alpha"] if !claude.Available || claude.Version != "1.2.3" || !claude.Capabilities.Permissions || !claude.Capabilities.Usage || !claude.Capabilities.Resume { - t.Fatalf("claude_code descriptor not converted: %#v", claude) + t.Fatalf("fake_alpha descriptor not converted: %#v", claude) } - opencode := byKind["opencode"] - if opencode.Available || opencode.Version != "missing" || !opencode.Capabilities.Streaming { - t.Fatalf("opencode descriptor not converted: %#v", opencode) + fake_beta := byKind["fake_beta"] + if fake_beta.Available || fake_beta.Version != "missing" || !fake_beta.Capabilities.Streaming { + t.Fatalf("fake_beta descriptor not converted: %#v", fake_beta) } - if !byKind["codex"].Capabilities.ExecutionControls || claude.Capabilities.ExecutionControls || opencode.Capabilities.ExecutionControls || !byKind["codex"].Capabilities.ToolObservations || claude.Capabilities.ToolObservations || opencode.Capabilities.ToolObservations || !byKind["codex"].Capabilities.SubagentControl || claude.Capabilities.SubagentControl || opencode.Capabilities.SubagentControl || !byKind["codex"].Capabilities.TextVerbosity || claude.Capabilities.TextVerbosity || opencode.Capabilities.TextVerbosity || !byKind["codex"].Capabilities.WebSearchControl || claude.Capabilities.WebSearchControl || opencode.Capabilities.WebSearchControl || !byKind["codex"].Capabilities.EnvironmentNone || claude.Capabilities.EnvironmentNone || opencode.Capabilities.EnvironmentNone || !byKind["codex"].Capabilities.MessageItems || !byKind["codex"].Capabilities.Steering || claude.Capabilities.Steering || opencode.Capabilities.Steering { + if !byKind["codex"].Capabilities.ExecutionControls || claude.Capabilities.ExecutionControls || fake_beta.Capabilities.ExecutionControls || !byKind["codex"].Capabilities.ToolObservations || claude.Capabilities.ToolObservations || fake_beta.Capabilities.ToolObservations || !byKind["codex"].Capabilities.SubagentControl || claude.Capabilities.SubagentControl || fake_beta.Capabilities.SubagentControl || !byKind["codex"].Capabilities.TextVerbosity || claude.Capabilities.TextVerbosity || fake_beta.Capabilities.TextVerbosity || !byKind["codex"].Capabilities.WebSearchControl || claude.Capabilities.WebSearchControl || fake_beta.Capabilities.WebSearchControl || !byKind["codex"].Capabilities.EnvironmentNone || claude.Capabilities.EnvironmentNone || fake_beta.Capabilities.EnvironmentNone || !byKind["codex"].Capabilities.MessageItems || !byKind["codex"].Capabilities.Steering || claude.Capabilities.Steering || fake_beta.Capabilities.Steering { t.Fatalf("steering capability not preserved: %#v", byKind) } - if !byKind["codex"].Capabilities.MCPHTTPTools || claude.Capabilities.MCPHTTPTools || opencode.Capabilities.MCPHTTPTools { + if !byKind["codex"].Capabilities.MCPHTTPTools || claude.Capabilities.MCPHTTPTools || fake_beta.Capabilities.MCPHTTPTools { t.Fatalf("HTTP MCP capability not preserved: %#v", byKind) } codex, found, known := sess.AgentKindStatus("codex") diff --git a/scripts/name-allowlist.json b/scripts/name-allowlist.json index 0e273f183..73e0c9a1a 100644 --- a/scripts/name-allowlist.json +++ b/scripts/name-allowlist.json @@ -159,21 +159,6 @@ "regex": "AGENTS_API_PUBLIC_URL", "reason": "Historical migration commentary records the environment name used when this migration was written." }, - { - "path": "apps/daemon/internal/agent/pi/provider_config.go", - "regex": "parsar(?:/[A-Za-z0-9<>_-]+)?|Parsar Anthropic", - "reason": "The dormant Pi adapter consumes the separate product model-provider slug and catalog display name; Core does not rename that external protocol." - }, - { - "path": "apps/daemon/internal/agent/pi/provider_config_test.go", - "regex": "parsar(?:/[A-Za-z0-9<>_-]+)?|Parsar Anthropic", - "reason": "The dormant Pi adapter consumes the separate product model-provider slug and catalog display name; Core does not rename that external protocol." - }, - { - "path": "apps/daemon/internal/agent/pi/session_provider_test.go", - "regex": "parsar(?:/[A-Za-z0-9<>_-]+)?|Parsar Anthropic", - "reason": "The dormant Pi adapter consumes the separate product model-provider slug and catalog display name; Core does not rename that external protocol." - }, { "path": "apps/daemon/internal/cli/skill_upload.go", "regex": "parsar(?:-server|\\.example\\.test)?|PARSAR_RUNNER_TOKEN", diff --git a/services/core/internal/api/environment_creation_test.go b/services/core/internal/api/environment_creation_test.go index 854724063..422d45abf 100644 --- a/services/core/internal/api/environment_creation_test.go +++ b/services/core/internal/api/environment_creation_test.go @@ -168,7 +168,7 @@ func TestSelfHostedCreationRejectsBeforePersistence(t *testing.T) { {name: "initial assistant message", environment: validEnvironment, input: `,"input":[{"role":"assistant","content":[{"type":"input_text","text":"start"}]}]`}, {name: "deferred functions", environment: validEnvironment, agentFields: `,"tools":[{"type":"function","name":"lookup","description":"Find a value","parameters":{"type":"object"},"defer_loading":true}]`}, {name: "unregistered harness placement", environment: validEnvironment, engine: "unregistered"}, - {name: "Claude Code placement", environment: validEnvironment, engine: "claude_code"}, + {name: "Unsupported Harness placement", environment: validEnvironment, engine: "fake_alpha"}, } { for _, stream := range []bool{false, true} { t.Run(fmt.Sprintf("%s/stream=%t", tc.name, stream), func(t *testing.T) { diff --git a/services/core/internal/api/environment_files_test.go b/services/core/internal/api/environment_files_test.go index 8c1a75afa..8eda51ac9 100644 --- a/services/core/internal/api/environment_files_test.go +++ b/services/core/internal/api/environment_files_test.go @@ -79,7 +79,7 @@ func environmentFilesHandler(t *testing.T, enabled bool) (http.Handler, *environ if enabled { options = append(options, WithEnvironmentDirectoryReader(f)) } - h, err := NewHandler(f, auth, "claude_code", options...) + h, err := NewHandler(f, auth, "fake_alpha", options...) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/api/environments_test.go b/services/core/internal/api/environments_test.go index eb6d1de36..13744ef1f 100644 --- a/services/core/internal/api/environments_test.go +++ b/services/core/internal/api/environments_test.go @@ -42,7 +42,7 @@ func environmentResourceHandler(t *testing.T) (http.Handler, *environmentResourc if err != nil { t.Fatal(err) } - h, err := NewHandler(f, auth, "claude_code") + h, err := NewHandler(f, auth, "fake_alpha") if err != nil { t.Fatal(err) } diff --git a/services/core/internal/api/vaults_test.go b/services/core/internal/api/vaults_test.go index f88b6cc61..94ebdd5d5 100644 --- a/services/core/internal/api/vaults_test.go +++ b/services/core/internal/api/vaults_test.go @@ -51,7 +51,7 @@ func vaultResourceHandler(t *testing.T) (http.Handler, *vaultResourceFixture) { if err != nil { t.Fatal(err) } - h, err := NewHandler(f, auth, "claude_code") + h, err := NewHandler(f, auth, "fake_alpha") if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/session_initial_public_test.go b/services/core/internal/store/session_initial_public_test.go index d04ab5c48..28ac362ea 100644 --- a/services/core/internal/store/session_initial_public_test.go +++ b/services/core/internal/store/session_initial_public_test.go @@ -43,7 +43,7 @@ func TestInitialSessionInputOfficialClient(t *testing.T) { } server := httptest.NewServer(handler) defer server.Close() - unsupported, err := api.NewHandler(s, auth, "claude_code", api.WithExecution(worker)) + unsupported, err := api.NewHandler(s, auth, "fake_alpha", api.WithExecution(worker)) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/sessions_test.go b/services/core/internal/store/sessions_test.go index 022d9b428..3bca4bf0e 100644 --- a/services/core/internal/store/sessions_test.go +++ b/services/core/internal/store/sessions_test.go @@ -142,7 +142,7 @@ func TestConcurrentSessionCreationIsIdempotent(t *testing.T) { s, _ := testStore(t) ctx := context.Background() tenant := uuid.NewString() - input := CreateSessionInput{Creator: FixtureCreator(), Engine: "claude_code", Metadata: map[string]string{"b": "2", "a": "1"}, IdempotencyKey: "repeated"} + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "fake_alpha", Metadata: map[string]string{"b": "2", "a": "1"}, IdempotencyKey: "repeated"} const count = 8 ids := make(chan string, count) errs := make(chan error, count) @@ -171,7 +171,7 @@ func TestConcurrentSessionCreationIsIdempotent(t *testing.T) { if len(unique) != 1 { t.Fatalf("duplicate sessions: %+v", unique) } - replay, err := s.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "claude_code", Metadata: map[string]string{"a": "1", "b": "2"}, IdempotencyKey: "repeated"}) + replay, err := s.CreateSession(ctx, tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "fake_alpha", Metadata: map[string]string{"a": "1", "b": "2"}, IdempotencyKey: "repeated"}) if err != nil || !unique[replay.ID] { t.Fatalf("reordered metadata was not replayed: %+v %v", replay, err) } From 4130f843d5ffbb0061a1b586177be5a45abb70e6 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 30 Sep 2026 09:23:45 +0000 Subject: [PATCH 2/2] fix(daemon): preserve Claude native usage provider identity --- apps/daemon/internal/agent/claudesdk/usage.go | 2 +- apps/daemon/internal/agent/claudesdk/usage_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/usage.go b/apps/daemon/internal/agent/claudesdk/usage.go index f76b4dcf6..960cc2af2 100644 --- a/apps/daemon/internal/agent/claudesdk/usage.go +++ b/apps/daemon/internal/agent/claudesdk/usage.go @@ -17,7 +17,7 @@ func nativeUsage(raw json.RawMessage) (proto.Usage, error) { } // The SDK owns native counter scopes and cost estimates. Do not expose an // incomplete public token breakdown or a model selected from an unordered map. - return proto.Usage{Provider: "claude_sdk", Raw: map[string]any{"claude_sdk_result": snapshot}}, nil + return proto.Usage{Provider: "claude_code", Raw: map[string]any{"claude_sdk_result": snapshot}}, nil } // Keep every native measurement when a query spans multiple native turns. Each diff --git a/apps/daemon/internal/agent/claudesdk/usage_test.go b/apps/daemon/internal/agent/claudesdk/usage_test.go index fa9d3a650..6780ef95d 100644 --- a/apps/daemon/internal/agent/claudesdk/usage_test.go +++ b/apps/daemon/internal/agent/claudesdk/usage_test.go @@ -73,7 +73,7 @@ func TestUsageTransportPreservesSnapshotOnFailureAndDone(t *testing.T) { } var want map[string]any _ = json.Unmarshal([]byte(original), &want) - if !reflect.DeepEqual(observed.Raw["claude_sdk_result"], want) || observed.Model != "" || observed.Tokens != nil || observed.CostUSD != 0 || observed.InputTokens != 0 || observed.OutputTokens != 0 { + if observed.Provider != "claude_code" || !reflect.DeepEqual(observed.Raw["claude_sdk_result"], want) || observed.Model != "" || observed.Tokens != nil || observed.CostUSD != 0 || observed.InputTokens != 0 || observed.OutputTokens != 0 { t.Fatalf("usage normalized or lost: %+v", observed) } }