From faefd061ce196433fc86b12c7b7c016b3a82eec1 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 28 Aug 2026 12:20:09 +0200 Subject: [PATCH 01/46] feat(acp): integrate ACP harness for agent communication - Added new `acp` package for handling ACP session lifecycle. - Implemented provider-neutral Agent Client Protocol (ACP) harness. - Created `acp.go`, `acp_session.go`, `acp_turn.go`, and `acp_client.go` for structuring ACP tool logic and session management. - Extracted opencode session export functionality into `ExportSession` method for reusability. - Updated imports to add support for ACP logic and made necessary code refactoring for cleaner management. - Removed redundant session export logic from `artifacts.go`. - Updated `go.sum` with new dependencies for ACP SDK integration. --- .../agent-harness/opencode.Dockerfile | 2 +- go/deployment-operator/go.mod | 1 + go/deployment-operator/go.sum | 2 + .../internal/controller/agentrun_pod.go | 2 +- .../pkg/agentrun-harness/tool/acp/acp.go | 313 +++++++++++++++ .../agentrun-harness/tool/acp/acp_client.go | 125 ++++++ .../tool/acp/acp_client_test.go | 91 +++++ .../agentrun-harness/tool/acp/acp_mapping.go | 119 ++++++ .../agentrun-harness/tool/acp/acp_session.go | 350 +++++++++++++++++ .../pkg/agentrun-harness/tool/acp/acp_test.go | 359 ++++++++++++++++++ .../pkg/agentrun-harness/tool/acp/acp_turn.go | 340 +++++++++++++++++ .../pkg/agentrun-harness/tool/acp/opencode.go | 64 ++++ .../tool/opencode/artifacts.go | 26 +- .../tool/opencode/opencode.go | 64 +--- .../tool/opencode/opencode_acp_types.go | 23 ++ .../tool/opencode/opencode_config.go | 124 ++++++ .../opencode/templates/opencode.json.gotmpl | 1 + .../pkg/agentrun-harness/tool/tool.go | 4 +- .../pkg/harness/exec/exec.go | 2 +- .../pkg/harness/exec/exec_stdio.go | 155 ++++++++ .../pkg/harness/exec/exec_stdio_test.go | 73 ++++ .../pkg/harness/exec/exec_stdio_types.go | 85 +++++ 22 files changed, 2239 insertions(+), 86 deletions(-) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_acp_types.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_config.go create mode 100644 go/deployment-operator/pkg/harness/exec/exec_stdio.go create mode 100644 go/deployment-operator/pkg/harness/exec/exec_stdio_test.go create mode 100644 go/deployment-operator/pkg/harness/exec/exec_stdio_types.go diff --git a/go/deployment-operator/dockerfiles/agent-harness/opencode.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/opencode.Dockerfile index 1101effa92..150687c86e 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/opencode.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/opencode.Dockerfile @@ -1,6 +1,6 @@ ARG NODE_IMAGE_TAG=24 ARG NODE_IMAGE=node:${NODE_IMAGE_TAG}-slim -ARG AGENT_VERSION=1.17.3 +ARG AGENT_VERSION=1.18.23 ARG AGENT_HARNESS_BASE_IMAGE_TAG=latest ARG AGENT_HARNESS_BASE_IMAGE_REPO=ghcr.io/pluralsh/agent-harness-base diff --git a/go/deployment-operator/go.mod b/go/deployment-operator/go.mod index f32d03d4af..048373b1de 100644 --- a/go/deployment-operator/go.mod +++ b/go/deployment-operator/go.mod @@ -29,6 +29,7 @@ require ( github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24 github.com/aws/aws-sdk-go-v2/service/eks v1.81.2 github.com/cert-manager/cert-manager v1.19.3 + github.com/coder/acp-go-sdk v0.13.5 github.com/cyphar/filepath-securejoin v0.6.1 github.com/evanphx/json-patch/v5 v5.9.11 github.com/fluxcd/flagger v1.41.0 diff --git a/go/deployment-operator/go.sum b/go/deployment-operator/go.sum index 3323d808f3..96096299a3 100644 --- a/go/deployment-operator/go.sum +++ b/go/deployment-operator/go.sum @@ -250,6 +250,8 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk= +github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/containerd v1.7.33 h1:iAkYGC/ifR/V+0eR4iXWHNGYUF0DF2PmGV5iz4Irj5M= diff --git a/go/deployment-operator/internal/controller/agentrun_pod.go b/go/deployment-operator/internal/controller/agentrun_pod.go index f5d2707f2a..e784829b81 100644 --- a/go/deployment-operator/internal/controller/agentrun_pod.go +++ b/go/deployment-operator/internal/controller/agentrun_pod.go @@ -121,7 +121,7 @@ var ( defaultContainerVersions = map[console.AgentRuntimeType]string{ console.AgentRuntimeTypeClaude: "%s-claude-2.1.72", console.AgentRuntimeTypeGemini: "%s-gemini-0.44.1", - console.AgentRuntimeTypeOpencode: "%s-opencode-1.17.3", + console.AgentRuntimeTypeOpencode: "%s-opencode-1.18.23", console.AgentRuntimeTypeCodex: "%s-codex-0.104.0", console.AgentRuntimeTypePi: "%s-pi-0.84.1", } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go new file mode 100644 index 0000000000..dc853c0958 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go @@ -0,0 +1,313 @@ +// Package acp contains the provider-neutral Agent Client Protocol harness. +// Provider adapters supply process launch, configuration, and native artifact +// export callbacks while this package owns the ACP session lifecycle and +// Console message mapping. +package acp + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "k8s.io/klog/v2" + + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" + "github.com/pluralsh/console/go/deployment-operator/pkg/log" +) + +const ( + defaultFlushInterval = 5 * time.Second + defaultFlushBytes = 64 * 1024 + defaultStopTimeout = 2 * time.Second +) + +// LaunchFunc starts one ACP agent process. A new process is started for every +// prompt, including resumed prompts; the ACP session itself remains the +// durable conversation state. +type LaunchFunc func(context.Context, []exec.Option) (*exec.StdioProcess, error) + +// ConfigureFunc writes provider configuration and any provider system prompt +// files needed before a run. +type ConfigureFunc func(consoleURL, consoleToken string) error + +// ExportFunc exports a provider-native session to outputPath. +type ExportFunc func(context.Context, string, string) error + +// Option configures the provider-neutral ACP tool. +type Option func(*Tool) + +// WithLauncher supplies the provider-specific ACP process launcher. +func WithLauncher(launch LaunchFunc) Option { + return func(tool *Tool) { tool.launch = launch } +} + +// WithConfigure supplies the provider-specific configuration callback. +func WithConfigure(configure ConfigureFunc) Option { + return func(tool *Tool) { tool.configure = configure } +} + +// WithBabysitConfigure supplies the provider-specific babysit configuration +// callback. +func WithBabysitConfigure(configure func() error) Option { + return func(tool *Tool) { tool.configureBabysit = configure } +} + +// WithExporter supplies provider-native session export behavior. +func WithExporter(export ExportFunc) Option { + return func(tool *Tool) { tool.export = export } +} + +// WithProviderName sets the name used when building the upload archive. +func WithProviderName(name string) Option { + return func(tool *Tool) { tool.providerName = name } +} + +// WithMode sets the provider ACP session mode. Empty mode leaves the agent's +// negotiated default untouched. +func WithMode(mode string) Option { + return func(tool *Tool) { tool.mode = mode } +} + +// WithModel sets the provider/model selection used by ACP session config +// options. The value must use the provider/model form advertised by the +// provider (for example, "openai/gpt-5.4"). +func WithModel(model string) Option { + return func(tool *Tool) { tool.model = model } +} + +// WithToolOutputFlushInterval changes the interval at which dirty tool output +// is emitted. It is primarily useful for deterministic tests. +func WithToolOutputFlushInterval(interval time.Duration) Option { + return func(tool *Tool) { + if interval > 0 { + tool.flushInterval = interval + } + } +} + +// WithToolOutputFlushBytes changes the amount of newly received UTF-8 bytes +// that triggers an immediate tool output flush. +func WithToolOutputFlushBytes(size int) Option { + return func(tool *Tool) { + if size > 0 { + tool.flushBytes = size + } + } +} + +// WithStopTimeout sets the bounded wait after session/cancel before the agent +// process is killed. +func WithStopTimeout(timeout time.Duration) Option { + return func(tool *Tool) { + if timeout > 0 { + tool.stopTimeout = timeout + } + } +} + +// WithNow injects the clock used for progressive tool output flushing. +func WithNow(now func() time.Time) Option { + return func(tool *Tool) { + if now != nil { + tool.now = now + } + } +} + +// Tool implements v1.Tool for an ACP-speaking provider. +type Tool struct { + toolv1.DefaultTool + + launch LaunchFunc + configure ConfigureFunc + configureBabysit func() error + export ExportFunc + providerName string + mode string + + flushInterval time.Duration + flushBytes int + stopTimeout time.Duration + now func() time.Time + model string + + mu sync.RWMutex + onMessage toolv1.MessageCallback + sessionID string + costBase *float64 +} + +// New creates a provider-neutral ACP tool. Provider adapters normally pass +// WithLauncher, WithConfigure, WithBabysitConfigure, and WithExporter. +func New(config toolv1.Config, options ...Option) *Tool { + tool := &Tool{ + DefaultTool: toolv1.DefaultTool{Config: config}, + providerName: "acp", + flushInterval: defaultFlushInterval, + flushBytes: defaultFlushBytes, + stopTimeout: defaultStopTimeout, + now: time.Now, + } + for _, option := range options { + option(tool) + } + return tool +} + +// Run starts the initial ACP prompt in the background. +func (tool *Tool) Run(ctx context.Context, options ...exec.Option) { + initialOptions := append([]exec.Option(nil), options...) + go func() { + if tool.Config.SkipInitialRun { + return + } + if tool.Config.Run == nil { + tool.reportError(errors.New("agent run is not set")) + return + } + tool.emit(&console.AgentMessageAttributes{Message: tool.Config.Run.Prompt, Role: console.AiRoleUser}, "") + if err := tool.runPromptWithOptions(ctx, tool.Config.Run.Prompt, initialOptions); err != nil { + tool.reportError(err) + } + }() +} + +// BabysitRun resumes the current ACP session when the babysit loop provides a +// changed prompt. A nil context means no prompt is needed. +func (tool *Tool) BabysitRun(ctx context.Context, babysit *toolv1.BabysitContext) bool { + if babysit == nil { + return false + } + tool.emit(&console.AgentMessageAttributes{Message: babysit.Prompt, Role: console.AiRoleUser}, "") + if err := tool.runPrompt(ctx, babysit.Prompt); err != nil { + tool.reportError(err) + } + return false +} + +// Configure configures the provider adapter. +func (tool *Tool) Configure(consoleURL, consoleToken string) error { + if tool.configure == nil { + return errors.New("ACP configure function is not set") + } + return tool.configure(consoleURL, consoleToken) +} + +// ConfigureBabysitRun configures provider files used by resumed prompts. +func (tool *Tool) ConfigureBabysitRun() error { + if tool.configureBabysit == nil { + return nil + } + return tool.configureBabysit() +} + +// OnMessage registers the Console message callback. +func (tool *Tool) OnMessage(callback toolv1.MessageCallback) { + tool.mu.Lock() + tool.onMessage = callback + tool.mu.Unlock() +} + +// FollowUpRun resumes the current ACP session. It deliberately does not emit +// the user prompt because the controller persists follow-up prompts itself. +func (tool *Tool) FollowUpRun(ctx context.Context, prompt string) error { + return tool.runPrompt(ctx, prompt) +} + +// UploadArtifacts exports and archives the native ACP provider session. +func (tool *Tool) UploadArtifacts(ctx context.Context) (*artifacts.UploadArtifacts, error) { + // Kept in a small adapter method so provider-specific export remains outside + // the protocol implementation. + if tool.export == nil { + return nil, errors.New("ACP session exporter is not set") + } + tool.mu.RLock() + sessionID := tool.sessionID + providerName := tool.providerName + tool.mu.RUnlock() + if sessionID == "" { + return nil, errors.New("ACP session id is not set") + } + + sourcePath, err := os.MkdirTemp(tool.Config.WorkDir, "acp-session-export-*") + if err != nil { + return nil, fmt.Errorf("create ACP session export dir: %w", err) + } + defer os.RemoveAll(sourcePath) + + sessionPath := filepath.Join(sourcePath, artifacts.SessionJSONName) + if err := tool.export(ctx, sessionPath, sessionID); err != nil { + return nil, err + } + return tool.BuildUploadArtifacts(ctx, artifacts.BuildArtifactsOptions{ + Provider: providerName, + Source: artifacts.SessionSource{ + Path: sourcePath, + ArchivePath: providerName, + }, + SessionID: sessionID, + }) +} + +func (tool *Tool) reportError(err error) { + if err == nil || tool.Config.ErrorChan == nil { + return + } + klog.V(log.LogLevelDefault).ErrorS(err, "ACP execution failed") + tool.Config.ErrorChan <- err +} + +func (tool *Tool) emit(message *console.AgentMessageAttributes, callID string) { + if message == nil { + return + } + tool.mu.RLock() + callback := tool.onMessage + tool.mu.RUnlock() + if callback == nil { + return + } + defer func() { + if recovered := recover(); recovered != nil { + klog.ErrorS(fmt.Errorf("panic in ACP message callback: %v", recovered), "ACP message callback panicked") + } + }() + callback(message, callID) +} + +func (tool *Tool) setSessionID(sessionID string) { + tool.mu.Lock() + tool.sessionID = sessionID + tool.mu.Unlock() +} + +func (tool *Tool) recordCost(amount float64) float64 { + if amount < 0 { + amount = 0 + } + tool.mu.Lock() + defer tool.mu.Unlock() + if tool.costBase == nil { + tool.costBase = &amount + return amount + } + if amount < *tool.costBase { + *tool.costBase = amount + return 0 + } + delta := amount - *tool.costBase + *tool.costBase = amount + if delta < 0 { + return 0 + } + return delta +} + +var _ toolv1.Tool = (*Tool)(nil) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go new file mode 100644 index 0000000000..112cc95b13 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go @@ -0,0 +1,125 @@ +package acp + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + acpsdk "github.com/coder/acp-go-sdk" +) + +type client struct { + turn *turnState +} + +func (client *client) ReadTextFile(_ context.Context, request acpsdk.ReadTextFileRequest) (acpsdk.ReadTextFileResponse, error) { + if err := client.validateSession(request.SessionId); err != nil { + return acpsdk.ReadTextFileResponse{}, err + } + if !filepath.IsAbs(request.Path) { + return acpsdk.ReadTextFileResponse{}, fmt.Errorf("ACP filesystem path must be absolute: %q", request.Path) + } + file, err := os.Open(request.Path) + if err != nil { + return acpsdk.ReadTextFileResponse{}, fmt.Errorf("read %s: %w", request.Path, err) + } + defer file.Close() + + reader := bufio.NewReader(file) + if request.Line != nil { + for line := 1; line < max(*request.Line, 1); line++ { + if _, readErr := reader.ReadString('\n'); readErr != nil { + if errors.Is(readErr, io.EOF) { + return acpsdk.ReadTextFileResponse{}, nil + } + return acpsdk.ReadTextFileResponse{}, fmt.Errorf("read %s: %w", request.Path, readErr) + } + } + } + if request.Limit == nil || *request.Limit <= 0 { + content, readErr := io.ReadAll(reader) + if readErr != nil { + return acpsdk.ReadTextFileResponse{}, fmt.Errorf("read %s: %w", request.Path, readErr) + } + return acpsdk.ReadTextFileResponse{Content: string(content)}, nil + } + + lines := make([]string, 0, min(*request.Limit, 1024)) + for len(lines) < *request.Limit { + line, readErr := reader.ReadString('\n') + lines = append(lines, strings.TrimSuffix(line, "\n")) + if readErr != nil { + if errors.Is(readErr, io.EOF) { + break + } + return acpsdk.ReadTextFileResponse{}, fmt.Errorf("read %s: %w", request.Path, readErr) + } + } + return acpsdk.ReadTextFileResponse{Content: strings.Join(lines, "\n")}, nil +} + +func (client *client) WriteTextFile(_ context.Context, request acpsdk.WriteTextFileRequest) (acpsdk.WriteTextFileResponse, error) { + if err := client.validateSession(request.SessionId); err != nil { + return acpsdk.WriteTextFileResponse{}, err + } + if !filepath.IsAbs(request.Path) { + return acpsdk.WriteTextFileResponse{}, fmt.Errorf("ACP filesystem path must be absolute: %q", request.Path) + } + if err := os.MkdirAll(filepath.Dir(request.Path), 0o755); err != nil { + return acpsdk.WriteTextFileResponse{}, fmt.Errorf("mkdir %s: %w", filepath.Dir(request.Path), err) + } + if err := os.WriteFile(request.Path, []byte(request.Content), 0o644); err != nil { + return acpsdk.WriteTextFileResponse{}, fmt.Errorf("write %s: %w", request.Path, err) + } + return acpsdk.WriteTextFileResponse{}, nil +} + +func (client *client) RequestPermission(context.Context, acpsdk.RequestPermissionRequest) (acpsdk.RequestPermissionResponse, error) { + return acpsdk.RequestPermissionResponse{}, errors.New("ACP permission requests are unavailable in unattended runs") +} + +func (*client) CreateTerminal(context.Context, acpsdk.CreateTerminalRequest) (acpsdk.CreateTerminalResponse, error) { + return acpsdk.CreateTerminalResponse{TerminalId: "terminal-1"}, nil +} + +func (*client) KillTerminal(context.Context, acpsdk.KillTerminalRequest) (acpsdk.KillTerminalResponse, error) { + return acpsdk.KillTerminalResponse{}, nil +} + +func (*client) TerminalOutput(context.Context, acpsdk.TerminalOutputRequest) (acpsdk.TerminalOutputResponse, error) { + return acpsdk.TerminalOutputResponse{Output: "", Truncated: false}, nil +} + +func (*client) ReleaseTerminal(context.Context, acpsdk.ReleaseTerminalRequest) (acpsdk.ReleaseTerminalResponse, error) { + return acpsdk.ReleaseTerminalResponse{}, nil +} + +func (*client) WaitForTerminalExit(context.Context, acpsdk.WaitForTerminalExitRequest) (acpsdk.WaitForTerminalExitResponse, error) { + return acpsdk.WaitForTerminalExitResponse{}, nil +} + +func (client *client) SessionUpdate(_ context.Context, notification acpsdk.SessionNotification) error { + return client.turn.handle(notification) +} + +func (client *client) UnstableCreateElicitation(context.Context, acpsdk.UnstableCreateElicitationRequest) (acpsdk.UnstableCreateElicitationResponse, error) { + return acpsdk.UnstableCreateElicitationResponse{}, errors.New("ACP elicitation requests are unavailable in unattended runs") +} + +func (client *client) validateSession(sessionID acpsdk.SessionId) error { + if client.turn == nil { + return errors.New("ACP client is not attached to a turn") + } + expected := client.turn.sessionID() + if sessionID != acpsdk.SessionId(expected) { + return fmt.Errorf("ACP request belongs to session %q, expected %q", sessionID, expected) + } + return nil +} + +var _ acpsdk.Client = (*client)(nil) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client_test.go new file mode 100644 index 0000000000..e73229845e --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client_test.go @@ -0,0 +1,91 @@ +package acp + +import ( + "context" + "os" + "path/filepath" + "testing" + + acpsdk "github.com/coder/acp-go-sdk" +) + +func testACPClient(t *testing.T) (*client, string) { + t.Helper() + tool := &Tool{} + turn := newTurn(tool, "session-1") + return &client{turn: turn}, t.TempDir() +} + +func TestReadAndWriteTextFile(t *testing.T) { + client, cwd := testACPClient(t) + path := filepath.Join(cwd, "nested", "file.txt") + request := acpsdk.WriteTextFileRequest{SessionId: "session-1", Path: path, Content: "one\ntwo\nthree\n"} + if _, err := client.WriteTextFile(context.Background(), request); err != nil { + t.Fatalf("write text file: %v", err) + } + + line, limit := 2, 2 + response, err := client.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{ + SessionId: "session-1", Path: path, Line: &line, Limit: &limit, + }) + if err != nil { + t.Fatalf("read text file: %v", err) + } + if response.Content != "two\nthree" { + t.Fatalf("read content = %q, want %q", response.Content, "two\nthree") + } + + emptyPath := filepath.Join(cwd, "nested", "empty.txt") + request.Path = emptyPath + request.Content = "" + if _, err := client.WriteTextFile(context.Background(), request); err != nil { + t.Fatalf("write empty text file: %v", err) + } + response, err = client.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{ + SessionId: "session-1", Path: emptyPath, + }) + if err != nil { + t.Fatalf("read empty text file: %v", err) + } + if response.Content != "" { + t.Fatalf("empty read content = %q, want empty", response.Content) + } + + if _, err := client.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{ + SessionId: "session-1", Path: "relative.txt", + }); err == nil { + t.Fatal("relative read path unexpectedly succeeded") + } + if _, err := client.WriteTextFile(context.Background(), acpsdk.WriteTextFileRequest{ + SessionId: "session-1", Path: "relative.txt", Content: "content", + }); err == nil { + t.Fatal("relative write path unexpectedly succeeded") + } + if _, err := client.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{ + SessionId: "wrong-session", Path: path, + }); err == nil { + t.Fatal("read for another session unexpectedly succeeded") + } + if _, err := client.WriteTextFile(context.Background(), acpsdk.WriteTextFileRequest{ + SessionId: "wrong-session", Path: path, Content: "content", + }); err == nil { + t.Fatal("write for another session unexpectedly succeeded") + } +} + +func TestWriteTextFileCreatesEmptyFile(t *testing.T) { + client, cwd := testACPClient(t) + path := filepath.Join(cwd, "empty", "file") + if _, err := client.WriteTextFile(context.Background(), acpsdk.WriteTextFileRequest{ + SessionId: "session-1", Path: path, + }); err != nil { + t.Fatalf("write empty file: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat empty file: %v", err) + } + if info.Size() != 0 { + t.Fatalf("empty file size = %d, want 0", info.Size()) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go new file mode 100644 index 0000000000..0b1b385d0e --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go @@ -0,0 +1,119 @@ +package acp + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + acpsdk "github.com/coder/acp-go-sdk" + + console "github.com/pluralsh/console/go/client" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func (call *toolCall) message() *console.AgentMessageAttributes { + name := call.name + output := call.output + if output == "" && (call.state == console.AgentMessageToolStateRunning || call.state == console.AgentMessageToolStatePending) { + output = toolv1.RunningToolOutput + } + message := &console.AgentMessageAttributes{ + Role: console.AiRoleAssistant, + Message: "Called tool", + Metadata: &console.AgentMessageMetadataAttributes{ + Tool: &console.AgentMessageToolAttributes{ + Name: new(name), State: &call.state, Output: new(output), + }, + }, + } + if call.input != "" { + message.Metadata.Tool.Input = new(call.input) + } + return message +} + +func toolState(status acpsdk.ToolCallStatus) (console.AgentMessageToolState, error) { + switch status { + case acpsdk.ToolCallStatusPending: + return console.AgentMessageToolStatePending, nil + case acpsdk.ToolCallStatusInProgress, "": + return console.AgentMessageToolStateRunning, nil + case acpsdk.ToolCallStatusCompleted: + return console.AgentMessageToolStateCompleted, nil + case acpsdk.ToolCallStatusFailed: + return console.AgentMessageToolStateError, nil + default: + return "", fmt.Errorf("ACP tool call has unknown status %q", status) + } +} + +func toolName(title string, kind acpsdk.ToolKind) string { + if title != "" { + return title + } + if kind != "" { + return string(kind) + } + return "tool" +} + +func contentText(content acpsdk.ContentBlock) (string, error) { + if content.Text != nil { + return content.Text.Text, nil + } + return "", errors.New("expected text content") +} + +func contentOutput(content []acpsdk.ToolCallContent) string { + var builder strings.Builder + for _, item := range content { + switch { + case item.Content != nil: + if item.Content.Content.Text != nil { + builder.WriteString(item.Content.Content.Text.Text) + } + case item.Diff != nil: + builder.WriteString(item.Diff.NewText) + case item.Terminal != nil: + builder.WriteString(item.Terminal.TerminalId) + } + } + return builder.String() +} + +func formatValue(value any) string { + if value == nil { + return "" + } + if stringValue, ok := value.(string); ok { + return stringValue + } + encoded, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(encoded) +} + +func normalizeUsage(providerUsage *acpsdk.Usage) (input, output, total, cached, thought int64) { + input = int64(max(providerUsage.InputTokens, 0)) + output = int64(max(providerUsage.OutputTokens, 0)) + total = int64(max(providerUsage.TotalTokens, 0)) + if total < input+output { + total = input + output + } + if providerUsage.CachedReadTokens != nil { + cached += int64(max(*providerUsage.CachedReadTokens, 0)) + } + if providerUsage.CachedWriteTokens != nil { + cached += int64(max(*providerUsage.CachedWriteTokens, 0)) + } + if providerUsage.ThoughtTokens != nil { + thought = int64(max(*providerUsage.ThoughtTokens, 0)) + if total < input+output+thought { + total = input + output + thought + } + } + return +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go new file mode 100644 index 0000000000..a2c730e323 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go @@ -0,0 +1,350 @@ +package acp + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "time" + + acpsdk "github.com/coder/acp-go-sdk" + "k8s.io/klog/v2" + + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" + "github.com/pluralsh/console/go/deployment-operator/pkg/log" +) + +func (tool *Tool) runPrompt(ctx context.Context, prompt string) error { + return tool.runPromptWithOptions(ctx, prompt, nil) +} + +func (tool *Tool) runPromptWithOptions(ctx context.Context, prompt string, options []exec.Option) error { + if ctx == nil { + ctx = context.Background() + } + if err := tool.validate(); err != nil { + return err + } + + return tool.runAttempt(ctx, prompt, options) +} + +func (tool *Tool) runAttempt(ctx context.Context, prompt string, options []exec.Option) error { + if ctx == nil { + ctx = context.Background() + } + cwd, err := filepath.Abs(tool.Config.RepositoryDir) + if err != nil { + return fmt.Errorf("resolve ACP repository directory: %w", err) + } + tool.mu.RLock() + launch := tool.launch + priorSessionID := tool.sessionID + tool.mu.RUnlock() + if launch == nil { + return errors.New("ACP launcher is not set") + } + + process, err := launch(ctx, options) + if err != nil { + return err + } + if process == nil || process.Stdin == nil || process.Stdout == nil { + if process != nil { + _ = process.Stop() + _ = process.Wait() + } + return errors.New("ACP launcher returned an incomplete stdio process") + } + defer func() { + // The process is stopped explicitly below. This is a final guard for + // setup failures and keeps test launchers from leaking children. + _ = process.Close() + }() + + if process.Stderr != nil { + go func() { + if _, copyErr := io.Copy(io.Discard, process.Stderr); copyErr != nil && !errors.Is(copyErr, io.ErrClosedPipe) { + klog.V(log.LogLevelDebug).InfoS("ACP stderr drain ended", "error", copyErr) + } + }() + } + + turn := newTurn(tool, priorSessionID) + defer turn.stopFlusher() + client := &client{turn: turn} + connection := acpsdk.NewClientSideConnection(client, process.Stdin, process.Stdout) + connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) + turn.startFlusher(ctx) + + waitForExit := func() error { + waitCh := make(chan error, 1) + go func() { waitCh <- process.Wait() }() + timer := time.NewTimer(tool.stopTimeout) + defer timer.Stop() + select { + case waitErr := <-waitCh: + return waitErr + case <-timer.C: + if killErr := process.Kill(); killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { + klog.V(log.LogLevelDebug).InfoS("ACP process kill failed", "error", killErr) + } + return <-waitCh + } + } + + stop := func(cancel bool) error { + if cancel { + cancelCtx, cancelFunc := context.WithTimeout(context.Background(), tool.stopTimeout) + sessionID := turn.sessionID() + var cancelErr error + if sessionID != "" { + cancelErr = connection.Cancel(cancelCtx, acpsdk.CancelNotification{SessionId: acpsdk.SessionId(sessionID)}) + } + cancelFunc() + if cancelErr != nil { + klog.V(log.LogLevelDebug).InfoS("ACP session cancellation failed", "error", cancelErr) + } + waitCh := make(chan error, 1) + go func() { waitCh <- process.Wait() }() + // Closing stdin lets a cooperative ACP process finish after it has + // acknowledged cancellation. If it does not, kill it below. + _ = process.Stdin.Close() + timer := time.NewTimer(tool.stopTimeout) + defer timer.Stop() + select { + case waitErr := <-waitCh: + return waitErr + case <-timer.C: + if killErr := process.Kill(); killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { + klog.V(log.LogLevelDebug).InfoS("ACP process kill failed", "error", killErr) + } + return <-waitCh + } + } + // ACP agents terminate cleanly when stdin reaches EOF. Give that + // path a bounded opportunity before using a hard kill. + if err := process.Stdin.Close(); err != nil && !errors.Is(err, os.ErrClosed) { + klog.V(log.LogLevelDebug).InfoS("ACP stdin close failed", "error", err) + } + return waitForExit() + } + + initialize, err := connection.Initialize(ctx, acpsdk.InitializeRequest{ + ProtocolVersion: acpsdk.ProtocolVersionNumber, + ClientInfo: &acpsdk.Implementation{ + Name: "plural-agent-harness", + Version: "1", + }, + ClientCapabilities: acpsdk.ClientCapabilities{ + Fs: acpsdk.FileSystemCapabilities{ + ReadTextFile: true, + WriteTextFile: true, + }, + Terminal: false, + Auth: acpsdk.AuthCapabilities{}, + }, + }) + if err != nil { + _ = stop(ctx.Err() != nil) + return fmt.Errorf("ACP initialize: %w", err) + } + if initialize.ProtocolVersion != acpsdk.ProtocolVersionNumber { + _ = stop(false) + return fmt.Errorf("ACP protocol version %d is unsupported", initialize.ProtocolVersion) + } + + tool.mu.RLock() + existingSession := tool.sessionID + tool.mu.RUnlock() + var modes *acpsdk.SessionModeState + var configOptions []acpsdk.SessionConfigOption + if existingSession == "" { + created, createErr := connection.NewSession(ctx, acpsdk.NewSessionRequest{ + Cwd: cwd, + McpServers: []acpsdk.McpServer{}, + }) + if createErr != nil { + _ = stop(ctx.Err() != nil) + return fmt.Errorf("ACP session/new: %w", createErr) + } + if created.SessionId == "" { + _ = stop(ctx.Err() != nil) + return errors.New("ACP session/new returned an empty session id") + } + tool.setSessionID(string(created.SessionId)) + turn.setSessionID(string(created.SessionId)) + modes = created.Modes + configOptions = created.ConfigOptions + } else { + resumed, resumeErr := connection.ResumeSession(ctx, acpsdk.ResumeSessionRequest{ + Cwd: cwd, + McpServers: []acpsdk.McpServer{}, + SessionId: acpsdk.SessionId(existingSession), + }) + if resumeErr != nil { + _ = stop(ctx.Err() != nil) + return fmt.Errorf("ACP session/resume: %w", resumeErr) + } + modes = resumed.Modes + configOptions = resumed.ConfigOptions + turn.setSessionID(existingSession) + } + + if err := tool.setSessionConfig(ctx, connection, turn.sessionID(), modes, configOptions); err != nil { + _ = stop(ctx.Err() != nil) + if priorSessionID == "" { + tool.setSessionID("") + } + return err + } + + if ctx.Err() != nil { + _ = stop(true) + return context.Cause(ctx) + } + response, promptErr := connection.Prompt(ctx, acpsdk.PromptRequest{ + SessionId: acpsdk.SessionId(turn.sessionID()), + Prompt: []acpsdk.ContentBlock{acpsdk.TextBlock(prompt)}, + }) + if promptErr != nil { + cancelled := ctx.Err() != nil + _ = stop(cancelled) + if cancelled { + return context.Cause(ctx) + } + // Prompt has crossed the dispatch boundary. Its result is never + // replayed because the agent may have received it. + return fmt.Errorf("ACP session/prompt: %w", promptErr) + } + + turn.stopFlusher() + turn.flushTools(true) + turn.emitAssistant(response.Usage) + if turn.err() != nil { + _ = stop(ctx.Err() != nil) + return turn.err() + } + if ctx.Err() != nil { + _ = stop(true) + return context.Cause(ctx) + } + if err := stop(false); err != nil { + return fmt.Errorf("stop ACP process: %w", err) + } + switch response.StopReason { + case acpsdk.StopReasonEndTurn: + return nil + case acpsdk.StopReasonMaxTokens, + acpsdk.StopReasonMaxTurnRequests, + acpsdk.StopReasonRefusal, + acpsdk.StopReasonCancelled: + return fmt.Errorf("ACP prompt stopped with reason %q", response.StopReason) + default: + return fmt.Errorf("ACP prompt returned unexpected stop reason %q", response.StopReason) + } +} + +func (tool *Tool) setSessionConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption) error { + tool.mu.RLock() + mode := tool.mode + model := tool.model + tool.mu.RUnlock() + + if model != "" { + if found, err := setConfigOption(ctx, connection, sessionID, options, "model", model); err != nil { + return err + } else if !found { + klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a model config option") + } + } + + if mode == "" { + return nil + } + if modes != nil { + for _, available := range modes.AvailableModes { + if string(available.Id) == mode { + if _, err := connection.SetSessionMode(ctx, acpsdk.SetSessionModeRequest{ + SessionId: acpsdk.SessionId(sessionID), + ModeId: acpsdk.SessionModeId(mode), + }); err != nil { + return fmt.Errorf("ACP session/set_mode: %w", err) + } + return nil + } + } + } + if found, err := setConfigOption(ctx, connection, sessionID, options, "mode", mode); err != nil { + return err + } else if found { + return nil + } + klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a mode config option", "mode", mode) + return nil +} + +func setConfigOption(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, configID, value string) (bool, error) { + for _, option := range options { + if option.Select == nil || string(option.Select.Id) != configID { + continue + } + wanted := acpsdk.SessionConfigValueId(value) + if option.Select.CurrentValue == wanted { + return true, nil + } + if !configOptionContains(option.Select.Options, wanted) { + return true, fmt.Errorf("ACP %s %q is not advertised", configID, value) + } + if _, err := connection.SetSessionConfigOption(ctx, acpsdk.SetSessionConfigOptionRequest{ + ValueId: &acpsdk.SetSessionConfigOptionValueId{ + ConfigId: option.Select.Id, + SessionId: acpsdk.SessionId(sessionID), + Value: wanted, + }, + }); err != nil { + return true, fmt.Errorf("ACP session/set_config_option %s: %w", configID, err) + } + return true, nil + } + return false, nil +} + +func configOptionContains(options acpsdk.SessionConfigSelectOptions, wanted acpsdk.SessionConfigValueId) bool { + if options.Ungrouped != nil { + for _, option := range *options.Ungrouped { + if option.Value == wanted { + return true + } + } + } + if options.Grouped != nil { + for _, group := range *options.Grouped { + for _, option := range group.Options { + if option.Value == wanted { + return true + } + } + } + } + return false +} + +func (tool *Tool) validate() error { + if tool.Config.Run == nil { + return errors.New("agent run is not set") + } + if tool.Config.RepositoryDir == "" { + return errors.New("repository directory is not set") + } + if tool.Config.WorkDir == "" { + return errors.New("work directory is not set") + } + if tool.Config.ErrorChan == nil { + return errors.New("error channel is not set") + } + return nil +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go new file mode 100644 index 0000000000..0e9f4f5ba4 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go @@ -0,0 +1,359 @@ +package acp + +import ( + "context" + "io" + "log/slog" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + acpsdk "github.com/coder/acp-go-sdk" + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" +) + +type scriptedState struct { + mu sync.Mutex + + initializations []acpsdk.InitializeRequest + newSessions []acpsdk.NewSessionRequest + resumedSessions []acpsdk.ResumeSessionRequest + configOptions []acpsdk.SessionConfigOption + setConfig []acpsdk.SetSessionConfigOptionRequest + modes *acpsdk.SessionModeState + setModes []acpsdk.SetSessionModeRequest + prompts []string + stopReason acpsdk.StopReason +} + +type scriptedAgent struct { + state *scriptedState + conn *acpsdk.AgentSideConnection +} + +func (agent *scriptedAgent) Authenticate(context.Context, acpsdk.AuthenticateRequest) (acpsdk.AuthenticateResponse, error) { + return acpsdk.AuthenticateResponse{}, nil +} + +func (agent *scriptedAgent) Initialize(_ context.Context, request acpsdk.InitializeRequest) (acpsdk.InitializeResponse, error) { + agent.state.mu.Lock() + agent.state.initializations = append(agent.state.initializations, request) + agent.state.mu.Unlock() + return acpsdk.InitializeResponse{ProtocolVersion: acpsdk.ProtocolVersionNumber}, nil +} + +func (agent *scriptedAgent) Logout(context.Context, acpsdk.LogoutRequest) (acpsdk.LogoutResponse, error) { + return acpsdk.LogoutResponse{}, nil +} + +func (agent *scriptedAgent) Cancel(context.Context, acpsdk.CancelNotification) error { + return nil +} + +func (agent *scriptedAgent) CloseSession(context.Context, acpsdk.CloseSessionRequest) (acpsdk.CloseSessionResponse, error) { + return acpsdk.CloseSessionResponse{}, nil +} + +func (agent *scriptedAgent) ListSessions(context.Context, acpsdk.ListSessionsRequest) (acpsdk.ListSessionsResponse, error) { + return acpsdk.ListSessionsResponse{}, nil +} + +func (agent *scriptedAgent) NewSession(_ context.Context, request acpsdk.NewSessionRequest) (acpsdk.NewSessionResponse, error) { + agent.state.mu.Lock() + agent.state.newSessions = append(agent.state.newSessions, request) + agent.state.mu.Unlock() + agent.state.mu.Lock() + configOptions := agent.state.configOptions + modes := agent.state.modes + agent.state.mu.Unlock() + return acpsdk.NewSessionResponse{SessionId: "session-1", ConfigOptions: configOptions, Modes: modes}, nil +} + +func (agent *scriptedAgent) Prompt(ctx context.Context, request acpsdk.PromptRequest) (acpsdk.PromptResponse, error) { + prompt := "" + if len(request.Prompt) > 0 && request.Prompt[0].Text != nil { + prompt = request.Prompt[0].Text.Text + } + agent.state.mu.Lock() + agent.state.prompts = append(agent.state.prompts, prompt) + stopReason := agent.state.stopReason + agent.state.mu.Unlock() + if err := agent.conn.SessionUpdate(ctx, acpsdk.SessionNotification{ + SessionId: request.SessionId, + Update: acpsdk.UpdateAgentMessageText("response: " + prompt), + }); err != nil { + return acpsdk.PromptResponse{}, err + } + if stopReason == "" { + stopReason = acpsdk.StopReasonEndTurn + } + return acpsdk.PromptResponse{StopReason: stopReason}, nil +} + +func (agent *scriptedAgent) ResumeSession(_ context.Context, request acpsdk.ResumeSessionRequest) (acpsdk.ResumeSessionResponse, error) { + agent.state.mu.Lock() + agent.state.resumedSessions = append(agent.state.resumedSessions, request) + agent.state.mu.Unlock() + agent.state.mu.Lock() + configOptions := agent.state.configOptions + modes := agent.state.modes + agent.state.mu.Unlock() + return acpsdk.ResumeSessionResponse{ConfigOptions: configOptions, Modes: modes}, nil +} + +func (agent *scriptedAgent) SetSessionConfigOption(_ context.Context, request acpsdk.SetSessionConfigOptionRequest) (acpsdk.SetSessionConfigOptionResponse, error) { + agent.state.mu.Lock() + agent.state.setConfig = append(agent.state.setConfig, request) + configOptions := agent.state.configOptions + agent.state.mu.Unlock() + return acpsdk.SetSessionConfigOptionResponse{ConfigOptions: configOptions}, nil +} + +func (agent *scriptedAgent) SetSessionMode(_ context.Context, request acpsdk.SetSessionModeRequest) (acpsdk.SetSessionModeResponse, error) { + agent.state.mu.Lock() + agent.state.setModes = append(agent.state.setModes, request) + agent.state.mu.Unlock() + return acpsdk.SetSessionModeResponse{}, nil +} + +func scriptedProcess(state *scriptedState) *exec.StdioProcess { + clientToAgentReader, clientToAgentWriter := io.Pipe() + agentToClientReader, agentToClientWriter := io.Pipe() + agent := &scriptedAgent{state: state} + agent.conn = acpsdk.NewAgentSideConnection(agent, agentToClientWriter, clientToAgentReader) + agent.conn.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) + + var closeOnce sync.Once + closePipes := func() { + closeOnce.Do(func() { + _ = clientToAgentWriter.Close() + _ = clientToAgentReader.Close() + _ = agentToClientWriter.Close() + _ = agentToClientReader.Close() + }) + } + return exec.NewStdioProcess(clientToAgentWriter, agentToClientReader, io.NopCloser(strings.NewReader("")), exec.StdioProcessHooks{ + Wait: func() error { + closePipes() + return nil + }, + Kill: func() error { + closePipes() + return nil + }, + Stop: func() error { + closePipes() + return nil + }, + Close: func() error { + closePipes() + return nil + }, + }) +} + +func testTool(t *testing.T, state *scriptedState) *Tool { + t.Helper() + repositoryDir := t.TempDir() + workDir := t.TempDir() + run := &agentrunv1.AgentRun{Prompt: "initial"} + tool := New(toolv1.Config{ + WorkDir: workDir, + RepositoryDir: repositoryDir, + Run: run, + Usage: usage.New(nil), + ErrorChan: make(chan error, 1), + }, WithLauncher(func(context.Context, []exec.Option) (*exec.StdioProcess, error) { + return scriptedProcess(state), nil + })) + return tool +} + +func TestRunPromptCreatesAndResumesSession(t *testing.T) { + state := &scriptedState{} + tool := testTool(t, state) + var messages []string + tool.OnMessage(func(message *console.AgentMessageAttributes, _ string) { + if message.Role == console.AiRoleAssistant { + messages = append(messages, message.Message) + } + }) + + if err := tool.FollowUpRun(context.Background(), "first"); err != nil { + t.Fatalf("first prompt: %v", err) + } + if err := tool.FollowUpRun(context.Background(), "second"); err != nil { + t.Fatalf("resumed prompt: %v", err) + } + + state.mu.Lock() + defer state.mu.Unlock() + if len(state.newSessions) != 1 || len(state.resumedSessions) != 1 { + t.Fatalf("session setup = new %d, resume %d; want one each", len(state.newSessions), len(state.resumedSessions)) + } + if len(state.prompts) != 2 || state.prompts[0] != "first" || state.prompts[1] != "second" { + t.Fatalf("prompts = %v", state.prompts) + } + if got := state.newSessions[0].Cwd; !filepath.IsAbs(got) { + t.Fatalf("new session cwd = %q, want absolute", got) + } + if got := state.resumedSessions[0].Cwd; !filepath.IsAbs(got) { + t.Fatalf("resumed session cwd = %q, want absolute", got) + } + if len(messages) != 2 { + t.Fatalf("assistant messages = %v, want two", messages) + } + if len(state.initializations) != 2 { + t.Fatalf("initializations = %d, want one per process", len(state.initializations)) + } + capabilities := state.initializations[0].ClientCapabilities + if !capabilities.Fs.ReadTextFile || !capabilities.Fs.WriteTextFile || capabilities.Terminal || capabilities.Auth.Terminal { + t.Fatal("unexpected ACP capabilities") + } +} + +func TestRunPromptAppliesOpenCodeModelAndModeConfigOptions(t *testing.T) { + modelOptions := acpsdk.SessionConfigSelectOptions{ + Ungrouped: &acpsdk.SessionConfigSelectOptionsUngrouped{ + {Name: "Default", Value: "provider/default"}, + {Name: "Configured", Value: "provider/configured"}, + }, + } + modeOptions := acpsdk.SessionConfigSelectOptions{ + Ungrouped: &acpsdk.SessionConfigSelectOptionsUngrouped{ + {Name: "Default", Value: "default"}, + {Name: "Analysis", Value: "analysis"}, + }, + } + state := &scriptedState{ + configOptions: []acpsdk.SessionConfigOption{ + {Select: &acpsdk.SessionConfigOptionSelect{Id: "model", CurrentValue: "provider/default", Options: modelOptions}}, + {Select: &acpsdk.SessionConfigOptionSelect{Id: "mode", CurrentValue: "default", Options: modeOptions}}, + }, + } + tool := testTool(t, state) + tool.model = "provider/configured" + tool.mode = "analysis" + + if err := tool.FollowUpRun(context.Background(), "configured"); err != nil { + t.Fatal(err) + } + + state.mu.Lock() + defer state.mu.Unlock() + if len(state.setConfig) != 2 { + t.Fatalf("config option updates = %d, want model and mode", len(state.setConfig)) + } + if got := string(state.setConfig[0].ValueId.Value); got != "provider/configured" { + t.Fatalf("model config value = %q", got) + } + if got := string(state.setConfig[1].ValueId.Value); got != "analysis" { + t.Fatalf("mode config value = %q", got) + } + if len(state.setModes) != 0 { + t.Fatalf("direct mode updates = %d, want config option update", len(state.setModes)) + } +} + +func TestRunUsesLifecycleOptionsOnlyForInitialPrompt(t *testing.T) { + state := &scriptedState{} + tool := testTool(t, state) + var launchOptionCounts []int + tool.launch = func(_ context.Context, options []exec.Option) (*exec.StdioProcess, error) { + state.mu.Lock() + launchOptionCounts = append(launchOptionCounts, len(options)) + state.mu.Unlock() + return scriptedProcess(state), nil + } + initialDone := make(chan struct{}) + tool.OnMessage(func(message *console.AgentMessageAttributes, _ string) { + if message.Role == console.AiRoleAssistant { + select { + case <-initialDone: + default: + close(initialDone) + } + } + }) + + tool.Run(context.Background(), exec.WithArgs([]string{"initial-only"})) + select { + case <-initialDone: + case <-time.After(time.Second): + t.Fatal("initial prompt did not complete") + } + if err := tool.FollowUpRun(context.Background(), "follow-up"); err != nil { + t.Fatal(err) + } + + if len(launchOptionCounts) != 2 { + t.Fatalf("launches = %v, want initial and follow-up", launchOptionCounts) + } + if launchOptionCounts[0] != 1 || launchOptionCounts[1] != 0 { + t.Fatalf("launch option counts = %v, want [1 0]", launchOptionCounts) + } +} + +func TestToolOutputThresholdAndTerminalFlush(t *testing.T) { + state := &scriptedState{} + tool := testTool(t, state) + tool.flushBytes = 3 + var outputs []string + tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { + if callID != "call-1" || message.Metadata == nil || message.Metadata.Tool == nil || message.Metadata.Tool.Output == nil { + return + } + outputs = append(outputs, *message.Metadata.Tool.Output) + }) + turn := newTurn(tool, "session-1") + start := acpsdk.StartToolCall("call-1", "shell", acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress)) + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { + t.Fatal(err) + } + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawOutput("abc"))}); err != nil { + t.Fatal(err) + } + if len(outputs) != 2 || outputs[1] != "abc" { + t.Fatalf("threshold outputs = %v, want initial and abc", outputs) + } + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), acpsdk.WithUpdateRawOutput("done"))}); err != nil { + t.Fatal(err) + } + if len(outputs) != 3 || outputs[2] != "done" { + t.Fatalf("terminal outputs = %v, want final done snapshot", outputs) + } +} + +func TestPromptStopReasonIsFailure(t *testing.T) { + state := &scriptedState{stopReason: acpsdk.StopReasonRefusal} + tool := testTool(t, state) + if err := tool.FollowUpRun(context.Background(), "refuse"); err == nil { + t.Fatal("refusal stop reason unexpectedly succeeded") + } +} + +func TestRecordCostHandlesCumulativeUpdatesAndReset(t *testing.T) { + tool := &Tool{} + for _, test := range []struct { + name string + amount float64 + want float64 + }{ + {name: "initial", amount: 4, want: 4}, + {name: "increase", amount: 7, want: 3}, + {name: "provider reset", amount: 2, want: 0}, + {name: "after reset", amount: 3, want: 1}, + } { + t.Run(test.name, func(t *testing.T) { + if got := tool.recordCost(test.amount); got != test.want { + t.Fatalf("recordCost(%v) = %v, want %v", test.amount, got, test.want) + } + }) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go new file mode 100644 index 0000000000..03492a888b --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go @@ -0,0 +1,340 @@ +package acp + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + acpsdk "github.com/coder/acp-go-sdk" + "k8s.io/klog/v2" + + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" + "github.com/pluralsh/console/go/deployment-operator/pkg/log" +) + +type turnState struct { + tool *Tool + mu sync.Mutex + sessionIDValue string + errValue error + assistant strings.Builder + reasoning strings.Builder + tools map[string]*toolCall + cost float64 + stopFlush chan struct{} + flushDone chan struct{} +} + +type toolCall struct { + id string + name string + input string + output string + state console.AgentMessageToolState + dirty bool + pendingBytes int + lastFlush time.Time +} + +func newTurn(tool *Tool, sessionID string) *turnState { + return &turnState{ + tool: tool, + sessionIDValue: sessionID, + tools: make(map[string]*toolCall), + stopFlush: make(chan struct{}), + flushDone: make(chan struct{}), + } +} + +func (turn *turnState) startFlusher(ctx context.Context) { + go func() { + defer close(turn.flushDone) + ticker := time.NewTicker(turn.tool.flushInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + turn.flushTools(false) + case <-turn.stopFlush: + return + case <-ctx.Done(): + return + } + } + }() +} + +func (turn *turnState) stopFlusher() { + select { + case <-turn.stopFlush: + default: + close(turn.stopFlush) + } + <-turn.flushDone +} + +func (turn *turnState) sessionID() string { + turn.mu.Lock() + defer turn.mu.Unlock() + return turn.sessionIDValue +} + +func (turn *turnState) setSessionID(sessionID string) { + turn.mu.Lock() + turn.sessionIDValue = sessionID + turn.mu.Unlock() +} + +func (turn *turnState) err() error { + turn.mu.Lock() + defer turn.mu.Unlock() + return turn.errValue +} + +func (turn *turnState) setErr(err error) { + if err == nil { + return + } + turn.mu.Lock() + if turn.errValue == nil { + turn.errValue = err + } + turn.mu.Unlock() +} + +func (turn *turnState) handle(notification acpsdk.SessionNotification) error { + if notification.SessionId != acpsdk.SessionId(turn.sessionID()) { + err := fmt.Errorf("ACP session update belongs to session %q, expected %q", notification.SessionId, turn.sessionID()) + turn.setErr(err) + return err + } + update := notification.Update + switch { + case update.AgentMessageChunk != nil: + text, err := contentText(update.AgentMessageChunk.Content) + if err != nil { + turn.setErr(fmt.Errorf("ACP agent message content: %w", err)) + return err + } + turn.mu.Lock() + turn.assistant.WriteString(text) + turn.mu.Unlock() + case update.AgentThoughtChunk != nil: + text, err := contentText(update.AgentThoughtChunk.Content) + if err != nil { + turn.setErr(fmt.Errorf("ACP agent thought content: %w", err)) + return err + } + turn.mu.Lock() + turn.reasoning.WriteString(text) + turn.mu.Unlock() + case update.ToolCall != nil: + return turn.startTool(update.ToolCall) + case update.ToolCallUpdate != nil: + return turn.updateTool(update.ToolCallUpdate) + case update.UsageUpdate != nil: + turn.usageUpdate(update.UsageUpdate) + case update.UserMessageChunk != nil: + if _, err := contentText(update.UserMessageChunk.Content); err != nil { + turn.setErr(fmt.Errorf("ACP user message content: %w", err)) + return err + } + default: + // Plans, mode/config notifications, and future optional updates do + // not affect the Console message contract. + klog.V(log.LogLevelDebug).InfoS("ignoring optional ACP session update") + } + return nil +} + +func (turn *turnState) startTool(update *acpsdk.SessionUpdateToolCall) error { + if update.ToolCallId == "" { + return turn.fail("ACP tool call has an empty id") + } + id := string(update.ToolCallId) + turn.mu.Lock() + if _, exists := turn.tools[id]; exists { + turn.mu.Unlock() + return turn.fail(fmt.Sprintf("ACP tool call %q was started twice", id)) + } + state, err := toolState(update.Status) + if err != nil { + turn.mu.Unlock() + return turn.fail(err.Error()) + } + call := &toolCall{ + id: id, + name: toolName(update.Title, update.Kind), + input: formatValue(update.RawInput), + state: state, + lastFlush: turn.tool.now(), + } + call.output = contentOutput(update.Content) + call.dirty = true + turn.tools[id] = call + message := call.message() + call.dirty = false + turn.mu.Unlock() + turn.tool.emit(message, id) + return nil +} + +func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { + id := string(update.ToolCallId) + turn.mu.Lock() + call, exists := turn.tools[id] + if !exists { + turn.mu.Unlock() + return turn.fail(fmt.Sprintf("ACP tool call update %q arrived before tool_call", id)) + } + if update.Title != nil { + call.name = *update.Title + } + if update.RawInput != nil { + call.input = formatValue(update.RawInput) + } + if output := contentOutput(update.Content); output != "" { + call.addOutput(output) + } + if update.RawOutput != nil { + call.addOutput(formatValue(update.RawOutput)) + } + terminal := false + if update.Status != nil { + state, err := toolState(*update.Status) + if err != nil { + turn.mu.Unlock() + return turn.fail(err.Error()) + } + call.state = state + terminal = state == console.AgentMessageToolStateCompleted || state == console.AgentMessageToolStateError + } + message := (*console.AgentMessageAttributes)(nil) + if terminal { + message = call.message() + delete(turn.tools, id) + call.dirty = false + } else if call.dirty && call.pendingBytes >= turn.tool.flushBytes { + message = call.message() + call.dirty = false + call.pendingBytes = 0 + call.lastFlush = turn.tool.now() + } + turn.mu.Unlock() + if message != nil { + turn.tool.emit(message, id) + } + return nil +} + +func (turn *turnState) flushTools(force bool) { + turn.mu.Lock() + type pendingMessage struct { + id string + message *console.AgentMessageAttributes + } + messages := make([]pendingMessage, 0) + now := turn.tool.now() + for id, call := range turn.tools { + if !call.dirty { + continue + } + if !force && now.Sub(call.lastFlush) < turn.tool.flushInterval { + continue + } + messages = append(messages, pendingMessage{id: id, message: call.message()}) + call.dirty = false + call.pendingBytes = 0 + call.lastFlush = now + _ = id + } + turn.mu.Unlock() + for _, pending := range messages { + turn.tool.emit(pending.message, pending.id) + } +} + +func (turn *turnState) emitAssistant(responseUsage *acpsdk.Usage) { + turn.mu.Lock() + text := turn.assistant.String() + reasoning := turn.reasoning.String() + cost := turn.cost + turn.mu.Unlock() + + message := &console.AgentMessageAttributes{Role: console.AiRoleAssistant, Message: text} + if reasoning != "" { + message.Metadata = &console.AgentMessageMetadataAttributes{ + Reasoning: &console.AgentMessageReasoningAttributes{Text: &reasoning}, + } + } + if responseUsage != nil { + input, output, total, cached, thought := normalizeUsage(responseUsage) + if turn.tool.Config.Usage != nil { + turn.tool.Config.Usage.RecordUsage(usage.Record{ + InputTokens: input, OutputTokens: output, TotalTokens: total, + CachedTokens: cached, ReasoningTokens: thought, + }) + } + inputValue := float64(input) + outputValue := float64(output) + thoughtValue := float64(thought) + message.Cost = &console.AgentMessageCostAttributes{ + Total: cost, + Tokens: &console.AgentMessageTokensAttributes{ + Input: &inputValue, Output: &outputValue, Reasoning: &thoughtValue, + }, + } + } else { + klog.V(log.LogLevelDebug).InfoS("ACP prompt response omitted optional usage") + } + if message.Cost == nil && cost > 0 { + message.Cost = &console.AgentMessageCostAttributes{Total: cost} + } + if text == "" { + if message.Cost == nil && reasoning == "" { + return + } + message.Message = "__plrl_ignore__" + } + turn.tool.emit(message, "") +} + +func (turn *turnState) usageUpdate(update *acpsdk.SessionUsageUpdate) { + if update.Cost == nil { + klog.V(log.LogLevelDebug).InfoS("ACP usage update omitted optional cost") + return + } + delta := turn.tool.recordCost(update.Cost.Amount) + if delta > 0 { + turn.mu.Lock() + turn.cost += delta + turn.mu.Unlock() + if turn.tool.Config.Usage != nil { + turn.tool.Config.Usage.RecordUsage(usage.Record{TotalCost: delta}) + } + } +} + +func (turn *turnState) fail(message string) error { + err := errors.New(message) + turn.setErr(err) + return err +} + +func (call *toolCall) addOutput(output string) { + if output == "" || output == call.output { + return + } + previous := call.output + call.output = output + if strings.HasPrefix(output, previous) { + call.pendingBytes += len(output) - len(previous) + } else { + call.pendingBytes += len(output) + } + call.dirty = true +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode.go new file mode 100644 index 0000000000..627fe16f99 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode.go @@ -0,0 +1,64 @@ +package acp + +import ( + "context" + "fmt" + "path/filepath" + + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/opencode" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" +) + +// NewOpenCode creates the OpenCode provider adapter for the provider-neutral +// ACP tool. OpenCode-specific configuration and native export remain owned by +// the opencode package; ACP session lifecycle stays in this package. +func NewOpenCode(config toolv1.Config) toolv1.Tool { + settings := opencode.ResolveACPSettings(config) + repositoryDir := config.RepositoryDir + if absolute, err := filepath.Abs(repositoryDir); err == nil { + repositoryDir = absolute + } + mode := "build" + if config.Run.Mode == console.AgentRunModeAnalyze { + mode = "plan" + } + + return New(config, + WithProviderName("opencode"), + WithMode(mode), + WithModel(settings.Provider+"/"+settings.Model), + WithConfigure(func(consoleURL, consoleToken string) error { + return opencode.Configure(config, consoleURL, consoleToken, opencode.Provider(settings.Provider), settings.Model, settings.OpenAICompatible) + }), + WithBabysitConfigure(func() error { + defaultTool := toolv1.DefaultTool{Config: config} + if err := defaultTool.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeOpencode); err != nil { + return err + } + return defaultTool.ConfigureSkills(opencode.ACPSkillsPath(config)) + }), + WithLauncher(func(ctx context.Context, options []exec.Option) (*exec.StdioProcess, error) { + configPath, err := filepath.Abs(opencode.ACPConfigPath(config)) + if err != nil { + return nil, fmt.Errorf("resolve opencode ACP config: %w", err) + } + options = append([]exec.Option(nil), options...) + options = append(options, + exec.WithArgs([]string{"acp"}), + exec.WithEnv(opencode.ACPEnvironment(config, configPath)), + exec.WithDir(repositoryDir), + exec.WithTimeout(config.Run.Runtime.Config.OpenCode.Timeout), + ) + // ACP owns cancellation ordering: send session/cancel first, then + // close stdin and kill if the process does not exit. Do not attach + // the prompt context directly to CommandContext, which would kill + // OpenCode before the ACP cancellation request is delivered. + return exec.StartWithStdio(context.Background(), "opencode", options...) + }), + WithExporter(func(ctx context.Context, outputPath, sessionID string) error { + return opencode.ExportSession(ctx, config, sessionID, outputPath) + }), + ) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/artifacts.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/artifacts.go index 61b4361f34..e293e7e958 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/artifacts.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/artifacts.go @@ -1,11 +1,9 @@ package opencode import ( - "bytes" "context" "fmt" "os" - stdexec "os/exec" "path/filepath" "k8s.io/klog/v2" @@ -48,27 +46,5 @@ func (in *Opencode) exportSession(ctx context.Context, path string) error { return fmt.Errorf("opencode session id is not set") } - configFilePath, err := filepath.Abs(in.configFilePath()) - if err != nil { - return err - } - - file, err := os.Create(path) - if err != nil { - return fmt.Errorf("create opencode session export %q: %w", path, err) - } - defer file.Close() - - var stderr bytes.Buffer - cmd := stdexec.CommandContext(ctx, "opencode", "export", in.sessionID) - cmd.Env = append(os.Environ(), in.env(configFilePath)...) - cmd.Dir = in.Config.RepositoryDir - cmd.Stdout = file - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("opencode export session %q: %w: %s", in.sessionID, err, stderr.String()) - } - - return nil + return ExportSession(ctx, in.Config, in.sessionID, path) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go index 9605854208..ea5a84c766 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "os" - "path" "path/filepath" "github.com/samber/lo" @@ -13,9 +12,6 @@ import ( console "github.com/pluralsh/console/go/client" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/environment" - "github.com/pluralsh/console/go/deployment-operator/pkg/common" - - "github.com/pluralsh/console/go/deployment-operator/internal/helpers" v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" "github.com/pluralsh/console/go/deployment-operator/pkg/log" @@ -26,47 +22,7 @@ func (in *Opencode) Run(ctx context.Context, options ...exec.Option) { } func (in *Opencode) Configure(consoleURL, consoleToken string) error { - if err := in.ConfigureSystemPrompt(console.AgentRuntimeTypeOpencode); err != nil { - return err - } - if err := in.ConfigureSkills(in.skillsPath()); err != nil { - return err - } - - input := &ConfigTemplateInput{ - ConsoleURL: consoleURL, - ConsoleToken: consoleToken, - AgentRunID: in.Config.Run.ID, - Provider: in.provider, - OpenAICompatible: in.openaiCompatible, - Endpoint: in.Config.Run.Runtime.Config.OpenCode.Endpoint, - Model: in.model, - Token: in.Config.Run.Runtime.Config.OpenCode.Token, - Mode: in.Config.Run.Mode, - DindEnabled: in.Config.Run.DindEnabled, - StreamingProxy: in.Config.Run.IsStreamingProxyEnabled(), - StreamingProxyBaseURL: common.AgentOpenAIBaseURL, - } - - _, content, err := configTemplate(input) - if err != nil { - return err - } - - if err = helpers.File().Create(in.configFilePath(), content, 0644); err != nil { - return fmt.Errorf("failed configuring opencode config file %q: %w", ConfigFileName, err) - } - - klog.V(log.LogLevelExtended).InfoS( - "opencode configured", - "configFile", in.configFilePath(), - "provider", in.provider, - "model", in.model, - "endpoint", in.Config.Run.Runtime.Config.OpenCode.Endpoint, - "mode", in.Config.Run.Mode, - ) - - return nil + return Configure(in.Config, consoleURL, consoleToken, in.provider, in.model, in.openaiCompatible) } func (in *Opencode) OnMessage(f v1.MessageCallback) { @@ -297,15 +253,15 @@ func (in *Opencode) agent() string { } func (in *Opencode) configFilePath() string { - return path.Join(in.providerPath(), ConfigFileName) + return opencodeConfigFilePath(in.Config) } func (in *Opencode) skillsPath() string { - return path.Join(in.providerPath(), "skills") + return opencodeSkillsPath(in.Config) } func (in *Opencode) providerPath() string { - return filepath.Join(in.Config.WorkDir, ".opencode") + return opencodeProviderPath(in.Config) } func truncateForLog(value string, limit int) string { @@ -439,23 +395,19 @@ func (in *Opencode) ConfigureBabysitRun() error { } func (in *Opencode) env(configFilePath string) []string { - return []string{ - fmt.Sprintf("OPENCODE_CONFIG=%s", configFilePath), - fmt.Sprintf("XDG_CONFIG_HOME=%s", in.configHome()), - fmt.Sprintf("XDG_DATA_HOME=%s", in.dataHome()), - } + return opencodeEnv(in.Config, configFilePath) } func (in *Opencode) configHome() string { - return filepath.Join(in.Config.WorkDir, ".config") + return opencodeConfigHome(in.Config) } func (in *Opencode) dataPath() string { - return filepath.Join(in.dataHome(), "opencode") + return filepath.Join(opencodeDataHome(in.Config), "opencode") } func (in *Opencode) dataHome() string { - return filepath.Join(in.Config.WorkDir, ".local", "share") + return opencodeDataHome(in.Config) } func (in *Opencode) recordSessionID(sessionID string) { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_acp_types.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_acp_types.go new file mode 100644 index 0000000000..1fab5c62a3 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_acp_types.go @@ -0,0 +1,23 @@ +package opencode + +import toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + +// ACPSettings contains the provider/model values OpenCode advertises through +// ACP session configuration options. +type ACPSettings struct { + Provider string + Model string + OpenAICompatible bool +} + +// ResolveACPSettings resolves the configured provider and model using the +// same rules as the legacy OpenCode adapter. +func ResolveACPSettings(config toolv1.Config) ACPSettings { + oc := config.Run.Runtime.Config.OpenCode + settings := resolveOpenCodeSettings(oc.Provider, oc.Model, oc.OpenAICompatible, config.Run.IsProxyEnabled()) + return ACPSettings{ + Provider: string(settings.provider), + Model: settings.model, + OpenAICompatible: settings.openaiCompatible, + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_config.go new file mode 100644 index 0000000000..e793b2a6a2 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_config.go @@ -0,0 +1,124 @@ +package opencode + +import ( + "bytes" + "context" + "fmt" + "os" + stdexec "os/exec" + "path/filepath" + + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/internal/helpers" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/common" +) + +// Configure writes the OpenCode provider configuration and shared system +// prompt files used by both the legacy and ACP adapters. +func Configure(config toolv1.Config, consoleURL, consoleToken string, provider Provider, model string, openaiCompatible bool) error { + defaultTool := toolv1.DefaultTool{Config: config} + if err := defaultTool.ConfigureSystemPrompt(console.AgentRuntimeTypeOpencode); err != nil { + return err + } + if err := defaultTool.ConfigureSkills(opencodeSkillsPath(config)); err != nil { + return err + } + + input := &ConfigTemplateInput{ + ConsoleURL: consoleURL, + ConsoleToken: consoleToken, + AgentRunID: config.Run.ID, + Provider: provider, + OpenAICompatible: openaiCompatible, + Endpoint: config.Run.Runtime.Config.OpenCode.Endpoint, + Model: model, + Token: config.Run.Runtime.Config.OpenCode.Token, + Mode: config.Run.Mode, + DindEnabled: config.Run.DindEnabled, + StreamingProxy: config.Run.IsStreamingProxyEnabled(), + StreamingProxyBaseURL: common.AgentOpenAIBaseURL, + } + + _, content, err := configTemplate(input) + if err != nil { + return err + } + + configPath := opencodeConfigFilePath(config) + if err = helpers.File().Create(configPath, content, 0644); err != nil { + return fmt.Errorf("failed configuring opencode config file %q: %w", ConfigFileName, err) + } + return nil +} + +func opencodeProviderPath(config toolv1.Config) string { + return filepath.Join(config.WorkDir, ".opencode") +} + +func opencodeConfigFilePath(config toolv1.Config) string { + return filepath.Join(opencodeProviderPath(config), ConfigFileName) +} + +func opencodeSkillsPath(config toolv1.Config) string { + return filepath.Join(opencodeProviderPath(config), "skills") +} + +func opencodeConfigHome(config toolv1.Config) string { + return filepath.Join(config.WorkDir, ".config") +} + +func opencodeDataHome(config toolv1.Config) string { + return filepath.Join(config.WorkDir, ".local", "share") +} + +func opencodeEnv(config toolv1.Config, configPath string) []string { + return []string{ + fmt.Sprintf("OPENCODE_CONFIG=%s", configPath), + fmt.Sprintf("XDG_CONFIG_HOME=%s", opencodeConfigHome(config)), + fmt.Sprintf("XDG_DATA_HOME=%s", opencodeDataHome(config)), + } +} + +// ACPConfigPath returns the path to the OpenCode configuration used by ACP. +func ACPConfigPath(config toolv1.Config) string { + return opencodeConfigFilePath(config) +} + +// ACPSkillsPath returns the path to OpenCode skills used by ACP. +func ACPSkillsPath(config toolv1.Config) string { + return opencodeSkillsPath(config) +} + +// ACPEnvironment returns the environment required by OpenCode ACP. +func ACPEnvironment(config toolv1.Config, configPath string) []string { + return opencodeEnv(config, configPath) +} + +// ExportSession writes an OpenCode native session export to outputPath. +func ExportSession(ctx context.Context, config toolv1.Config, sessionID, outputPath string) error { + if sessionID == "" { + return fmt.Errorf("opencode session id is not set") + } + configPath, err := filepath.Abs(opencodeConfigFilePath(config)) + if err != nil { + return err + } + + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create opencode session export %q: %w", outputPath, err) + } + defer file.Close() + + cmd := stdexec.CommandContext(ctx, "opencode", "export", sessionID) + cmd.Env = append(os.Environ(), opencodeEnv(config, configPath)...) + cmd.Dir = config.RepositoryDir + cmd.Stdout = file + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("opencode export session %q: %w: %s", sessionID, err, stderr.String()) + } + return nil +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl index c1c921b2ce..3cde6e0cbf 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl @@ -2,6 +2,7 @@ "$schema": "https://opencode.ai/config.json", "autoupdate": false, "snapshot": false, + "model": "{{ .Provider }}/{{ .Model }}", "permission": { "skill": { "*": "allow" diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go index db9b48ed86..c6d8049468 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go @@ -4,10 +4,10 @@ import ( "fmt" console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/claude" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/codex" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/gemini" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/opencode" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/pi" v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/log" @@ -21,7 +21,7 @@ func New(runtimeType console.AgentRuntimeType, config v1.Config) (v1.Tool, error switch runtimeType { case console.AgentRuntimeTypeOpencode: - return opencode.New(config), nil + return acp.NewOpenCode(config), nil case console.AgentRuntimeTypeClaude: return claude.New(config), nil case console.AgentRuntimeTypeGemini: diff --git a/go/deployment-operator/pkg/harness/exec/exec.go b/go/deployment-operator/pkg/harness/exec/exec.go index b2e4ec5422..52e673cb1c 100644 --- a/go/deployment-operator/pkg/harness/exec/exec.go +++ b/go/deployment-operator/pkg/harness/exec/exec.go @@ -12,13 +12,13 @@ import ( "sync" "time" - "github.com/pluralsh/console/go/polly/algorithms" "k8s.io/apimachinery/pkg/util/uuid" "k8s.io/klog/v2" "github.com/pluralsh/console/go/deployment-operator/pkg/harness/signals" v1 "github.com/pluralsh/console/go/deployment-operator/pkg/harness/stackrun/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/log" + "github.com/pluralsh/console/go/polly/algorithms" ) func (in *executable) Run(ctx context.Context) error { diff --git a/go/deployment-operator/pkg/harness/exec/exec_stdio.go b/go/deployment-operator/pkg/harness/exec/exec_stdio.go new file mode 100644 index 0000000000..bbc61c98a7 --- /dev/null +++ b/go/deployment-operator/pkg/harness/exec/exec_stdio.go @@ -0,0 +1,155 @@ +package exec + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sync" + + "k8s.io/klog/v2" + + v1 "github.com/pluralsh/console/go/deployment-operator/pkg/harness/stackrun/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/log" +) + +// StartWithStdio starts an executable without taking ownership of its output +// streams. This is used by protocols that carry their own framed messages over +// stdin/stdout. Callers must drain Stdout and Stderr, then call Wait. +func (in *executable) StartWithStdio(ctx context.Context) (*StdioProcess, error) { + if err := in.runLifecycleFunction(v1.LifecyclePreStart); err != nil { + return nil, err + } + + if ctx == nil { + ctx = context.Background() + } + var runCtx context.Context + var cancelRun context.CancelFunc + if in.timeout > 0 { + runCtx, cancelRun = context.WithTimeout(ctx, in.timeout) + } else { + runCtx, cancelRun = context.WithCancel(ctx) + } + cmd := exec.CommandContext(runCtx, in.command, in.args...) + cmd.Env = append(os.Environ(), in.env...) + if len(in.workingDirectory) > 0 { + cmd.Dir = in.workingDirectory + } + + stdin, err := cmd.StdinPipe() + if err != nil { + cancelRun() + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + cancelRun() + _ = stdin.Close() + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + cancelRun() + _ = stdin.Close() + _ = stdout.Close() + return nil, err + } + + klog.V(log.LogLevelExtended).InfoS("executing", "command", in.Command()) + if err := cmd.Start(); err != nil { + cancelRun() + _ = stdin.Close() + _ = stdout.Close() + _ = stderr.Close() + return nil, err + } + + var waitOnce sync.Once + var waitErr error + var closeOnce sync.Once + var closeErr error + var stopOnce sync.Once + var stopErr error + intentionalStop := false + var stateMu sync.Mutex + + closeStreams := func() error { + closeOnce.Do(func() { + for _, stream := range []io.Closer{stdin, stdout, stderr} { + if err := stream.Close(); err != nil && !errors.Is(err, os.ErrClosed) { + closeErr = errors.Join(closeErr, err) + } + } + }) + return closeErr + } + + wait := func() error { + waitOnce.Do(func() { + waitErr = cmd.Wait() + cause := context.Cause(runCtx) + cancelRun() + _ = closeStreams() + + stateMu.Lock() + wasStopped := intentionalStop + stateMu.Unlock() + if cause != nil && !wasStopped { + waitErr = errors.Join(waitErr, cause) + } + if wasStopped { + // Stop is an intentional lifecycle operation. The process is + // expected to report a signal-related exit in this case. + waitErr = nil + } + if err := in.runLifecycleFunction(v1.LifecyclePostStart); err != nil { + waitErr = errors.Join(waitErr, err) + } + }) + return waitErr + } + + kill := func() error { + if cmd.Process == nil { + return nil + } + err := cmd.Process.Kill() + if errors.Is(err, os.ErrProcessDone) { + return nil + } + return err + } + + stop := func() error { + stopOnce.Do(func() { + stateMu.Lock() + intentionalStop = true + stateMu.Unlock() + _ = stdin.Close() + stopErr = kill() + }) + return stopErr + } + + return NewStdioProcess(stdin, stdout, stderr, StdioProcessHooks{ + Wait: wait, + Kill: kill, + Stop: stop, + Close: closeStreams, + }), nil +} + +// StartWithStdio creates and starts a command with bidirectional standard +// streams. It is additive to NewExecutable and leaves existing execution +// behavior unchanged. +func StartWithStdio(ctx context.Context, command string, options ...Option) (*StdioProcess, error) { + executable := NewExecutable(command, options...) + stdio, ok := executable.(StdioExecutable) + if !ok { + return nil, fmt.Errorf("executable %q does not support bidirectional stdio", command) + } + return stdio.StartWithStdio(ctx) +} diff --git a/go/deployment-operator/pkg/harness/exec/exec_stdio_test.go b/go/deployment-operator/pkg/harness/exec/exec_stdio_test.go new file mode 100644 index 0000000000..78e7010b04 --- /dev/null +++ b/go/deployment-operator/pkg/harness/exec/exec_stdio_test.go @@ -0,0 +1,73 @@ +package exec + +import ( + "context" + "errors" + "io" + "sync/atomic" + "testing" + + stackv1 "github.com/pluralsh/console/go/deployment-operator/pkg/harness/stackrun/v1" + "github.com/stretchr/testify/require" +) + +func TestStartWithStdioRunsLifecycleHooksAndClosesStreams(t *testing.T) { + var preStarts atomic.Int32 + var postStarts atomic.Int32 + process, err := StartWithStdio(context.Background(), "sh", + WithArgs([]string{"-c", "read value; printf '%s' \"$value\""}), + WithHook(stackv1.LifecyclePreStart, func() error { + preStarts.Add(1) + return nil + }), + WithHook(stackv1.LifecyclePostStart, func() error { + postStarts.Add(1) + return nil + }), + ) + require.NoError(t, err) + + stdout := make(chan []byte, 1) + go func() { + output, _ := io.ReadAll(process.Stdout) + stdout <- output + }() + _, err = io.WriteString(process.Stdin, "hello\n") + require.NoError(t, err) + require.NoError(t, process.Stdin.Close()) + require.NoError(t, process.Wait()) + require.NoError(t, process.Wait(), "Wait must be idempotent") + require.Equal(t, []byte("hello"), <-stdout) + require.Equal(t, int32(1), preStarts.Load()) + require.Equal(t, int32(1), postStarts.Load()) + require.NoError(t, process.Close()) +} + +func TestStartWithStdioStopIsIdempotent(t *testing.T) { + var postStarts atomic.Int32 + process, err := StartWithStdio(context.Background(), "sh", + WithArgs([]string{"-c", "sleep 30"}), + WithHook(stackv1.LifecyclePostStart, func() error { + postStarts.Add(1) + return nil + }), + ) + require.NoError(t, err) + + require.NoError(t, process.Stop()) + require.NoError(t, process.Stop(), "Stop must be idempotent") + require.NoError(t, process.Wait()) + require.NoError(t, process.Wait(), "Wait must be idempotent") + require.Equal(t, int32(1), postStarts.Load()) + require.NoError(t, process.Close()) +} + +func TestStartWithStdioPreStartFailureDoesNotStartProcess(t *testing.T) { + preErr := errors.New("pre-start failed") + process, err := StartWithStdio(context.Background(), "sh", + WithArgs([]string{"-c", "exit 0"}), + WithHook(stackv1.LifecyclePreStart, func() error { return preErr }), + ) + require.ErrorIs(t, err, preErr) + require.Nil(t, process) +} diff --git a/go/deployment-operator/pkg/harness/exec/exec_stdio_types.go b/go/deployment-operator/pkg/harness/exec/exec_stdio_types.go new file mode 100644 index 0000000000..a0f72ed169 --- /dev/null +++ b/go/deployment-operator/pkg/harness/exec/exec_stdio_types.go @@ -0,0 +1,85 @@ +package exec + +import ( + "context" + "io" +) + +// StdioProcess is a running executable with bidirectional standard input and +// output. The process owner must drain Stdout and Stderr before calling Wait. +// Stop closes the input stream and terminates the process; it is safe to call +// more than once. +type StdioProcess struct { + Stdin io.WriteCloser + Stdout io.ReadCloser + Stderr io.ReadCloser + + wait func() error + kill func() error + close func() error + stop func() error +} + +// StdioProcessHooks supplies lifecycle operations for a StdioProcess. It is +// useful for protocol adapters and deterministic tests that provide their own +// in-memory streams. +type StdioProcessHooks struct { + Wait func() error + Kill func() error + Stop func() error + Close func() error +} + +// NewStdioProcess wraps bidirectional streams and their lifecycle operations. +func NewStdioProcess(stdin io.WriteCloser, stdout, stderr io.ReadCloser, hooks StdioProcessHooks) *StdioProcess { + return &StdioProcess{ + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + wait: hooks.Wait, + kill: hooks.Kill, + stop: hooks.Stop, + close: hooks.Close, + } +} + +// Wait waits for the process and runs its post-start lifecycle hook. It also +// closes the process streams after the child exits. +func (p *StdioProcess) Wait() error { + if p == nil || p.wait == nil { + return nil + } + return p.wait() +} + +// Kill terminates the child process without waiting for it. +func (p *StdioProcess) Kill() error { + if p == nil || p.kill == nil { + return nil + } + return p.kill() +} + +// Stop closes stdin and terminates the child process. +func (p *StdioProcess) Stop() error { + if p == nil || p.stop == nil { + return nil + } + return p.stop() +} + +// Close closes all process streams. It does not wait for the process. +func (p *StdioProcess) Close() error { + if p == nil || p.close == nil { + return nil + } + return p.close() +} + +// StdioExecutable is implemented by executables that can be started with +// bidirectional standard streams. It is intentionally separate from +// Executable so existing callers and test doubles keep their contracts. +type StdioExecutable interface { + Executable + StartWithStdio(context.Context) (*StdioProcess, error) +} From b5fdd1c49d60a4b872dd39d008cfb0ae6be82ca1 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 28 Aug 2026 13:06:04 +0200 Subject: [PATCH 02/46] feat(acp): enhance file read functionality with context checks - Add context cancellation checks in `ReadTextFile` for robustness. - Introduce a limit on text file read size with `maxTextFileBytes`. - Implement `contextReader --- .../agentrun-harness/tool/acp/acp_client.go | 34 +++++++++++++++++-- .../tool/acp/acp_client_test.go | 28 +++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go index 112cc95b13..aa26a3dccf 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go @@ -13,14 +13,19 @@ import ( acpsdk "github.com/coder/acp-go-sdk" ) +const maxTextFileBytes = 16 << 20 + type client struct { turn *turnState } -func (client *client) ReadTextFile(_ context.Context, request acpsdk.ReadTextFileRequest) (acpsdk.ReadTextFileResponse, error) { +func (client *client) ReadTextFile(ctx context.Context, request acpsdk.ReadTextFileRequest) (acpsdk.ReadTextFileResponse, error) { if err := client.validateSession(request.SessionId); err != nil { return acpsdk.ReadTextFileResponse{}, err } + if err := ctx.Err(); err != nil { + return acpsdk.ReadTextFileResponse{}, err + } if !filepath.IsAbs(request.Path) { return acpsdk.ReadTextFileResponse{}, fmt.Errorf("ACP filesystem path must be absolute: %q", request.Path) } @@ -29,8 +34,18 @@ func (client *client) ReadTextFile(_ context.Context, request acpsdk.ReadTextFil return acpsdk.ReadTextFileResponse{}, fmt.Errorf("read %s: %w", request.Path, err) } defer file.Close() + info, err := file.Stat() + if err != nil { + return acpsdk.ReadTextFileResponse{}, fmt.Errorf("stat %s: %w", request.Path, err) + } + if !info.Mode().IsRegular() { + return acpsdk.ReadTextFileResponse{}, fmt.Errorf("ACP filesystem path is not a regular file: %q", request.Path) + } + if info.Size() > maxTextFileBytes { + return acpsdk.ReadTextFileResponse{}, fmt.Errorf("ACP filesystem file exceeds %d-byte read limit: %q", maxTextFileBytes, request.Path) + } - reader := bufio.NewReader(file) + reader := bufio.NewReader(io.LimitReader(&contextReader{ctx: ctx, reader: file}, maxTextFileBytes+1)) if request.Line != nil { for line := 1; line < max(*request.Line, 1); line++ { if _, readErr := reader.ReadString('\n'); readErr != nil { @@ -46,6 +61,9 @@ func (client *client) ReadTextFile(_ context.Context, request acpsdk.ReadTextFil if readErr != nil { return acpsdk.ReadTextFileResponse{}, fmt.Errorf("read %s: %w", request.Path, readErr) } + if len(content) > maxTextFileBytes { + return acpsdk.ReadTextFileResponse{}, fmt.Errorf("ACP filesystem file exceeds %d-byte read limit: %q", maxTextFileBytes, request.Path) + } return acpsdk.ReadTextFileResponse{Content: string(content)}, nil } @@ -63,6 +81,18 @@ func (client *client) ReadTextFile(_ context.Context, request acpsdk.ReadTextFil return acpsdk.ReadTextFileResponse{Content: strings.Join(lines, "\n")}, nil } +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (reader *contextReader) Read(buffer []byte) (int, error) { + if err := reader.ctx.Err(); err != nil { + return 0, err + } + return reader.reader.Read(buffer) +} + func (client *client) WriteTextFile(_ context.Context, request acpsdk.WriteTextFileRequest) (acpsdk.WriteTextFileResponse, error) { if err := client.validateSession(request.SessionId); err != nil { return acpsdk.WriteTextFileResponse{}, err diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client_test.go index e73229845e..71898e704c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client_test.go @@ -2,6 +2,7 @@ package acp import ( "context" + "errors" "os" "path/filepath" "testing" @@ -89,3 +90,30 @@ func TestWriteTextFileCreatesEmptyFile(t *testing.T) { t.Fatalf("empty file size = %d, want 0", info.Size()) } } + +func TestReadTextFileRejectsCanceledAndOversizedReads(t *testing.T) { + client, cwd := testACPClient(t) + path := filepath.Join(cwd, "large.txt") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(maxTextFileBytes + 1); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + + request := acpsdk.ReadTextFileRequest{SessionId: "session-1", Path: path} + if _, err := client.ReadTextFile(context.Background(), request); err == nil { + t.Fatal("oversized read unexpectedly succeeded") + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + request.Path = filepath.Join(cwd, "missing.txt") + if _, err := client.ReadTextFile(ctx, request); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled read error = %v, want context.Canceled", err) + } +} From f9b8388514c7a7dcb1c69bb3541309d9998a4a88 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 28 Aug 2026 13:19:59 +0200 Subject: [PATCH 03/46] chore(ci): update OPENCODE_VERSION in deployment workflow - Bump OPENCODE_VERSION from 1.17.3 to 1.18.23 for deployment compatibility. --- .github/workflows/deployment-operator-cd-agent-harness.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment-operator-cd-agent-harness.yaml b/.github/workflows/deployment-operator-cd-agent-harness.yaml index ad4308fbc0..e65819729f 100644 --- a/.github/workflows/deployment-operator-cd-agent-harness.yaml +++ b/.github/workflows/deployment-operator-cd-agent-harness.yaml @@ -33,7 +33,7 @@ jobs: NODE_VERSION: 24.11.1 CLAUDE_VERSION: 2.1.72 GEMINI_VERSION: 0.44.1 - OPENCODE_VERSION: 1.17.3 + OPENCODE_VERSION: 1.18.23 CODEX_VERSION: 0.104.0 PI_VERSION: 0.84.1 outputs: From c2f5efd417846a649cefaf8c4eb0fb5bc8a8ce73 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 28 Aug 2026 13:38:48 +0200 Subject: [PATCH 04/46] feat(tool): implement testing and enhance agent mode configuration - Add `TestNewOpenCodeSelectsConfiguredAgentForRunMode` to verify agent selection based on run mode. - Update `opencode_args_test.go` to use `Default*Agent` constants. - Change `opencode_types.go` to export `Default*Agent` constants. - Modify `opencode.go` files to utilize exported `Default*Agent` constants. --- .../pkg/agentrun-harness/tool/acp/opencode.go | 4 +-- .../tool/acp/opencode_test.go | 33 +++++++++++++++++++ .../tool/opencode/opencode.go | 4 +-- .../tool/opencode/opencode_args_test.go | 4 +-- .../tool/opencode/opencode_types.go | 7 ++-- 5 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode_test.go diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode.go index 627fe16f99..ee9f16982b 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode.go @@ -20,9 +20,9 @@ func NewOpenCode(config toolv1.Config) toolv1.Tool { if absolute, err := filepath.Abs(repositoryDir); err == nil { repositoryDir = absolute } - mode := "build" + mode := opencode.DefaultWriteAgent if config.Run.Mode == console.AgentRunModeAnalyze { - mode = "plan" + mode = opencode.DefaultAnalysisAgent } return New(config, diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode_test.go new file mode 100644 index 0000000000..4d75109f5a --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode_test.go @@ -0,0 +1,33 @@ +package acp + +import ( + "testing" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestNewOpenCodeSelectsConfiguredAgentForRunMode(t *testing.T) { + for _, test := range []struct { + name string + mode console.AgentRunMode + want string + }{ + {name: "write", mode: console.AgentRunModeWrite, want: "autonomous"}, + {name: "analyze", mode: console.AgentRunModeAnalyze, want: "analysis"}, + } { + t.Run(test.name, func(t *testing.T) { + config := toolv1.Config{Run: &agentrunv1.AgentRun{ + Mode: test.mode, + Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{ + OpenCode: &agentrunv1.OpencodeConfig{}, + }}, + }} + tool := NewOpenCode(config).(*Tool) + if tool.mode != test.want { + t.Fatalf("ACP mode = %q, want configured OpenCode agent %q", tool.mode, test.want) + } + }) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go index ea5a84c766..4076ef2d7f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go @@ -246,10 +246,10 @@ func (in *Opencode) args(prompt string, resume bool) []string { func (in *Opencode) agent() string { if in.Config.Run.Mode == console.AgentRunModeAnalyze { - return defaultAnalysisAgent + return DefaultAnalysisAgent } - return defaultWriteAgent + return DefaultWriteAgent } func (in *Opencode) configFilePath() string { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go index 765a0fb0f3..8ad5ba6879 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go @@ -21,7 +21,7 @@ func TestOpencodeArgs(t *testing.T) { want := []string{ "run", "--format", "json", - "--agent", defaultWriteAgent, + "--agent", DefaultWriteAgent, "--model", "anthropic/claude-sonnet-4-6", "fix bug", } @@ -43,7 +43,7 @@ func TestOpencodeArgsResume(t *testing.T) { want := []string{ "run", "--format", "json", - "--agent", defaultAnalysisAgent, + "--agent", DefaultAnalysisAgent, "--model", "anthropic/claude-sonnet-4-6", "--session", sessionID, "continue analysis", diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_types.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_types.go index 8cd2ae71a4..8b08e95ec4 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_types.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_types.go @@ -4,18 +4,19 @@ import ( "encoding/json" "time" + "github.com/samber/lo" + console "github.com/pluralsh/console/go/client" proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" - "github.com/samber/lo" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" ) const ( - defaultAnalysisAgent = "analysis" - defaultWriteAgent = "autonomous" + DefaultAnalysisAgent = "analysis" + DefaultWriteAgent = "autonomous" ) // Provider is an OpenCode provider id (https://models.dev). From d6bb72053bbca297fc68e4b1ffb8215c81b63429 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 28 Aug 2026 14:03:19 +0200 Subject: [PATCH 05/46] feat(acp): refactor session lifecycle management - Introduce `sessionAttempt` and `sessionDetails` structs for clearer separation of attempts and session configurations - Implement session attempt lifecycle methods: `startAttempt`, `run`, `stop`, `initialize`, `openSession`, `createSession`, `resumeSession` - Add error handling improvements: `fail`, `promptFailure` - Consolidate process rejection logic into `rejectProcess` - Refine session configuration with `setModelConfig` and `setModeConfig` functions for cleaner configuration flow --- .../agentrun-harness/tool/acp/acp_session.go | 452 +++++++++++------- 1 file changed, 284 insertions(+), 168 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go index a2c730e323..aa27c939c6 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go @@ -40,100 +40,137 @@ func (tool *Tool) runAttempt(ctx context.Context, prompt string, options []exec. if err != nil { return fmt.Errorf("resolve ACP repository directory: %w", err) } + attempt, err := tool.startAttempt(ctx, options) + if err != nil { + return err + } + defer attempt.close() + defer attempt.turn.stopFlusher() + return attempt.run(cwd, prompt) +} + +type sessionAttempt struct { + tool *Tool + ctx context.Context + process *exec.StdioProcess + connection *acpsdk.ClientSideConnection + turn *turnState + priorSessionID string +} + +type sessionDetails struct { + sessionID string + modes *acpsdk.SessionModeState + configOptions []acpsdk.SessionConfigOption +} + +func (tool *Tool) startAttempt(ctx context.Context, options []exec.Option) (*sessionAttempt, error) { tool.mu.RLock() launch := tool.launch priorSessionID := tool.sessionID tool.mu.RUnlock() if launch == nil { - return errors.New("ACP launcher is not set") + return nil, errors.New("acp launcher is not set") } process, err := launch(ctx, options) if err != nil { - return err + return nil, err } if process == nil || process.Stdin == nil || process.Stdout == nil { - if process != nil { - _ = process.Stop() - _ = process.Wait() - } - return errors.New("ACP launcher returned an incomplete stdio process") + return nil, rejectProcess(process) } - defer func() { - // The process is stopped explicitly below. This is a final guard for - // setup failures and keeps test launchers from leaking children. - _ = process.Close() - }() + return newSessionAttempt(tool, ctx, process, priorSessionID), nil +} - if process.Stderr != nil { - go func() { - if _, copyErr := io.Copy(io.Discard, process.Stderr); copyErr != nil && !errors.Is(copyErr, io.ErrClosedPipe) { - klog.V(log.LogLevelDebug).InfoS("ACP stderr drain ended", "error", copyErr) - } - }() +func rejectProcess(process *exec.StdioProcess) error { + if process != nil { + _ = process.Stop() + _ = process.Wait() } + return errors.New("acp launcher returned an incomplete stdio process") +} +func newSessionAttempt(tool *Tool, ctx context.Context, process *exec.StdioProcess, priorSessionID string) *sessionAttempt { turn := newTurn(tool, priorSessionID) - defer turn.stopFlusher() - client := &client{turn: turn} - connection := acpsdk.NewClientSideConnection(client, process.Stdin, process.Stdout) - connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) - turn.startFlusher(ctx) - - waitForExit := func() error { - waitCh := make(chan error, 1) - go func() { waitCh <- process.Wait() }() - timer := time.NewTimer(tool.stopTimeout) - defer timer.Stop() - select { - case waitErr := <-waitCh: - return waitErr - case <-timer.C: - if killErr := process.Kill(); killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { - klog.V(log.LogLevelDebug).InfoS("ACP process kill failed", "error", killErr) - } - return <-waitCh - } + attempt := &sessionAttempt{ + tool: tool, + ctx: ctx, + process: process, + connection: acpsdk.NewClientSideConnection(&client{turn: turn}, process.Stdin, process.Stdout), + turn: turn, + priorSessionID: priorSessionID, } + attempt.connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) + attempt.drainStderr() + attempt.turn.startFlusher(ctx) + return attempt +} - stop := func(cancel bool) error { - if cancel { - cancelCtx, cancelFunc := context.WithTimeout(context.Background(), tool.stopTimeout) - sessionID := turn.sessionID() - var cancelErr error - if sessionID != "" { - cancelErr = connection.Cancel(cancelCtx, acpsdk.CancelNotification{SessionId: acpsdk.SessionId(sessionID)}) - } - cancelFunc() - if cancelErr != nil { - klog.V(log.LogLevelDebug).InfoS("ACP session cancellation failed", "error", cancelErr) - } - waitCh := make(chan error, 1) - go func() { waitCh <- process.Wait() }() - // Closing stdin lets a cooperative ACP process finish after it has - // acknowledged cancellation. If it does not, kill it below. - _ = process.Stdin.Close() - timer := time.NewTimer(tool.stopTimeout) - defer timer.Stop() - select { - case waitErr := <-waitCh: - return waitErr - case <-timer.C: - if killErr := process.Kill(); killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { - klog.V(log.LogLevelDebug).InfoS("ACP process kill failed", "error", killErr) - } - return <-waitCh - } - } - // ACP agents terminate cleanly when stdin reaches EOF. Give that - // path a bounded opportunity before using a hard kill. - if err := process.Stdin.Close(); err != nil && !errors.Is(err, os.ErrClosed) { - klog.V(log.LogLevelDebug).InfoS("ACP stdin close failed", "error", err) +func (attempt *sessionAttempt) drainStderr() { + if attempt.process.Stderr == nil { + return + } + go func() { + if _, err := io.Copy(io.Discard, attempt.process.Stderr); err != nil && !errors.Is(err, io.ErrClosedPipe) { + klog.V(log.LogLevelDebug).InfoS("ACP stderr drain ended", "error", err) } - return waitForExit() + }() +} + +func (attempt *sessionAttempt) close() { + // The process is stopped explicitly during the run. This final guard + // handles setup failures and keeps test launchers from leaking children. + _ = attempt.process.Close() +} + +func (attempt *sessionAttempt) run(cwd, prompt string) error { + initialize, err := attempt.initialize() + if err != nil { + return attempt.fail(fmt.Errorf("acp initialize: %w", err), attempt.cancelled()) } + if initialize.ProtocolVersion != acpsdk.ProtocolVersionNumber { + return attempt.fail(fmt.Errorf("acp protocol version %d is unsupported", initialize.ProtocolVersion), false) + } + + details, err := attempt.openSession(cwd) + if err != nil { + return attempt.fail(err, attempt.cancelled()) + } + if err := attempt.configureSession(details); err != nil { + return attempt.fail(err, attempt.cancelled()) + } + if err := attempt.stopIfCancelled(); err != nil { + return err + } + + response, err := attempt.prompt(prompt, details.sessionID) + if err != nil { + return attempt.promptFailure(err) + } + attempt.finishTurn(response.Usage) + if err := attempt.turn.err(); err != nil { + return attempt.fail(err, attempt.cancelled()) + } + if err := attempt.stopIfCancelled(); err != nil { + return err + } + if err := attempt.stop(false); err != nil { + return fmt.Errorf("stop acp process: %w", err) + } + return promptResult(response.StopReason) +} - initialize, err := connection.Initialize(ctx, acpsdk.InitializeRequest{ +func (attempt *sessionAttempt) configureSession(details sessionDetails) error { + err := attempt.tool.setSessionConfig(attempt.ctx, attempt.connection, details.sessionID, details.modes, details.configOptions) + if err != nil && attempt.priorSessionID == "" { + attempt.tool.setSessionID("") + } + return err +} + +func (attempt *sessionAttempt) initialize() (acpsdk.InitializeResponse, error) { + return attempt.connection.Initialize(attempt.ctx, acpsdk.InitializeRequest{ ProtocolVersion: acpsdk.ProtocolVersionNumber, ClientInfo: &acpsdk.Implementation{ Name: "plural-agent-harness", @@ -148,103 +185,161 @@ func (tool *Tool) runAttempt(ctx context.Context, prompt string, options []exec. Auth: acpsdk.AuthCapabilities{}, }, }) - if err != nil { - _ = stop(ctx.Err() != nil) - return fmt.Errorf("ACP initialize: %w", err) - } - if initialize.ProtocolVersion != acpsdk.ProtocolVersionNumber { - _ = stop(false) - return fmt.Errorf("ACP protocol version %d is unsupported", initialize.ProtocolVersion) - } +} - tool.mu.RLock() - existingSession := tool.sessionID - tool.mu.RUnlock() - var modes *acpsdk.SessionModeState - var configOptions []acpsdk.SessionConfigOption +func (attempt *sessionAttempt) openSession(cwd string) (sessionDetails, error) { + existingSession := attempt.tool.sessionIDValue() if existingSession == "" { - created, createErr := connection.NewSession(ctx, acpsdk.NewSessionRequest{ - Cwd: cwd, - McpServers: []acpsdk.McpServer{}, - }) - if createErr != nil { - _ = stop(ctx.Err() != nil) - return fmt.Errorf("ACP session/new: %w", createErr) - } - if created.SessionId == "" { - _ = stop(ctx.Err() != nil) - return errors.New("ACP session/new returned an empty session id") - } - tool.setSessionID(string(created.SessionId)) - turn.setSessionID(string(created.SessionId)) - modes = created.Modes - configOptions = created.ConfigOptions - } else { - resumed, resumeErr := connection.ResumeSession(ctx, acpsdk.ResumeSessionRequest{ - Cwd: cwd, - McpServers: []acpsdk.McpServer{}, - SessionId: acpsdk.SessionId(existingSession), - }) - if resumeErr != nil { - _ = stop(ctx.Err() != nil) - return fmt.Errorf("ACP session/resume: %w", resumeErr) - } - modes = resumed.Modes - configOptions = resumed.ConfigOptions - turn.setSessionID(existingSession) + return attempt.createSession(cwd) } + return attempt.resumeSession(cwd, existingSession) +} - if err := tool.setSessionConfig(ctx, connection, turn.sessionID(), modes, configOptions); err != nil { - _ = stop(ctx.Err() != nil) - if priorSessionID == "" { - tool.setSessionID("") - } - return err +func (attempt *sessionAttempt) createSession(cwd string) (sessionDetails, error) { + created, err := attempt.connection.NewSession(attempt.ctx, acpsdk.NewSessionRequest{ + Cwd: cwd, + McpServers: []acpsdk.McpServer{}, + }) + if err != nil { + return sessionDetails{}, fmt.Errorf("acp session/new: %w", err) + } + if created.SessionId == "" { + return sessionDetails{}, errors.New("acp session/new returned an empty session id") } + sessionID := string(created.SessionId) + attempt.tool.setSessionID(sessionID) + attempt.turn.setSessionID(sessionID) + return sessionDetails{ + sessionID: sessionID, + modes: created.Modes, + configOptions: created.ConfigOptions, + }, nil +} - if ctx.Err() != nil { - _ = stop(true) - return context.Cause(ctx) +func (attempt *sessionAttempt) resumeSession(cwd, sessionID string) (sessionDetails, error) { + resumed, err := attempt.connection.ResumeSession(attempt.ctx, acpsdk.ResumeSessionRequest{ + Cwd: cwd, + McpServers: []acpsdk.McpServer{}, + SessionId: acpsdk.SessionId(sessionID), + }) + if err != nil { + return sessionDetails{}, fmt.Errorf("acp session/resume: %w", err) } - response, promptErr := connection.Prompt(ctx, acpsdk.PromptRequest{ - SessionId: acpsdk.SessionId(turn.sessionID()), + attempt.turn.setSessionID(sessionID) + return sessionDetails{ + sessionID: sessionID, + modes: resumed.Modes, + configOptions: resumed.ConfigOptions, + }, nil +} + +func (attempt *sessionAttempt) prompt(prompt, sessionID string) (acpsdk.PromptResponse, error) { + return attempt.connection.Prompt(attempt.ctx, acpsdk.PromptRequest{ + SessionId: acpsdk.SessionId(sessionID), Prompt: []acpsdk.ContentBlock{acpsdk.TextBlock(prompt)}, }) - if promptErr != nil { - cancelled := ctx.Err() != nil - _ = stop(cancelled) - if cancelled { - return context.Cause(ctx) - } - // Prompt has crossed the dispatch boundary. Its result is never - // replayed because the agent may have received it. - return fmt.Errorf("ACP session/prompt: %w", promptErr) +} + +func (attempt *sessionAttempt) finishTurn(usage *acpsdk.Usage) { + attempt.turn.stopFlusher() + attempt.turn.flushTools(true) + attempt.turn.emitAssistant(usage) +} + +func (attempt *sessionAttempt) promptFailure(err error) error { + cancelled := attempt.cancelled() + _ = attempt.stop(cancelled) + if cancelled { + return context.Cause(attempt.ctx) + } + // Prompt has crossed the dispatch boundary. Its result is never replayed + // because the agent may have received it. + return fmt.Errorf("acp session/prompt: %w", err) +} + +func (attempt *sessionAttempt) fail(err error, cancel bool) error { + _ = attempt.stop(cancel) + return err +} + +func (attempt *sessionAttempt) cancelled() bool { + return attempt.ctx.Err() != nil +} + +func (attempt *sessionAttempt) stopIfCancelled() error { + if !attempt.cancelled() { + return nil } + _ = attempt.stop(true) + return context.Cause(attempt.ctx) +} - turn.stopFlusher() - turn.flushTools(true) - turn.emitAssistant(response.Usage) - if turn.err() != nil { - _ = stop(ctx.Err() != nil) - return turn.err() +func (attempt *sessionAttempt) stop(cancel bool) error { + if cancel { + attempt.cancelSession() + // Closing stdin lets a cooperative ACP process finish after it has + // acknowledged cancellation. If it does not, kill it below. + _ = attempt.process.Stdin.Close() + return attempt.waitForExit() } - if ctx.Err() != nil { - _ = stop(true) - return context.Cause(ctx) + // ACP agents terminate cleanly when stdin reaches EOF. Give that path a + // bounded opportunity before using a hard kill. + if err := attempt.process.Stdin.Close(); err != nil && !errors.Is(err, os.ErrClosed) { + klog.V(log.LogLevelDebug).InfoS("ACP stdin close failed", "error", err) + } + return attempt.waitForExit() +} + +func (attempt *sessionAttempt) cancelSession() { + sessionID := attempt.turn.sessionID() + if sessionID == "" { + return + } + cancelCtx, cancel := context.WithTimeout(context.Background(), attempt.tool.stopTimeout) + err := attempt.connection.Cancel(cancelCtx, acpsdk.CancelNotification{SessionId: acpsdk.SessionId(sessionID)}) + cancel() + if err != nil { + klog.V(log.LogLevelDebug).InfoS("ACP session cancellation failed", "error", err) + } +} + +func (attempt *sessionAttempt) waitForExit() error { + waitCh := make(chan error, 1) + go func() { waitCh <- attempt.process.Wait() }() + timer := time.NewTimer(attempt.tool.stopTimeout) + defer timer.Stop() + select { + case waitErr := <-waitCh: + return waitErr + case <-timer.C: + return attempt.killAndWait(waitCh) } - if err := stop(false); err != nil { - return fmt.Errorf("stop ACP process: %w", err) +} + +func (attempt *sessionAttempt) killAndWait(waitCh <-chan error) error { + if err := attempt.process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + klog.V(log.LogLevelDebug).InfoS("ACP process kill failed", "error", err) } - switch response.StopReason { + return <-waitCh +} + +func (tool *Tool) sessionIDValue() string { + tool.mu.RLock() + defer tool.mu.RUnlock() + return tool.sessionID +} + +func promptResult(reason acpsdk.StopReason) error { + switch reason { case acpsdk.StopReasonEndTurn: return nil case acpsdk.StopReasonMaxTokens, acpsdk.StopReasonMaxTurnRequests, acpsdk.StopReasonRefusal, acpsdk.StopReasonCancelled: - return fmt.Errorf("ACP prompt stopped with reason %q", response.StopReason) + return fmt.Errorf("acp prompt stopped with reason %q", reason) default: - return fmt.Errorf("ACP prompt returned unexpected stop reason %q", response.StopReason) + return fmt.Errorf("acp prompt returned unexpected stop reason %q", reason) } } @@ -254,29 +349,38 @@ func (tool *Tool) setSessionConfig(ctx context.Context, connection *acpsdk.Clien model := tool.model tool.mu.RUnlock() - if model != "" { - if found, err := setConfigOption(ctx, connection, sessionID, options, "model", model); err != nil { - return err - } else if !found { - klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a model config option") - } + if err := setModelConfig(ctx, connection, sessionID, options, model); err != nil { + return err + } + return setModeConfig(ctx, connection, sessionID, modes, options, mode) +} + +func setModelConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, model string) error { + if model == "" { + return nil + } + found, err := setConfigOption(ctx, connection, sessionID, options, "model", model) + if err != nil { + return err } + if !found { + klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a model config option") + } + return nil +} +func setModeConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption, mode string) error { if mode == "" { return nil } - if modes != nil { - for _, available := range modes.AvailableModes { - if string(available.Id) == mode { - if _, err := connection.SetSessionMode(ctx, acpsdk.SetSessionModeRequest{ - SessionId: acpsdk.SessionId(sessionID), - ModeId: acpsdk.SessionModeId(mode), - }); err != nil { - return fmt.Errorf("ACP session/set_mode: %w", err) - } - return nil - } + if modeAvailable(modes, mode) { + if _, err := connection.SetSessionMode(ctx, acpsdk.SetSessionModeRequest{ + SessionId: acpsdk.SessionId(sessionID), + ModeId: acpsdk.SessionModeId(mode), + }); err != nil { + return fmt.Errorf("acp session/set_mode: %w", err) } + return nil } if found, err := setConfigOption(ctx, connection, sessionID, options, "mode", mode); err != nil { return err @@ -287,6 +391,18 @@ func (tool *Tool) setSessionConfig(ctx context.Context, connection *acpsdk.Clien return nil } +func modeAvailable(modes *acpsdk.SessionModeState, mode string) bool { + if modes == nil { + return false + } + for _, available := range modes.AvailableModes { + if string(available.Id) == mode { + return true + } + } + return false +} + func setConfigOption(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, configID, value string) (bool, error) { for _, option := range options { if option.Select == nil || string(option.Select.Id) != configID { @@ -297,7 +413,7 @@ func setConfigOption(ctx context.Context, connection *acpsdk.ClientSideConnectio return true, nil } if !configOptionContains(option.Select.Options, wanted) { - return true, fmt.Errorf("ACP %s %q is not advertised", configID, value) + return true, fmt.Errorf("acp %s %q is not advertised", configID, value) } if _, err := connection.SetSessionConfigOption(ctx, acpsdk.SetSessionConfigOptionRequest{ ValueId: &acpsdk.SetSessionConfigOptionValueId{ @@ -306,7 +422,7 @@ func setConfigOption(ctx context.Context, connection *acpsdk.ClientSideConnectio Value: wanted, }, }); err != nil { - return true, fmt.Errorf("ACP session/set_config_option %s: %w", configID, err) + return true, fmt.Errorf("acp session/set_config_option %s: %w", configID, err) } return true, nil } From 7420b8d869c93d0c25b2c8390103d7109a1be663 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 28 Aug 2026 14:20:47 +0200 Subject: [PATCH 06/46] chore(ci): update OPENCODE_VERSION in deployment workflow - Bump `opencode` version from 1.17.3 to 1.18.23 in deployment workflow file for compatibility. --- .github/workflows/deployment-operator-cd-agent-harness.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment-operator-cd-agent-harness.yaml b/.github/workflows/deployment-operator-cd-agent-harness.yaml index e65819729f..7ddc8e48a5 100644 --- a/.github/workflows/deployment-operator-cd-agent-harness.yaml +++ b/.github/workflows/deployment-operator-cd-agent-harness.yaml @@ -183,7 +183,7 @@ jobs: - name: gemini version: 0.44.1 - name: opencode - version: 1.17.3 + version: 1.18.23 - name: codex version: 0.104.0 - name: pi From f914b762e04fe0bf7bcd38e9d7ce5165b08a2333 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 28 Aug 2026 16:13:08 +0200 Subject: [PATCH 07/46] feat(acp): enhance tool output processing - Add test `TestToolOutputPrefersContentOverRawOutput` to prioritize content over raw output in tool messages. - Add test `TestToolOutputFallsBackToRawOutputWithoutContent` to ensure raw output is used when content is unavailable. - Simplify `normalizeUsage` function logic to use `max` for total token calculation. - Refactor `acp_turn.go` to improve tool output handling by checking and formatting content and raw output effectively. --- .../agentrun-harness/tool/acp/acp_mapping.go | 5 +- .../pkg/agentrun-harness/tool/acp/acp_test.go | 61 +++++++++++++++++++ .../pkg/agentrun-harness/tool/acp/acp_turn.go | 9 +-- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go index 0b1b385d0e..9c0a5f2194 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go @@ -99,10 +99,7 @@ func formatValue(value any) string { func normalizeUsage(providerUsage *acpsdk.Usage) (input, output, total, cached, thought int64) { input = int64(max(providerUsage.InputTokens, 0)) output = int64(max(providerUsage.OutputTokens, 0)) - total = int64(max(providerUsage.TotalTokens, 0)) - if total < input+output { - total = input + output - } + total = max(int64(max(providerUsage.TotalTokens, 0)), input+output) if providerUsage.CachedReadTokens != nil { cached += int64(max(*providerUsage.CachedReadTokens, 0)) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go index 0e9f4f5ba4..afa30ec018 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go @@ -330,6 +330,67 @@ func TestToolOutputThresholdAndTerminalFlush(t *testing.T) { } } +func TestToolOutputPrefersContentOverRawOutput(t *testing.T) { + state := &scriptedState{} + tool := testTool(t, state) + var outputs []string + tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { + if callID != "call-1" || message.Metadata == nil || message.Metadata.Tool == nil || message.Metadata.Tool.Output == nil { + return + } + outputs = append(outputs, *message.Metadata.Tool.Output) + }) + + turn := newTurn(tool, "session-1") + start := acpsdk.StartToolCall("call-1", "shell", acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress)) + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { + t.Fatal(err) + } + update := acpsdk.UpdateToolCall( + "call-1", + acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), + acpsdk.WithUpdateContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("display output"))}), + acpsdk.WithUpdateRawOutput(map[string]any{"result": "structured output"}), + ) + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: update}); err != nil { + t.Fatal(err) + } + + if len(outputs) != 2 || outputs[1] != "display output" { + t.Fatalf("outputs = %v, want initial and display output", outputs) + } +} + +func TestToolOutputFallsBackToRawOutputWithoutContent(t *testing.T) { + state := &scriptedState{} + tool := testTool(t, state) + var outputs []string + tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { + if callID != "call-1" || message.Metadata == nil || message.Metadata.Tool == nil || message.Metadata.Tool.Output == nil { + return + } + outputs = append(outputs, *message.Metadata.Tool.Output) + }) + + turn := newTurn(tool, "session-1") + start := acpsdk.StartToolCall("call-1", "shell", acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress)) + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { + t.Fatal(err) + } + update := acpsdk.UpdateToolCall( + "call-1", + acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), + acpsdk.WithUpdateRawOutput(map[string]any{"result": "structured output"}), + ) + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: update}); err != nil { + t.Fatal(err) + } + + if len(outputs) != 2 || outputs[1] != `{"result":"structured output"}` { + t.Fatalf("outputs = %v, want initial and structured JSON output", outputs) + } +} + func TestPromptStopReasonIsFailure(t *testing.T) { state := &scriptedState{stopReason: acpsdk.StopReasonRefusal} tool := testTool(t, state) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go index 03492a888b..3a70a11ef3 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go @@ -197,11 +197,12 @@ func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { if update.RawInput != nil { call.input = formatValue(update.RawInput) } - if output := contentOutput(update.Content); output != "" { - call.addOutput(output) + output := contentOutput(update.Content) + if output == "" && update.RawOutput != nil { + output = formatValue(update.RawOutput) } - if update.RawOutput != nil { - call.addOutput(formatValue(update.RawOutput)) + if output != "" { + call.addOutput(output) } terminal := false if update.Status != nil { From 0992f937883acd1160609e1924fb60f1847024f3 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Mon, 31 Aug 2026 14:40:50 +0200 Subject: [PATCH 08/46] refactor(tool): switch methods to pointer receivers - Update `ConfigureSystemPrompt` and `systemPromptInput` methods to use pointer receivers in `tool/v1/tool.go` - Modify `ConfigureSkills` and related methods to use pointer receivers in `v1/skills.go` - Change test functions in `acp_test.go` to test new tool behaviors - Simplify tool management logic by removing redundant flusher handlers in `acp.go` and `acp_turn.go` - Enhance cumulative cost tracking by introducing a new `RecordCumulativeCost` method --- .../pkg/agentrun-harness/tool/acp/acp.go | 76 +---- .../agentrun-harness/tool/acp/acp_session.go | 4 - .../pkg/agentrun-harness/tool/acp/acp_test.go | 288 ++++++++++++++++-- .../pkg/agentrun-harness/tool/acp/acp_turn.go | 142 +++------ .../pkg/agentrun-harness/tool/v1/skills.go | 6 +- .../pkg/agentrun-harness/tool/v1/tool.go | 8 +- .../pkg/agentrun-harness/usage/usage.go | 50 ++- .../pkg/agentrun-harness/usage/usage_test.go | 35 +++ 8 files changed, 397 insertions(+), 212 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go index dc853c0958..2a7a8d211e 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go @@ -18,14 +18,13 @@ import ( console "github.com/pluralsh/console/go/client" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" "github.com/pluralsh/console/go/deployment-operator/pkg/log" ) const ( - defaultFlushInterval = 5 * time.Second - defaultFlushBytes = 64 * 1024 - defaultStopTimeout = 2 * time.Second + defaultStopTimeout = 2 * time.Second ) // LaunchFunc starts one ACP agent process. A new process is started for every @@ -82,26 +81,6 @@ func WithModel(model string) Option { return func(tool *Tool) { tool.model = model } } -// WithToolOutputFlushInterval changes the interval at which dirty tool output -// is emitted. It is primarily useful for deterministic tests. -func WithToolOutputFlushInterval(interval time.Duration) Option { - return func(tool *Tool) { - if interval > 0 { - tool.flushInterval = interval - } - } -} - -// WithToolOutputFlushBytes changes the amount of newly received UTF-8 bytes -// that triggers an immediate tool output flush. -func WithToolOutputFlushBytes(size int) Option { - return func(tool *Tool) { - if size > 0 { - tool.flushBytes = size - } - } -} - // WithStopTimeout sets the bounded wait after session/cancel before the agent // process is killed. func WithStopTimeout(timeout time.Duration) Option { @@ -112,15 +91,6 @@ func WithStopTimeout(timeout time.Duration) Option { } } -// WithNow injects the clock used for progressive tool output flushing. -func WithNow(now func() time.Time) Option { - return func(tool *Tool) { - if now != nil { - tool.now = now - } - } -} - // Tool implements v1.Tool for an ACP-speaking provider. type Tool struct { toolv1.DefaultTool @@ -132,28 +102,24 @@ type Tool struct { providerName string mode string - flushInterval time.Duration - flushBytes int - stopTimeout time.Duration - now func() time.Time - model string + stopTimeout time.Duration + model string mu sync.RWMutex onMessage toolv1.MessageCallback sessionID string - costBase *float64 } // New creates a provider-neutral ACP tool. Provider adapters normally pass // WithLauncher, WithConfigure, WithBabysitConfigure, and WithExporter. func New(config toolv1.Config, options ...Option) *Tool { + if config.Usage == nil { + config.Usage = usage.New(nil) + } tool := &Tool{ - DefaultTool: toolv1.DefaultTool{Config: config}, - providerName: "acp", - flushInterval: defaultFlushInterval, - flushBytes: defaultFlushBytes, - stopTimeout: defaultStopTimeout, - now: time.Now, + DefaultTool: toolv1.DefaultTool{Config: config}, + providerName: "acp", + stopTimeout: defaultStopTimeout, } for _, option := range options { option(tool) @@ -288,26 +254,4 @@ func (tool *Tool) setSessionID(sessionID string) { tool.mu.Unlock() } -func (tool *Tool) recordCost(amount float64) float64 { - if amount < 0 { - amount = 0 - } - tool.mu.Lock() - defer tool.mu.Unlock() - if tool.costBase == nil { - tool.costBase = &amount - return amount - } - if amount < *tool.costBase { - *tool.costBase = amount - return 0 - } - delta := amount - *tool.costBase - *tool.costBase = amount - if delta < 0 { - return 0 - } - return delta -} - var _ toolv1.Tool = (*Tool)(nil) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go index aa27c939c6..686de1551f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go @@ -45,7 +45,6 @@ func (tool *Tool) runAttempt(ctx context.Context, prompt string, options []exec. return err } defer attempt.close() - defer attempt.turn.stopFlusher() return attempt.run(cwd, prompt) } @@ -103,7 +102,6 @@ func newSessionAttempt(tool *Tool, ctx context.Context, process *exec.StdioProce } attempt.connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) attempt.drainStderr() - attempt.turn.startFlusher(ctx) return attempt } @@ -241,8 +239,6 @@ func (attempt *sessionAttempt) prompt(prompt, sessionID string) (acpsdk.PromptRe } func (attempt *sessionAttempt) finishTurn(usage *acpsdk.Usage) { - attempt.turn.stopFlusher() - attempt.turn.flushTools(true) attempt.turn.emitAssistant(usage) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go index afa30ec018..acae706377 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go @@ -300,33 +300,253 @@ func TestRunUsesLifecycleOptionsOnlyForInitialPrompt(t *testing.T) { } } -func TestToolOutputThresholdAndTerminalFlush(t *testing.T) { +func TestToolOutputEmitsAccumulatedStdout(t *testing.T) { state := &scriptedState{} tool := testTool(t, state) - tool.flushBytes = 3 var outputs []string + tool.OnOutput(func(callID, stdout string) { + if callID == "call-1" { + outputs = append(outputs, stdout) + } + }) + + turn := newTurn(tool, "session-1") + start := acpsdk.StartToolCall( + "call-1", + "shell", + acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress), + acpsdk.WithStartContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("first"))}), + ) + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { + t.Fatal(err) + } + updates := []acpsdk.SessionNotification{ + {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("first\nsecond"))}))}, + {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), acpsdk.WithUpdateContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("first\nsecond\nfinal"))}))}, + } + for _, update := range updates { + if err := turn.handle(update); err != nil { + t.Fatal(err) + } + } + + want := []string{"first", "first\nsecond", "first\nsecond\nfinal"} + if len(outputs) != len(want) { + t.Fatalf("output callbacks = %v, want %v", outputs, want) + } + for i := range want { + if outputs[i] != want[i] { + t.Fatalf("output callback %d = %q, want %q", i, outputs[i], want[i]) + } + } +} + +func TestToolNonterminalMessagesTrackMeaningfulMetadataChanges(t *testing.T) { + state := &scriptedState{} + tool := testTool(t, state) + var messages []*console.AgentMessageAttributes tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { - if callID != "call-1" || message.Metadata == nil || message.Metadata.Tool == nil || message.Metadata.Tool.Output == nil { - return + if callID == "call-1" { + messages = append(messages, message) } - outputs = append(outputs, *message.Metadata.Tool.Output) }) + turn := newTurn(tool, "session-1") start := acpsdk.StartToolCall("call-1", "shell", acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress)) if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { t.Fatal(err) } - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawOutput("abc"))}); err != nil { + updates := []acpsdk.SessionNotification{ + {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawOutput("output-only"))}, + {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateTitle("renamed"))}, + {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateTitle("renamed"))}, + {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawInput(map[string]string{"command": "ls"}))}, + {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawInput(map[string]string{"command": "ls"}))}, + {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusPending))}, + {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusPending))}, + } + for _, update := range updates { + if err := turn.handle(update); err != nil { + t.Fatal(err) + } + } + + if len(messages) != 4 { + t.Fatalf("nonterminal messages = %d, want start plus three metadata changes", len(messages)) + } + if got := *messages[1].Metadata.Tool.Name; got != "renamed" { + t.Fatalf("title update = %q, want renamed", got) + } + if got := *messages[2].Metadata.Tool.Input; got != "{\"command\":\"ls\"}" { + t.Fatalf("input update = %q, want command input", got) + } + if got := *messages[3].Metadata.Tool.State; got != console.AgentMessageToolStatePending { + t.Fatalf("status update = %q, want pending", got) + } +} + +func TestToolOutputOnlyUpdatesDoNotRewriteMessage(t *testing.T) { + state := &scriptedState{} + tool := testTool(t, state) + var messages []*console.AgentMessageAttributes + var outputs []string + tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { + if callID == "call-1" { + messages = append(messages, message) + } + }) + tool.OnOutput(func(callID, stdout string) { + if callID == "call-1" { + outputs = append(outputs, stdout) + } + }) + + turn := newTurn(tool, "session-1") + start := acpsdk.StartToolCall("call-1", "shell", acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress)) + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { + t.Fatal(err) + } + for _, output := range []string{"first", "first\nsecond"} { + update := acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawOutput(output)) + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: update}); err != nil { + t.Fatal(err) + } + } + + if len(messages) != 1 { + t.Fatalf("output-only messages = %d, want start message only", len(messages)) + } + if len(outputs) != 2 || outputs[0] != "first" || outputs[1] != "first\nsecond" { + t.Fatalf("output snapshots = %v, want [first first\\nsecond]", outputs) + } +} + +func TestToolStartFallsBackToRawOutput(t *testing.T) { + state := &scriptedState{} + tool := testTool(t, state) + var messageOutput string + var streamedOutput string + tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { + if callID == "call-1" && message.Metadata != nil && message.Metadata.Tool != nil && message.Metadata.Tool.Output != nil { + messageOutput = *message.Metadata.Tool.Output + } + }) + tool.OnOutput(func(callID, stdout string) { + if callID == "call-1" { + streamedOutput = stdout + } + }) + + turn := newTurn(tool, "session-1") + start := acpsdk.StartToolCall( + "call-1", + "shell", + acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress), + acpsdk.WithStartRawOutput(map[string]string{"result": "structured output"}), + ) + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { + t.Fatal(err) + } + + want := "{\"result\":\"structured output\"}" + if messageOutput != want { + t.Fatalf("start message output = %q, want %q", messageOutput, want) + } + if streamedOutput != want { + t.Fatalf("start streamed output = %q, want %q", streamedOutput, want) + } +} + +func TestToolOutputCallbackOrdering(t *testing.T) { + state := &scriptedState{} + tool := testTool(t, state) + var events []string + tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { + state := "message" + if message.Metadata != nil && message.Metadata.Tool != nil && message.Metadata.Tool.State != nil { + state = string(*message.Metadata.Tool.State) + } + events = append(events, state+":"+callID) + }) + tool.OnOutput(func(callID, stdout string) { + events = append(events, "output:"+callID+":"+stdout) + }) + + turn := newTurn(tool, "session-1") + start := acpsdk.StartToolCall( + "call-1", + "shell", + acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress), + acpsdk.WithStartContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("start"))}), + ) + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { + t.Fatal(err) + } + terminal := acpsdk.UpdateToolCall( + "call-1", + acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), + acpsdk.WithUpdateContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("start\nterminal"))}), + ) + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: terminal}); err != nil { t.Fatal(err) } - if len(outputs) != 2 || outputs[1] != "abc" { - t.Fatalf("threshold outputs = %v, want initial and abc", outputs) + + want := []string{ + "RUNNING:call-1", + "output:call-1:start", + "output:call-1:start\nterminal", + "COMPLETED:call-1", + } + if len(events) != len(want) { + t.Fatalf("callback events = %v, want %v", events, want) } - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), acpsdk.WithUpdateRawOutput("done"))}); err != nil { + for i := range want { + if events[i] != want[i] { + t.Fatalf("callback event %d = %q, want %q", i, events[i], want[i]) + } + } +} + +func TestToolOutputSkipsNonMonotonicSnapshots(t *testing.T) { + state := &scriptedState{} + tool := testTool(t, state) + var outputs []string + var terminalOutput string + tool.OnOutput(func(callID, stdout string) { + if callID == "call-1" { + outputs = append(outputs, stdout) + } + }) + tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { + if callID != "call-1" || message.Metadata == nil || message.Metadata.Tool == nil || message.Metadata.Tool.State == nil || *message.Metadata.Tool.State != console.AgentMessageToolStateCompleted { + return + } + if message.Metadata.Tool.Output != nil { + terminalOutput = *message.Metadata.Tool.Output + } + }) + + turn := newTurn(tool, "session-1") + start := acpsdk.StartToolCall("call-1", "shell", acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress)) + if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { t.Fatal(err) } - if len(outputs) != 3 || outputs[2] != "done" { - t.Fatalf("terminal outputs = %v, want final done snapshot", outputs) + updates := []acpsdk.SessionNotification{ + {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawOutput("abc"))}, + {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawOutput("done"))}, + {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), acpsdk.WithUpdateRawOutput("ab"))}, + } + for _, update := range updates { + if err := turn.handle(update); err != nil { + t.Fatal(err) + } + } + + if len(outputs) != 1 || outputs[0] != "abc" { + t.Fatalf("output callbacks = %v, want [abc]", outputs) + } + if terminalOutput != "ab" { + t.Fatalf("terminal metadata output = %q, want ab", terminalOutput) } } @@ -399,22 +619,34 @@ func TestPromptStopReasonIsFailure(t *testing.T) { } } -func TestRecordCostHandlesCumulativeUpdatesAndReset(t *testing.T) { - tool := &Tool{} - for _, test := range []struct { - name string - amount float64 - want float64 - }{ - {name: "initial", amount: 4, want: 4}, - {name: "increase", amount: 7, want: 3}, - {name: "provider reset", amount: 2, want: 0}, - {name: "after reset", amount: 3, want: 1}, - } { - t.Run(test.name, func(t *testing.T) { - if got := tool.recordCost(test.amount); got != test.want { - t.Fatalf("recordCost(%v) = %v, want %v", test.amount, got, test.want) - } - }) +func TestNewInitializesUsageForCumulativeCostUpdates(t *testing.T) { + tool := New(toolv1.Config{}) + if tool.Config.Usage == nil { + t.Fatal("ACP tool did not initialize usage") + } + + var message *console.AgentMessageAttributes + tool.OnMessage(func(got *console.AgentMessageAttributes, _ string) { + message = got + }) + turn := newTurn(tool, "session-1") + turn.usageUpdate(&acpsdk.SessionUsageUpdate{Cost: &acpsdk.Cost{Amount: 4}}) + turn.emitAssistant(nil) + + attrs := tool.Config.Usage.Attributes() + if attrs == nil || attrs.TotalCost == nil || *attrs.TotalCost != 4 { + t.Fatalf("recorded total cost = %v, want 4", attrs) + } + if message == nil || message.Cost == nil || message.Cost.Total != 4 { + t.Fatalf("assistant cost = %v, want 4", message) + } +} + +func TestNewPreservesProvidedUsage(t *testing.T) { + provided := usage.New(nil) + tool := New(toolv1.Config{Usage: provided}) + + if tool.Config.Usage != provided { + t.Fatal("ACP tool replaced the provided usage recorder") } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go index 3a70a11ef3..e227684069 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go @@ -1,12 +1,10 @@ package acp import ( - "context" "errors" "fmt" "strings" "sync" - "time" acpsdk "github.com/coder/acp-go-sdk" "k8s.io/klog/v2" @@ -25,19 +23,14 @@ type turnState struct { reasoning strings.Builder tools map[string]*toolCall cost float64 - stopFlush chan struct{} - flushDone chan struct{} } type toolCall struct { - id string - name string - input string - output string - state console.AgentMessageToolState - dirty bool - pendingBytes int - lastFlush time.Time + id string + name string + input string + output string + state console.AgentMessageToolState } func newTurn(tool *Tool, sessionID string) *turnState { @@ -45,38 +38,9 @@ func newTurn(tool *Tool, sessionID string) *turnState { tool: tool, sessionIDValue: sessionID, tools: make(map[string]*toolCall), - stopFlush: make(chan struct{}), - flushDone: make(chan struct{}), } } -func (turn *turnState) startFlusher(ctx context.Context) { - go func() { - defer close(turn.flushDone) - ticker := time.NewTicker(turn.tool.flushInterval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - turn.flushTools(false) - case <-turn.stopFlush: - return - case <-ctx.Done(): - return - } - } - }() -} - -func (turn *turnState) stopFlusher() { - select { - case <-turn.stopFlush: - default: - close(turn.stopFlush) - } - <-turn.flushDone -} - func (turn *turnState) sessionID() string { turn.mu.Lock() defer turn.mu.Unlock() @@ -167,19 +131,21 @@ func (turn *turnState) startTool(update *acpsdk.SessionUpdateToolCall) error { return turn.fail(err.Error()) } call := &toolCall{ - id: id, - name: toolName(update.Title, update.Kind), - input: formatValue(update.RawInput), - state: state, - lastFlush: turn.tool.now(), + id: id, + name: toolName(update.Title, update.Kind), + input: formatValue(update.RawInput), + state: state, } call.output = contentOutput(update.Content) - call.dirty = true + if call.output == "" && update.RawOutput != nil { + call.output = formatValue(update.RawOutput) + } turn.tools[id] = call message := call.message() - call.dirty = false + output := call.output turn.mu.Unlock() turn.tool.emit(message, id) + turn.tool.EmitOutput(id, output) return nil } @@ -191,12 +157,21 @@ func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { turn.mu.Unlock() return turn.fail(fmt.Sprintf("ACP tool call update %q arrived before tool_call", id)) } + metadataChanged := false if update.Title != nil { - call.name = *update.Title + if call.name != *update.Title { + metadataChanged = true + call.name = *update.Title + } } if update.RawInput != nil { - call.input = formatValue(update.RawInput) + input := formatValue(update.RawInput) + if call.input != input { + metadataChanged = true + call.input = input + } } + previousOutput := call.output output := contentOutput(update.Content) if output == "" && update.RawOutput != nil { output = formatValue(update.RawOutput) @@ -204,6 +179,8 @@ func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { if output != "" { call.addOutput(output) } + accumulatedOutput := call.output + streamOutput := accumulatedOutput != previousOutput && (previousOutput == "" || strings.HasPrefix(accumulatedOutput, previousOutput)) terminal := false if update.Status != nil { state, err := toolState(*update.Status) @@ -211,52 +188,30 @@ func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { turn.mu.Unlock() return turn.fail(err.Error()) } - call.state = state + if call.state != state { + metadataChanged = true + call.state = state + } terminal = state == console.AgentMessageToolStateCompleted || state == console.AgentMessageToolStateError } message := (*console.AgentMessageAttributes)(nil) if terminal { message = call.message() delete(turn.tools, id) - call.dirty = false - } else if call.dirty && call.pendingBytes >= turn.tool.flushBytes { + } else if metadataChanged { message = call.message() - call.dirty = false - call.pendingBytes = 0 - call.lastFlush = turn.tool.now() } turn.mu.Unlock() + if terminal && streamOutput { + turn.tool.EmitOutput(id, accumulatedOutput) + } if message != nil { turn.tool.emit(message, id) } - return nil -} - -func (turn *turnState) flushTools(force bool) { - turn.mu.Lock() - type pendingMessage struct { - id string - message *console.AgentMessageAttributes - } - messages := make([]pendingMessage, 0) - now := turn.tool.now() - for id, call := range turn.tools { - if !call.dirty { - continue - } - if !force && now.Sub(call.lastFlush) < turn.tool.flushInterval { - continue - } - messages = append(messages, pendingMessage{id: id, message: call.message()}) - call.dirty = false - call.pendingBytes = 0 - call.lastFlush = now - _ = id - } - turn.mu.Unlock() - for _, pending := range messages { - turn.tool.emit(pending.message, pending.id) + if !terminal && streamOutput { + turn.tool.EmitOutput(id, accumulatedOutput) } + return nil } func (turn *turnState) emitAssistant(responseUsage *acpsdk.Usage) { @@ -309,15 +264,13 @@ func (turn *turnState) usageUpdate(update *acpsdk.SessionUsageUpdate) { klog.V(log.LogLevelDebug).InfoS("ACP usage update omitted optional cost") return } - delta := turn.tool.recordCost(update.Cost.Amount) - if delta > 0 { - turn.mu.Lock() - turn.cost += delta - turn.mu.Unlock() - if turn.tool.Config.Usage != nil { - turn.tool.Config.Usage.RecordUsage(usage.Record{TotalCost: delta}) - } + delta := turn.tool.Config.Usage.RecordCumulativeCost(turn.sessionID(), update.Cost.Amount) + if delta <= 0 { + return } + turn.mu.Lock() + turn.cost += delta + turn.mu.Unlock() } func (turn *turnState) fail(message string) error { @@ -330,12 +283,5 @@ func (call *toolCall) addOutput(output string) { if output == "" || output == call.output { return } - previous := call.output call.output = output - if strings.HasPrefix(output, previous) { - call.pendingBytes += len(output) - len(previous) - } else { - call.pendingBytes += len(output) - } - call.dirty = true } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/v1/skills.go b/go/deployment-operator/pkg/agentrun-harness/tool/v1/skills.go index 898c12b471..78ac12f8d8 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/v1/skills.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/v1/skills.go @@ -24,7 +24,7 @@ type skillFrontmatter struct { } // ConfigureSkills sideloads agent-run skills into the provider-specific skills directory. -func (in DefaultTool) ConfigureSkills(skillRoot string) error { +func (in *DefaultTool) ConfigureSkills(skillRoot string) error { if in.Config.Run == nil || len(in.Config.Run.Skills) == 0 { return nil } @@ -62,7 +62,7 @@ func (in DefaultTool) ConfigureSkills(skillRoot string) error { return nil } -func (in DefaultTool) renderSkill(name string, skill agentrunv1.AgentSkill) (string, error) { +func (in *DefaultTool) renderSkill(name string, skill agentrunv1.AgentSkill) (string, error) { description := lo.CoalesceOrEmpty( strings.TrimSpace(lo.FromPtr(skill.Description)), fmt.Sprintf("Plural workbench skill from agent run %s", in.Config.Run.ID), @@ -82,7 +82,7 @@ func (in DefaultTool) renderSkill(name string, skill agentrunv1.AgentSkill) (str ), nil } -func (in DefaultTool) skillOutputPath(root, name string) (string, error) { +func (in *DefaultTool) skillOutputPath(root, name string) (string, error) { cleanRoot, err := filepath.Abs(root) if err != nil { return "", fmt.Errorf("failed resolving skill root %q: %w", root, err) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/v1/tool.go b/go/deployment-operator/pkg/agentrun-harness/tool/v1/tool.go index d2c5efddc6..a46217027f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/v1/tool.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/v1/tool.go @@ -32,7 +32,7 @@ const ( // ConfigureSystemPrompt prepares system prompt/context files for the provider and puts them in the required directory // for the agent CLI to read during the run. -func (in DefaultTool) ConfigureSystemPrompt(runtime console.AgentRuntimeType) error { +func (in *DefaultTool) ConfigureSystemPrompt(runtime console.AgentRuntimeType) error { providerDir := "" switch runtime { case console.AgentRuntimeTypeClaude: @@ -70,7 +70,7 @@ func (in DefaultTool) ConfigureSystemPrompt(runtime console.AgentRuntimeType) er return nil } -func (in DefaultTool) ConfigureSystemPromptForBabysitRun(runtime console.AgentRuntimeType) error { +func (in *DefaultTool) ConfigureSystemPromptForBabysitRun(runtime console.AgentRuntimeType) error { providerDir := "" switch runtime { case console.AgentRuntimeTypeClaude: @@ -100,7 +100,7 @@ func (in DefaultTool) ConfigureSystemPromptForBabysitRun(runtime console.AgentRu return nil } -func (in DefaultTool) systemPromptInput() *SystemPromptTemplateInput { +func (in *DefaultTool) systemPromptInput() *SystemPromptTemplateInput { branch := "" if in.Config.Run.Branch != nil { branch = *in.Config.Run.Branch @@ -119,7 +119,7 @@ func (in DefaultTool) systemPromptInput() *SystemPromptTemplateInput { } } -func (in DefaultTool) BuildUploadArtifacts(ctx context.Context, opts artifacts.BuildArtifactsOptions) (*artifacts.UploadArtifacts, error) { +func (in *DefaultTool) BuildUploadArtifacts(ctx context.Context, opts artifacts.BuildArtifactsOptions) (*artifacts.UploadArtifacts, error) { return artifacts.NewUploadArtifactBuilder(artifacts.Config{ WorkDir: in.Config.WorkDir, RepositoryDir: in.Config.RepositoryDir, diff --git a/go/deployment-operator/pkg/agentrun-harness/usage/usage.go b/go/deployment-operator/pkg/agentrun-harness/usage/usage.go index fbe2b3b070..1ef0d10a7e 100644 --- a/go/deployment-operator/pkg/agentrun-harness/usage/usage.go +++ b/go/deployment-operator/pkg/agentrun-harness/usage/usage.go @@ -4,7 +4,6 @@ import ( "sync" console "github.com/pluralsh/console/go/client" - "github.com/samber/lo" ) // Usage is the central run-level token and cost accumulator for agent harnesses. @@ -19,6 +18,7 @@ type Usage struct { inputCost float64 outputCost float64 totalCost float64 + cumulativeCosts map[string]float64 } // Record contains a single provider usage event, normalized to the Console schema. @@ -86,6 +86,38 @@ func (u *Usage) RecordUsage(record Record) { u.mu.Unlock() } +// RecordCumulativeCost records the positive change from the previous cost +// snapshot for scope and returns that change. The first snapshot for a scope +// is measured from zero. A lower snapshot resets the scope baseline without +// recording a cost. +func (u *Usage) RecordCumulativeCost(scope string, cumulative float64) float64 { + if u == nil { + return 0 + } + + if cumulative < 0 { + cumulative = 0 + } + + u.mu.Lock() + defer u.mu.Unlock() + if u.cumulativeCosts == nil { + u.cumulativeCosts = make(map[string]float64) + } + previous, exists := u.cumulativeCosts[scope] + u.cumulativeCosts[scope] = cumulative + if exists && cumulative < previous { + return 0 + } + + delta := cumulative - previous + if delta > 0 { + u.totalCost += delta + return delta + } + return 0 +} + func (u *Usage) Attributes() *console.AiUsageAttributes { if u == nil { return nil @@ -102,14 +134,14 @@ func (u *Usage) attributesLocked() *console.AiUsageAttributes { } return &console.AiUsageAttributes{ - InputTokens: lo.ToPtr(u.inputTokens), - OutputTokens: lo.ToPtr(u.outputTokens), - TotalTokens: lo.ToPtr(u.totalTokens), - CachedTokens: lo.ToPtr(u.cachedTokens), - ReasoningTokens: lo.ToPtr(u.reasoningTokens), - InputCost: lo.ToPtr(u.inputCost), - OutputCost: lo.ToPtr(u.outputCost), - TotalCost: lo.ToPtr(u.totalCost), + InputTokens: new(u.inputTokens), + OutputTokens: new(u.outputTokens), + TotalTokens: new(u.totalTokens), + CachedTokens: new(u.cachedTokens), + ReasoningTokens: new(u.reasoningTokens), + InputCost: new(u.inputCost), + OutputCost: new(u.outputCost), + TotalCost: new(u.totalCost), } } diff --git a/go/deployment-operator/pkg/agentrun-harness/usage/usage_test.go b/go/deployment-operator/pkg/agentrun-harness/usage/usage_test.go index 394374bbe0..2d8a65099b 100644 --- a/go/deployment-operator/pkg/agentrun-harness/usage/usage_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/usage/usage_test.go @@ -49,3 +49,38 @@ func TestNewPreservesExistingUsage(t *testing.T) { require.Equal(t, int64(10), *attrs.InputTokens) require.Equal(t, 0.5, *attrs.TotalCost) } + +func TestRecordCumulativeCostTracksScopesAndResets(t *testing.T) { + u := New(nil) + for _, test := range []struct { + name string + scope string + cumulative float64 + wantDelta float64 + }{ + {name: "initial", scope: "session-1", cumulative: 4, wantDelta: 4}, + {name: "increase", scope: "session-1", cumulative: 7, wantDelta: 3}, + {name: "independent scope", scope: "session-2", cumulative: 2, wantDelta: 2}, + {name: "provider reset", scope: "session-1", cumulative: 2, wantDelta: 0}, + {name: "after reset", scope: "session-1", cumulative: 3, wantDelta: 1}, + {name: "negative snapshot", scope: "session-2", cumulative: -1, wantDelta: 0}, + } { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.wantDelta, u.RecordCumulativeCost(test.scope, test.cumulative)) + }) + } + + attrs := u.Attributes() + require.NotNil(t, attrs) + require.Equal(t, 10.0, *attrs.TotalCost) +} + +func TestRecordCumulativeCostDoesNotUseExistingTotal(t *testing.T) { + existingTotal := 10.0 + u := New(&console.AgentRunUsage{TotalCost: &existingTotal}) + + require.Equal(t, 4.0, u.RecordCumulativeCost("session-1", 4)) + attrs := u.Attributes() + require.NotNil(t, attrs) + require.Equal(t, 14.0, *attrs.TotalCost) +} From 70b0b0fa5253f42ca811ab748bbff8be73fcd70c Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Mon, 31 Aug 2026 15:57:27 +0200 Subject: [PATCH 09/46] feat(acp): improve session handling and tests - Introduced `newSessionUpdates` to handle pre-response session updates - Enhanced `NewSession` to process updates using `SessionUpdate` - Added test `TestRunPromptHandlesUpdatesSentBeforeNewSessionResponse` to verify update handling order - Added test `TestRunPromptRejectsMismatchedInitialSessionUpdate` to ensure proper session update matching - Added test `TestSessionUpdateRejectsEmptySessionIDBeforeBinding` to enforce session ID validation - Refactored session config functions into methods: `setSessionConfig`, `setModelConfig`, `setModeConfig` - Introduced `startAttempt` for clearer session initiation logic - Created `validate` method for comprehensive tool configuration checks - Updated error messages for consistency with lowecase conventions - Reorganized `toolCall` and `toolUpdateEvents` to streamline tool communication logic --- .../pkg/agentrun-harness/tool/acp/acp.go | 164 ++++++++++- .../agentrun-harness/tool/acp/acp_client.go | 103 ++++--- .../agentrun-harness/tool/acp/acp_mapping.go | 109 +++----- .../agentrun-harness/tool/acp/acp_session.go | 188 ++----------- .../pkg/agentrun-harness/tool/acp/acp_test.go | 86 +++++- .../pkg/agentrun-harness/tool/acp/acp_turn.go | 254 +++++++++++------- .../pkg/agentrun-harness/tool/acp/common.go | 20 ++ 7 files changed, 538 insertions(+), 386 deletions(-) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/common.go diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go index 2a7a8d211e..44f92a589d 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go @@ -13,6 +13,7 @@ import ( "sync" "time" + acpsdk "github.com/coder/acp-go-sdk" "k8s.io/klog/v2" console "github.com/pluralsh/console/go/client" @@ -91,6 +92,8 @@ func WithStopTimeout(timeout time.Duration) Option { } } +var _ toolv1.Tool = (*Tool)(nil) + // Tool implements v1.Tool for an ACP-speaking provider. type Tool struct { toolv1.DefaultTool @@ -161,7 +164,7 @@ func (tool *Tool) BabysitRun(ctx context.Context, babysit *toolv1.BabysitContext // Configure configures the provider adapter. func (tool *Tool) Configure(consoleURL, consoleToken string) error { if tool.configure == nil { - return errors.New("ACP configure function is not set") + return errors.New("acp configure function is not set") } return tool.configure(consoleURL, consoleToken) } @@ -192,19 +195,19 @@ func (tool *Tool) UploadArtifacts(ctx context.Context) (*artifacts.UploadArtifac // Kept in a small adapter method so provider-specific export remains outside // the protocol implementation. if tool.export == nil { - return nil, errors.New("ACP session exporter is not set") + return nil, errors.New("acp session exporter is not set") } tool.mu.RLock() sessionID := tool.sessionID providerName := tool.providerName tool.mu.RUnlock() if sessionID == "" { - return nil, errors.New("ACP session id is not set") + return nil, errors.New("acp session id is not set") } sourcePath, err := os.MkdirTemp(tool.Config.WorkDir, "acp-session-export-*") if err != nil { - return nil, fmt.Errorf("create ACP session export dir: %w", err) + return nil, fmt.Errorf("create acp session export dir: %w", err) } defer os.RemoveAll(sourcePath) @@ -222,6 +225,29 @@ func (tool *Tool) UploadArtifacts(ctx context.Context) (*artifacts.UploadArtifac }) } +func (tool *Tool) startAttempt(ctx context.Context, options []exec.Option) (*sessionAttempt, error) { + tool.mu.RLock() + launch := tool.launch + priorSessionID := tool.sessionID + tool.mu.RUnlock() + if launch == nil { + return nil, errors.New("acp launcher is not set") + } + + process, err := launch(ctx, options) + if err != nil { + return nil, err + } + if process == nil || process.Stdin == nil || process.Stdout == nil { + if process != nil { + _ = process.Stop() + _ = process.Wait() + } + return nil, errors.New("acp launcher returned an incomplete stdio process") + } + return newSessionAttempt(tool, ctx, process, priorSessionID), nil +} + func (tool *Tool) reportError(err error) { if err == nil || tool.Config.ErrorChan == nil { return @@ -242,7 +268,7 @@ func (tool *Tool) emit(message *console.AgentMessageAttributes, callID string) { } defer func() { if recovered := recover(); recovered != nil { - klog.ErrorS(fmt.Errorf("panic in ACP message callback: %v", recovered), "ACP message callback panicked") + klog.ErrorS(fmt.Errorf("panic in acp message callback: %v", recovered), "ACP message callback panicked") } }() callback(message, callID) @@ -254,4 +280,130 @@ func (tool *Tool) setSessionID(sessionID string) { tool.mu.Unlock() } -var _ toolv1.Tool = (*Tool)(nil) +func (tool *Tool) sessionIDValue() string { + tool.mu.RLock() + defer tool.mu.RUnlock() + return tool.sessionID +} + +func (tool *Tool) setSessionConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption) error { + tool.mu.RLock() + mode := tool.mode + model := tool.model + tool.mu.RUnlock() + + if err := tool.setModelConfig(ctx, connection, sessionID, options, model); err != nil { + return err + } + return tool.setModeConfig(ctx, connection, sessionID, modes, options, mode) +} + +func (tool *Tool) setModelConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, model string) error { + if model == "" { + return nil + } + found, err := tool.setConfigOption(ctx, connection, sessionID, options, "model", model) + if err != nil { + return err + } + if !found { + klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a model config option") + } + return nil +} + +func (tool *Tool) setModeConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption, mode string) error { + if mode == "" { + return nil + } + if tool.modeAvailable(modes, mode) { + if _, err := connection.SetSessionMode(ctx, acpsdk.SetSessionModeRequest{ + SessionId: acpsdk.SessionId(sessionID), + ModeId: acpsdk.SessionModeId(mode), + }); err != nil { + return fmt.Errorf("acp session/set_mode: %w", err) + } + return nil + } + if found, err := tool.setConfigOption(ctx, connection, sessionID, options, "mode", mode); err != nil { + return err + } else if found { + return nil + } + klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a mode config option", "mode", mode) + return nil +} + +func (tool *Tool) modeAvailable(modes *acpsdk.SessionModeState, mode string) bool { + if modes == nil { + return false + } + for _, available := range modes.AvailableModes { + if string(available.Id) == mode { + return true + } + } + return false +} + +func (tool *Tool) setConfigOption(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, configID, value string) (bool, error) { + for _, option := range options { + if option.Select == nil || string(option.Select.Id) != configID { + continue + } + wanted := acpsdk.SessionConfigValueId(value) + if option.Select.CurrentValue == wanted { + return true, nil + } + if !tool.configOptionContains(option.Select.Options, wanted) { + return true, fmt.Errorf("acp %s %q is not advertised", configID, value) + } + if _, err := connection.SetSessionConfigOption(ctx, acpsdk.SetSessionConfigOptionRequest{ + ValueId: &acpsdk.SetSessionConfigOptionValueId{ + ConfigId: option.Select.Id, + SessionId: acpsdk.SessionId(sessionID), + Value: wanted, + }, + }); err != nil { + return true, fmt.Errorf("acp session/set_config_option %s: %w", configID, err) + } + return true, nil + } + return false, nil +} + +func (tool *Tool) configOptionContains(options acpsdk.SessionConfigSelectOptions, wanted acpsdk.SessionConfigValueId) bool { + if options.Ungrouped != nil { + for _, option := range *options.Ungrouped { + if option.Value == wanted { + return true + } + } + } + if options.Grouped != nil { + for _, group := range *options.Grouped { + for _, option := range group.Options { + if option.Value == wanted { + return true + } + } + } + } + return false +} + +func (tool *Tool) validate() error { + if tool.Config.Run == nil { + return errors.New("agent run is not set") + } + if tool.Config.RepositoryDir == "" { + return errors.New("repository directory is not set") + } + if tool.Config.WorkDir == "" { + return errors.New("work directory is not set") + } + if tool.Config.ErrorChan == nil { + return errors.New("error channel is not set") + } + return nil +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go index aa26a3dccf..3b0641cc4f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go @@ -15,6 +15,8 @@ import ( const maxTextFileBytes = 16 << 20 +var _ acpsdk.Client = (*client)(nil) + type client struct { turn *turnState } @@ -26,59 +28,90 @@ func (client *client) ReadTextFile(ctx context.Context, request acpsdk.ReadTextF if err := ctx.Err(); err != nil { return acpsdk.ReadTextFileResponse{}, err } - if !filepath.IsAbs(request.Path) { - return acpsdk.ReadTextFileResponse{}, fmt.Errorf("ACP filesystem path must be absolute: %q", request.Path) - } - file, err := os.Open(request.Path) + file, err := client.openTextFile(request.Path) if err != nil { - return acpsdk.ReadTextFileResponse{}, fmt.Errorf("read %s: %w", request.Path, err) + return acpsdk.ReadTextFileResponse{}, err } defer file.Close() + + reader := bufio.NewReader(io.LimitReader(&contextReader{ctx: ctx, reader: file}, maxTextFileBytes+1)) + exhausted, err := client.skipTextFileLines(reader, request.Line, request.Path) + if err != nil { + return acpsdk.ReadTextFileResponse{}, err + } + if exhausted { + return acpsdk.ReadTextFileResponse{}, nil + } + content, err := client.readTextFileContent(reader, request.Path, request.Limit) + if err != nil { + return acpsdk.ReadTextFileResponse{}, err + } + return acpsdk.ReadTextFileResponse{Content: content}, nil +} + +func (client *client) openTextFile(path string) (*os.File, error) { + if !filepath.IsAbs(path) { + return nil, fmt.Errorf("acp filesystem path must be absolute: %q", path) + } + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } info, err := file.Stat() if err != nil { - return acpsdk.ReadTextFileResponse{}, fmt.Errorf("stat %s: %w", request.Path, err) + _ = file.Close() + return nil, fmt.Errorf("stat %s: %w", path, err) } if !info.Mode().IsRegular() { - return acpsdk.ReadTextFileResponse{}, fmt.Errorf("ACP filesystem path is not a regular file: %q", request.Path) + _ = file.Close() + return nil, fmt.Errorf("acp filesystem path is not a regular file: %q", path) } if info.Size() > maxTextFileBytes { - return acpsdk.ReadTextFileResponse{}, fmt.Errorf("ACP filesystem file exceeds %d-byte read limit: %q", maxTextFileBytes, request.Path) + _ = file.Close() + return nil, fmt.Errorf("acp filesystem file exceeds %d-byte read limit: %q", maxTextFileBytes, path) } + return file, nil +} - reader := bufio.NewReader(io.LimitReader(&contextReader{ctx: ctx, reader: file}, maxTextFileBytes+1)) - if request.Line != nil { - for line := 1; line < max(*request.Line, 1); line++ { - if _, readErr := reader.ReadString('\n'); readErr != nil { - if errors.Is(readErr, io.EOF) { - return acpsdk.ReadTextFileResponse{}, nil - } - return acpsdk.ReadTextFileResponse{}, fmt.Errorf("read %s: %w", request.Path, readErr) +func (client *client) skipTextFileLines(reader *bufio.Reader, line *int, path string) (bool, error) { + if line == nil { + return false, nil + } + for current := 1; current < max(*line, 1); current++ { + if _, err := reader.ReadString('\n'); err != nil { + if errors.Is(err, io.EOF) { + return true, nil } + return false, fmt.Errorf("read %s: %w", path, err) } } - if request.Limit == nil || *request.Limit <= 0 { - content, readErr := io.ReadAll(reader) - if readErr != nil { - return acpsdk.ReadTextFileResponse{}, fmt.Errorf("read %s: %w", request.Path, readErr) + return false, nil +} + +func (client *client) readTextFileContent(reader *bufio.Reader, path string, limit *int) (string, error) { + if limit == nil || *limit <= 0 { + content, err := io.ReadAll(reader) + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) } if len(content) > maxTextFileBytes { - return acpsdk.ReadTextFileResponse{}, fmt.Errorf("ACP filesystem file exceeds %d-byte read limit: %q", maxTextFileBytes, request.Path) + return "", fmt.Errorf("acp filesystem file exceeds %d-byte read limit: %q", maxTextFileBytes, path) } - return acpsdk.ReadTextFileResponse{Content: string(content)}, nil + return string(content), nil } - lines := make([]string, 0, min(*request.Limit, 1024)) - for len(lines) < *request.Limit { - line, readErr := reader.ReadString('\n') + lines := make([]string, 0, min(*limit, 1024)) + for len(lines) < *limit { + line, err := reader.ReadString('\n') lines = append(lines, strings.TrimSuffix(line, "\n")) - if readErr != nil { - if errors.Is(readErr, io.EOF) { + if err != nil { + if errors.Is(err, io.EOF) { break } - return acpsdk.ReadTextFileResponse{}, fmt.Errorf("read %s: %w", request.Path, readErr) + return "", fmt.Errorf("read %s: %w", path, err) } } - return acpsdk.ReadTextFileResponse{Content: strings.Join(lines, "\n")}, nil + return strings.Join(lines, "\n"), nil } type contextReader struct { @@ -98,7 +131,7 @@ func (client *client) WriteTextFile(_ context.Context, request acpsdk.WriteTextF return acpsdk.WriteTextFileResponse{}, err } if !filepath.IsAbs(request.Path) { - return acpsdk.WriteTextFileResponse{}, fmt.Errorf("ACP filesystem path must be absolute: %q", request.Path) + return acpsdk.WriteTextFileResponse{}, fmt.Errorf("acp filesystem path must be absolute: %q", request.Path) } if err := os.MkdirAll(filepath.Dir(request.Path), 0o755); err != nil { return acpsdk.WriteTextFileResponse{}, fmt.Errorf("mkdir %s: %w", filepath.Dir(request.Path), err) @@ -110,7 +143,7 @@ func (client *client) WriteTextFile(_ context.Context, request acpsdk.WriteTextF } func (client *client) RequestPermission(context.Context, acpsdk.RequestPermissionRequest) (acpsdk.RequestPermissionResponse, error) { - return acpsdk.RequestPermissionResponse{}, errors.New("ACP permission requests are unavailable in unattended runs") + return acpsdk.RequestPermissionResponse{}, errors.New("acp permission requests are unavailable in unattended runs") } func (*client) CreateTerminal(context.Context, acpsdk.CreateTerminalRequest) (acpsdk.CreateTerminalResponse, error) { @@ -138,18 +171,16 @@ func (client *client) SessionUpdate(_ context.Context, notification acpsdk.Sessi } func (client *client) UnstableCreateElicitation(context.Context, acpsdk.UnstableCreateElicitationRequest) (acpsdk.UnstableCreateElicitationResponse, error) { - return acpsdk.UnstableCreateElicitationResponse{}, errors.New("ACP elicitation requests are unavailable in unattended runs") + return acpsdk.UnstableCreateElicitationResponse{}, errors.New("acp elicitation requests are unavailable in unattended runs") } func (client *client) validateSession(sessionID acpsdk.SessionId) error { if client.turn == nil { - return errors.New("ACP client is not attached to a turn") + return errors.New("acp client is not attached to a turn") } expected := client.turn.sessionID() if sessionID != acpsdk.SessionId(expected) { - return fmt.Errorf("ACP request belongs to session %q, expected %q", sessionID, expected) + return fmt.Errorf("acp request belongs to session %q, expected %q", sessionID, expected) } return nil } - -var _ acpsdk.Client = (*client)(nil) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go index 9c0a5f2194..4b1f314a46 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go @@ -1,10 +1,7 @@ package acp import ( - "encoding/json" - "errors" "fmt" - "strings" acpsdk "github.com/coder/acp-go-sdk" @@ -33,84 +30,54 @@ func (call *toolCall) message() *console.AgentMessageAttributes { return message } -func toolState(status acpsdk.ToolCallStatus) (console.AgentMessageToolState, error) { - switch status { - case acpsdk.ToolCallStatusPending: - return console.AgentMessageToolStatePending, nil - case acpsdk.ToolCallStatusInProgress, "": - return console.AgentMessageToolStateRunning, nil - case acpsdk.ToolCallStatusCompleted: - return console.AgentMessageToolStateCompleted, nil - case acpsdk.ToolCallStatusFailed: - return console.AgentMessageToolStateError, nil +func (call *toolCall) setName(title string, kind acpsdk.ToolKind) { + switch { + case title != "": + call.name = title + case kind != "": + call.name = string(kind) default: - return "", fmt.Errorf("ACP tool call has unknown status %q", status) + call.name = "tool" } } -func toolName(title string, kind acpsdk.ToolKind) string { - if title != "" { - return title - } - if kind != "" { - return string(kind) +func (call *toolCall) updateMetadata(update *acpsdk.SessionToolCallUpdate) bool { + changed := false + if update.Title != nil && call.name != *update.Title { + call.name = *update.Title + changed = true } - return "tool" -} - -func contentText(content acpsdk.ContentBlock) (string, error) { - if content.Text != nil { - return content.Text.Text, nil - } - return "", errors.New("expected text content") -} - -func contentOutput(content []acpsdk.ToolCallContent) string { - var builder strings.Builder - for _, item := range content { - switch { - case item.Content != nil: - if item.Content.Content.Text != nil { - builder.WriteString(item.Content.Content.Text.Text) - } - case item.Diff != nil: - builder.WriteString(item.Diff.NewText) - case item.Terminal != nil: - builder.WriteString(item.Terminal.TerminalId) + if update.RawInput != nil { + input := formatValue(update.RawInput) + if call.input != input { + call.input = input + changed = true } } - return builder.String() + return changed } -func formatValue(value any) string { - if value == nil { - return "" - } - if stringValue, ok := value.(string); ok { - return stringValue +func (call *toolCall) updateStatus(status *acpsdk.ToolCallStatus) (bool, bool, error) { + if status == nil { + return false, false, nil } - encoded, err := json.Marshal(value) - if err != nil { - return fmt.Sprintf("%v", value) - } - return string(encoded) -} - -func normalizeUsage(providerUsage *acpsdk.Usage) (input, output, total, cached, thought int64) { - input = int64(max(providerUsage.InputTokens, 0)) - output = int64(max(providerUsage.OutputTokens, 0)) - total = max(int64(max(providerUsage.TotalTokens, 0)), input+output) - if providerUsage.CachedReadTokens != nil { - cached += int64(max(*providerUsage.CachedReadTokens, 0)) - } - if providerUsage.CachedWriteTokens != nil { - cached += int64(max(*providerUsage.CachedWriteTokens, 0)) + var state console.AgentMessageToolState + switch *status { + case acpsdk.ToolCallStatusPending: + state = console.AgentMessageToolStatePending + case acpsdk.ToolCallStatusInProgress, "": + state = console.AgentMessageToolStateRunning + case acpsdk.ToolCallStatusCompleted: + state = console.AgentMessageToolStateCompleted + case acpsdk.ToolCallStatusFailed: + state = console.AgentMessageToolStateError + default: + return false, false, fmt.Errorf("acp tool call has unknown status %q", *status) } - if providerUsage.ThoughtTokens != nil { - thought = int64(max(*providerUsage.ThoughtTokens, 0)) - if total < input+output+thought { - total = input + output + thought - } + terminal := state == console.AgentMessageToolStateCompleted || state == console.AgentMessageToolStateError + changed := call.state != state + if changed { + call.state = state } - return + return terminal, changed, nil } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go index 686de1551f..e4e3498fce 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go @@ -38,7 +38,7 @@ func (tool *Tool) runAttempt(ctx context.Context, prompt string, options []exec. } cwd, err := filepath.Abs(tool.Config.RepositoryDir) if err != nil { - return fmt.Errorf("resolve ACP repository directory: %w", err) + return fmt.Errorf("resolve acp repository directory: %w", err) } attempt, err := tool.startAttempt(ctx, options) if err != nil { @@ -63,48 +63,6 @@ type sessionDetails struct { configOptions []acpsdk.SessionConfigOption } -func (tool *Tool) startAttempt(ctx context.Context, options []exec.Option) (*sessionAttempt, error) { - tool.mu.RLock() - launch := tool.launch - priorSessionID := tool.sessionID - tool.mu.RUnlock() - if launch == nil { - return nil, errors.New("acp launcher is not set") - } - - process, err := launch(ctx, options) - if err != nil { - return nil, err - } - if process == nil || process.Stdin == nil || process.Stdout == nil { - return nil, rejectProcess(process) - } - return newSessionAttempt(tool, ctx, process, priorSessionID), nil -} - -func rejectProcess(process *exec.StdioProcess) error { - if process != nil { - _ = process.Stop() - _ = process.Wait() - } - return errors.New("acp launcher returned an incomplete stdio process") -} - -func newSessionAttempt(tool *Tool, ctx context.Context, process *exec.StdioProcess, priorSessionID string) *sessionAttempt { - turn := newTurn(tool, priorSessionID) - attempt := &sessionAttempt{ - tool: tool, - ctx: ctx, - process: process, - connection: acpsdk.NewClientSideConnection(&client{turn: turn}, process.Stdin, process.Stdout), - turn: turn, - priorSessionID: priorSessionID, - } - attempt.connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) - attempt.drainStderr() - return attempt -} - func (attempt *sessionAttempt) drainStderr() { if attempt.process.Stderr == nil { return @@ -156,7 +114,7 @@ func (attempt *sessionAttempt) run(cwd, prompt string) error { if err := attempt.stop(false); err != nil { return fmt.Errorf("stop acp process: %w", err) } - return promptResult(response.StopReason) + return attempt.promptResult(response.StopReason) } func (attempt *sessionAttempt) configureSession(details sessionDetails) error { @@ -205,6 +163,9 @@ func (attempt *sessionAttempt) createSession(cwd string) (sessionDetails, error) return sessionDetails{}, errors.New("acp session/new returned an empty session id") } sessionID := string(created.SessionId) + if provisionalID := attempt.turn.sessionID(); provisionalID != "" && provisionalID != sessionID { + return sessionDetails{}, attempt.turn.sessionUpdateMismatch(acpsdk.SessionId(provisionalID), sessionID) + } attempt.tool.setSessionID(sessionID) attempt.turn.setSessionID(sessionID) return sessionDetails{ @@ -319,13 +280,7 @@ func (attempt *sessionAttempt) killAndWait(waitCh <-chan error) error { return <-waitCh } -func (tool *Tool) sessionIDValue() string { - tool.mu.RLock() - defer tool.mu.RUnlock() - return tool.sessionID -} - -func promptResult(reason acpsdk.StopReason) error { +func (attempt *sessionAttempt) promptResult(reason acpsdk.StopReason) error { switch reason { case acpsdk.StopReasonEndTurn: return nil @@ -339,124 +294,17 @@ func promptResult(reason acpsdk.StopReason) error { } } -func (tool *Tool) setSessionConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption) error { - tool.mu.RLock() - mode := tool.mode - model := tool.model - tool.mu.RUnlock() - - if err := setModelConfig(ctx, connection, sessionID, options, model); err != nil { - return err - } - return setModeConfig(ctx, connection, sessionID, modes, options, mode) -} - -func setModelConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, model string) error { - if model == "" { - return nil - } - found, err := setConfigOption(ctx, connection, sessionID, options, "model", model) - if err != nil { - return err - } - if !found { - klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a model config option") - } - return nil -} - -func setModeConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption, mode string) error { - if mode == "" { - return nil - } - if modeAvailable(modes, mode) { - if _, err := connection.SetSessionMode(ctx, acpsdk.SetSessionModeRequest{ - SessionId: acpsdk.SessionId(sessionID), - ModeId: acpsdk.SessionModeId(mode), - }); err != nil { - return fmt.Errorf("acp session/set_mode: %w", err) - } - return nil - } - if found, err := setConfigOption(ctx, connection, sessionID, options, "mode", mode); err != nil { - return err - } else if found { - return nil - } - klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a mode config option", "mode", mode) - return nil -} - -func modeAvailable(modes *acpsdk.SessionModeState, mode string) bool { - if modes == nil { - return false - } - for _, available := range modes.AvailableModes { - if string(available.Id) == mode { - return true - } - } - return false -} - -func setConfigOption(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, configID, value string) (bool, error) { - for _, option := range options { - if option.Select == nil || string(option.Select.Id) != configID { - continue - } - wanted := acpsdk.SessionConfigValueId(value) - if option.Select.CurrentValue == wanted { - return true, nil - } - if !configOptionContains(option.Select.Options, wanted) { - return true, fmt.Errorf("acp %s %q is not advertised", configID, value) - } - if _, err := connection.SetSessionConfigOption(ctx, acpsdk.SetSessionConfigOptionRequest{ - ValueId: &acpsdk.SetSessionConfigOptionValueId{ - ConfigId: option.Select.Id, - SessionId: acpsdk.SessionId(sessionID), - Value: wanted, - }, - }); err != nil { - return true, fmt.Errorf("acp session/set_config_option %s: %w", configID, err) - } - return true, nil - } - return false, nil -} - -func configOptionContains(options acpsdk.SessionConfigSelectOptions, wanted acpsdk.SessionConfigValueId) bool { - if options.Ungrouped != nil { - for _, option := range *options.Ungrouped { - if option.Value == wanted { - return true - } - } - } - if options.Grouped != nil { - for _, group := range *options.Grouped { - for _, option := range group.Options { - if option.Value == wanted { - return true - } - } - } - } - return false -} - -func (tool *Tool) validate() error { - if tool.Config.Run == nil { - return errors.New("agent run is not set") - } - if tool.Config.RepositoryDir == "" { - return errors.New("repository directory is not set") - } - if tool.Config.WorkDir == "" { - return errors.New("work directory is not set") - } - if tool.Config.ErrorChan == nil { - return errors.New("error channel is not set") +func newSessionAttempt(tool *Tool, ctx context.Context, process *exec.StdioProcess, priorSessionID string) *sessionAttempt { + turn := newTurn(tool, priorSessionID) + attempt := &sessionAttempt{ + tool: tool, + ctx: ctx, + process: process, + connection: acpsdk.NewClientSideConnection(&client{turn: turn}, process.Stdin, process.Stdout), + turn: turn, + priorSessionID: priorSessionID, } - return nil + attempt.connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) + attempt.drainStderr() + return attempt } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go index acae706377..1cb54dc13b 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go @@ -21,15 +21,16 @@ import ( type scriptedState struct { mu sync.Mutex - initializations []acpsdk.InitializeRequest - newSessions []acpsdk.NewSessionRequest - resumedSessions []acpsdk.ResumeSessionRequest - configOptions []acpsdk.SessionConfigOption - setConfig []acpsdk.SetSessionConfigOptionRequest - modes *acpsdk.SessionModeState - setModes []acpsdk.SetSessionModeRequest - prompts []string - stopReason acpsdk.StopReason + initializations []acpsdk.InitializeRequest + newSessions []acpsdk.NewSessionRequest + newSessionUpdates []acpsdk.SessionNotification + resumedSessions []acpsdk.ResumeSessionRequest + configOptions []acpsdk.SessionConfigOption + setConfig []acpsdk.SetSessionConfigOptionRequest + modes *acpsdk.SessionModeState + setModes []acpsdk.SetSessionModeRequest + prompts []string + stopReason acpsdk.StopReason } type scriptedAgent struct { @@ -64,10 +65,16 @@ func (agent *scriptedAgent) ListSessions(context.Context, acpsdk.ListSessionsReq return acpsdk.ListSessionsResponse{}, nil } -func (agent *scriptedAgent) NewSession(_ context.Context, request acpsdk.NewSessionRequest) (acpsdk.NewSessionResponse, error) { +func (agent *scriptedAgent) NewSession(ctx context.Context, request acpsdk.NewSessionRequest) (acpsdk.NewSessionResponse, error) { agent.state.mu.Lock() agent.state.newSessions = append(agent.state.newSessions, request) + updates := append([]acpsdk.SessionNotification(nil), agent.state.newSessionUpdates...) agent.state.mu.Unlock() + for _, update := range updates { + if err := agent.conn.SessionUpdate(ctx, update); err != nil { + return acpsdk.NewSessionResponse{}, err + } + } agent.state.mu.Lock() configOptions := agent.state.configOptions modes := agent.state.modes @@ -75,6 +82,65 @@ func (agent *scriptedAgent) NewSession(_ context.Context, request acpsdk.NewSess return acpsdk.NewSessionResponse{SessionId: "session-1", ConfigOptions: configOptions, Modes: modes}, nil } +func TestRunPromptHandlesUpdatesSentBeforeNewSessionResponse(t *testing.T) { + state := &scriptedState{ + newSessionUpdates: []acpsdk.SessionNotification{ + {SessionId: "session-1", Update: acpsdk.UpdateAgentMessageText("early ")}, + {SessionId: "session-1", Update: acpsdk.UpdateAgentMessageText("update ")}, + }, + } + tool := testTool(t, state) + var messages []string + tool.OnMessage(func(message *console.AgentMessageAttributes, _ string) { + if message.Role == console.AiRoleAssistant { + messages = append(messages, message.Message) + } + }) + + if err := tool.FollowUpRun(context.Background(), "prompt"); err != nil { + t.Fatalf("prompt: %v", err) + } + + if len(messages) != 1 || messages[0] != "early update response: prompt" { + t.Fatalf("assistant messages = %v, want [early update response: prompt]", messages) + } +} + +func TestRunPromptRejectsMismatchedInitialSessionUpdate(t *testing.T) { + state := &scriptedState{ + newSessionUpdates: []acpsdk.SessionNotification{ + {SessionId: "session-other", Update: acpsdk.UpdateAgentMessageText("wrong session")}, + }, + } + tool := testTool(t, state) + + err := tool.FollowUpRun(context.Background(), "prompt") + if err == nil { + t.Fatal("prompt with mismatched initial session update unexpectedly succeeded") + } + if !strings.Contains(err.Error(), `belongs to session "session-other", expected "session-1"`) { + t.Fatalf("mismatched initial update error = %v", err) + } + if got := tool.sessionIDValue(); got != "" { + t.Fatalf("tool session id = %q, want empty after rejected session", got) + } +} + +func TestSessionUpdateRejectsEmptySessionIDBeforeBinding(t *testing.T) { + turn := newTurn(&Tool{}, "") + + err := turn.handle(acpsdk.SessionNotification{Update: acpsdk.UpdateAgentMessageText("ignored")}) + if err == nil { + t.Fatal("empty session update unexpectedly succeeded") + } + if got := err.Error(); got != "acp session update has an empty session id" { + t.Fatalf("empty session update error = %q", got) + } + if got := turn.sessionID(); got != "" { + t.Fatalf("provisional session id = %q, want empty", got) + } +} + func (agent *scriptedAgent) Prompt(ctx context.Context, request acpsdk.PromptRequest) (acpsdk.PromptResponse, error) { prompt := "" if len(request.Prompt) > 0 && request.Prompt[0].Text != nil { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go index e227684069..7d0c9b37b7 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go @@ -14,6 +14,28 @@ import ( "github.com/pluralsh/console/go/deployment-operator/pkg/log" ) +type toolCall struct { + id string + name string + input string + output string + state console.AgentMessageToolState +} + +func (call *toolCall) addOutput(output string) { + if output == "" || output == call.output { + return + } + call.output = output +} + +type toolUpdateEvents struct { + message *console.AgentMessageAttributes + output string + streamOutput bool + terminal bool +} + type turnState struct { tool *Tool mu sync.Mutex @@ -25,20 +47,55 @@ type turnState struct { cost float64 } -type toolCall struct { - id string - name string - input string - output string - state console.AgentMessageToolState +func (turn *turnState) contentText(content acpsdk.ContentBlock) (string, error) { + if content.Text != nil { + return content.Text.Text, nil + } + return "", errors.New("expected text content") } -func newTurn(tool *Tool, sessionID string) *turnState { - return &turnState{ - tool: tool, - sessionIDValue: sessionID, - tools: make(map[string]*toolCall), +func (turn *turnState) contentOutput(content []acpsdk.ToolCallContent) string { + var builder strings.Builder + for _, item := range content { + switch { + case item.Content != nil: + if item.Content.Content.Text != nil { + builder.WriteString(item.Content.Content.Text.Text) + } + case item.Diff != nil: + builder.WriteString(item.Diff.NewText) + case item.Terminal != nil: + builder.WriteString(item.Terminal.TerminalId) + } + } + return builder.String() +} + +func (turn *turnState) toolOutput(content []acpsdk.ToolCallContent, rawOutput any) string { + output := turn.contentOutput(content) + if output == "" && rawOutput != nil { + return formatValue(rawOutput) + } + return output +} + +func (turn *turnState) normalizeUsage(providerUsage *acpsdk.Usage) (input, output, total, cached, thought int64) { + input = int64(max(providerUsage.InputTokens, 0)) + output = int64(max(providerUsage.OutputTokens, 0)) + total = max(int64(max(providerUsage.TotalTokens, 0)), input+output) + if providerUsage.CachedReadTokens != nil { + cached += int64(max(*providerUsage.CachedReadTokens, 0)) + } + if providerUsage.CachedWriteTokens != nil { + cached += int64(max(*providerUsage.CachedWriteTokens, 0)) + } + if providerUsage.ThoughtTokens != nil { + thought = int64(max(*providerUsage.ThoughtTokens, 0)) + if total < input+output+thought { + total = input + output + thought + } } + return } func (turn *turnState) sessionID() string { @@ -71,31 +128,15 @@ func (turn *turnState) setErr(err error) { } func (turn *turnState) handle(notification acpsdk.SessionNotification) error { - if notification.SessionId != acpsdk.SessionId(turn.sessionID()) { - err := fmt.Errorf("ACP session update belongs to session %q, expected %q", notification.SessionId, turn.sessionID()) - turn.setErr(err) + if err := turn.bindNotification(notification.SessionId); err != nil { return err } update := notification.Update switch { case update.AgentMessageChunk != nil: - text, err := contentText(update.AgentMessageChunk.Content) - if err != nil { - turn.setErr(fmt.Errorf("ACP agent message content: %w", err)) - return err - } - turn.mu.Lock() - turn.assistant.WriteString(text) - turn.mu.Unlock() + return turn.appendTextChunk(update.AgentMessageChunk.Content, &turn.assistant, "agent message") case update.AgentThoughtChunk != nil: - text, err := contentText(update.AgentThoughtChunk.Content) - if err != nil { - turn.setErr(fmt.Errorf("ACP agent thought content: %w", err)) - return err - } - turn.mu.Lock() - turn.reasoning.WriteString(text) - turn.mu.Unlock() + return turn.appendTextChunk(update.AgentThoughtChunk.Content, &turn.reasoning, "agent thought") case update.ToolCall != nil: return turn.startTool(update.ToolCall) case update.ToolCallUpdate != nil: @@ -103,8 +144,8 @@ func (turn *turnState) handle(notification acpsdk.SessionNotification) error { case update.UsageUpdate != nil: turn.usageUpdate(update.UsageUpdate) case update.UserMessageChunk != nil: - if _, err := contentText(update.UserMessageChunk.Content); err != nil { - turn.setErr(fmt.Errorf("ACP user message content: %w", err)) + if _, err := turn.contentText(update.UserMessageChunk.Content); err != nil { + turn.setErr(fmt.Errorf("acp user message content: %w", err)) return err } default: @@ -115,31 +156,66 @@ func (turn *turnState) handle(notification acpsdk.SessionNotification) error { return nil } +func (turn *turnState) bindNotification(sessionID acpsdk.SessionId) error { + if sessionID == "" { + return turn.fail("acp session update has an empty session id") + } + + // A trusted ACP child can send updates before NewSession returns. Bind the + // first non-empty notification provisionally and reconcile it in createSession; + // buffering those callbacks would add latency and state without protecting + // this trusted process from a protocol race. + turn.mu.Lock() + expected := turn.sessionIDValue + if expected == "" { + expected = string(sessionID) + turn.sessionIDValue = expected + } + turn.mu.Unlock() + if sessionID == acpsdk.SessionId(expected) { + return nil + } + return turn.sessionUpdateMismatch(sessionID, expected) +} + +func (turn *turnState) appendTextChunk(content acpsdk.ContentBlock, target *strings.Builder, kind string) error { + text, err := turn.contentText(content) + if err != nil { + turn.setErr(fmt.Errorf("acp %s content: %w", kind, err)) + return err + } + turn.mu.Lock() + target.WriteString(text) + turn.mu.Unlock() + return nil +} + +func (turn *turnState) sessionUpdateMismatch(actual acpsdk.SessionId, expected string) error { + err := fmt.Errorf("acp session update belongs to session %q, expected %q", actual, expected) + turn.setErr(err) + return err +} + func (turn *turnState) startTool(update *acpsdk.SessionUpdateToolCall) error { if update.ToolCallId == "" { - return turn.fail("ACP tool call has an empty id") + return turn.fail("acp tool call has an empty id") } id := string(update.ToolCallId) turn.mu.Lock() if _, exists := turn.tools[id]; exists { turn.mu.Unlock() - return turn.fail(fmt.Sprintf("ACP tool call %q was started twice", id)) - } - state, err := toolState(update.Status) - if err != nil { - turn.mu.Unlock() - return turn.fail(err.Error()) + return turn.fail(fmt.Sprintf("acp tool call %q was started twice", id)) } call := &toolCall{ id: id, - name: toolName(update.Title, update.Kind), input: formatValue(update.RawInput), - state: state, } - call.output = contentOutput(update.Content) - if call.output == "" && update.RawOutput != nil { - call.output = formatValue(update.RawOutput) + call.setName(update.Title, update.Kind) + if _, _, err := call.updateStatus(&update.Status); err != nil { + turn.mu.Unlock() + return turn.fail(err.Error()) } + call.output = turn.toolOutput(update.Content, update.RawOutput) turn.tools[id] = call message := call.message() output := call.output @@ -150,50 +226,34 @@ func (turn *turnState) startTool(update *acpsdk.SessionUpdateToolCall) error { } func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { - id := string(update.ToolCallId) turn.mu.Lock() + events, err := turn.applyToolUpdate(update) + turn.mu.Unlock() + if err != nil { + turn.setErr(err) + return err + } + turn.emitToolUpdate(update.ToolCallId, events) + return nil +} + +func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (toolUpdateEvents, error) { + id := string(update.ToolCallId) call, exists := turn.tools[id] if !exists { - turn.mu.Unlock() - return turn.fail(fmt.Sprintf("ACP tool call update %q arrived before tool_call", id)) - } - metadataChanged := false - if update.Title != nil { - if call.name != *update.Title { - metadataChanged = true - call.name = *update.Title - } - } - if update.RawInput != nil { - input := formatValue(update.RawInput) - if call.input != input { - metadataChanged = true - call.input = input - } + return toolUpdateEvents{}, fmt.Errorf("acp tool call update %q arrived before tool_call", id) } + metadataChanged := call.updateMetadata(update) previousOutput := call.output - output := contentOutput(update.Content) - if output == "" && update.RawOutput != nil { - output = formatValue(update.RawOutput) - } - if output != "" { + if output := turn.toolOutput(update.Content, update.RawOutput); output != "" { call.addOutput(output) } - accumulatedOutput := call.output - streamOutput := accumulatedOutput != previousOutput && (previousOutput == "" || strings.HasPrefix(accumulatedOutput, previousOutput)) - terminal := false - if update.Status != nil { - state, err := toolState(*update.Status) - if err != nil { - turn.mu.Unlock() - return turn.fail(err.Error()) - } - if call.state != state { - metadataChanged = true - call.state = state - } - terminal = state == console.AgentMessageToolStateCompleted || state == console.AgentMessageToolStateError + streamOutput := call.output != previousOutput && (previousOutput == "" || strings.HasPrefix(call.output, previousOutput)) + terminal, statusChanged, err := call.updateStatus(update.Status) + if err != nil { + return toolUpdateEvents{}, err } + metadataChanged = metadataChanged || statusChanged message := (*console.AgentMessageAttributes)(nil) if terminal { message = call.message() @@ -201,17 +261,24 @@ func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { } else if metadataChanged { message = call.message() } - turn.mu.Unlock() - if terminal && streamOutput { - turn.tool.EmitOutput(id, accumulatedOutput) + return toolUpdateEvents{ + message: message, + output: call.output, + streamOutput: streamOutput, + terminal: terminal, + }, nil +} + +func (turn *turnState) emitToolUpdate(id acpsdk.ToolCallId, events toolUpdateEvents) { + if events.terminal && events.streamOutput { + turn.tool.EmitOutput(string(id), events.output) } - if message != nil { - turn.tool.emit(message, id) + if events.message != nil { + turn.tool.emit(events.message, string(id)) } - if !terminal && streamOutput { - turn.tool.EmitOutput(id, accumulatedOutput) + if !events.terminal && events.streamOutput { + turn.tool.EmitOutput(string(id), events.output) } - return nil } func (turn *turnState) emitAssistant(responseUsage *acpsdk.Usage) { @@ -228,7 +295,7 @@ func (turn *turnState) emitAssistant(responseUsage *acpsdk.Usage) { } } if responseUsage != nil { - input, output, total, cached, thought := normalizeUsage(responseUsage) + input, output, total, cached, thought := turn.normalizeUsage(responseUsage) if turn.tool.Config.Usage != nil { turn.tool.Config.Usage.RecordUsage(usage.Record{ InputTokens: input, OutputTokens: output, TotalTokens: total, @@ -279,9 +346,10 @@ func (turn *turnState) fail(message string) error { return err } -func (call *toolCall) addOutput(output string) { - if output == "" || output == call.output { - return +func newTurn(tool *Tool, sessionID string) *turnState { + return &turnState{ + tool: tool, + sessionIDValue: sessionID, + tools: make(map[string]*toolCall), } - call.output = output } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/common.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/common.go new file mode 100644 index 0000000000..995cd09b99 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/common.go @@ -0,0 +1,20 @@ +package acp + +import ( + "encoding/json" + "fmt" +) + +func formatValue(value any) string { + if value == nil { + return "" + } + if stringValue, ok := value.(string); ok { + return stringValue + } + encoded, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(encoded) +} From c4996bf31fbb24b1d46546db87eca5f9e53896b9 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Tue, 1 Sep 2026 14:07:40 +0200 Subject: [PATCH 10/46] fix(opencode): correct case of `DefaultReviewAgent` return value - Adjust `opencode.go` to use `DefaultReviewAgent` consistently across logic. - Resolve potential errors due to incorrect agent name casing. --- .../pkg/agentrun-harness/tool/opencode/opencode.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go index fa701b2014..90a3538dd9 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go @@ -249,7 +249,7 @@ func (in *Opencode) agent() string { case console.AgentRunModeAnalyze: return DefaultAnalysisAgent case console.AgentRunModeReview: - return defaultReviewAgent + return DefaultReviewAgent } return DefaultWriteAgent From 301b105c2f669fc9796a7904c07e7fd2a95a713b Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Tue, 1 Sep 2026 17:15:55 +0200 Subject: [PATCH 11/46] fix(tests): adjust object creation and correct agent case - Change object creation in `templates_test.go` to use pointer receiver syntax - Correct casing of `DefaultReviewAgent` in `opencode_args_test.go` --- .../pkg/agentrun-harness/tool/opencode/opencode_args_test.go | 2 +- .../pkg/agentrun-harness/tool/v1/templates_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go index e0318489a4..e968c7178a 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go @@ -64,7 +64,7 @@ func TestOpencodeArgsReview(t *testing.T) { assertArgsEqual(t, []string{ "run", "--format", "json", - "--agent", defaultReviewAgent, + "--agent", DefaultReviewAgent, "--model", "anthropic/claude-sonnet-4-6", "review changes", }, args) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/v1/templates_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/v1/templates_test.go index 5e3ac9dbd6..48710acfab 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/v1/templates_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/v1/templates_test.go @@ -97,7 +97,7 @@ func TestSystemPromptTemplate_ReviewDepth(t *testing.T) { } func TestSystemPromptInputIncludesPRURL(t *testing.T) { - input := (DefaultTool{Config: Config{ + input := (&DefaultTool{Config: Config{ Run: &agentrunv1.AgentRun{ Mode: console.AgentRunModeReview, PRURL: "https://github.com/pluralsh/console/pull/1", From 70f898078d0191f0d568594e11204b0a52c7cef1 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 4 Sep 2026 11:14:39 +0200 Subject: [PATCH 12/46] feat(tool): integrate Agent Client Protocol (ACP) and enhance test coverage - Introduced ACP transport for Opencode with provider-specific session handling in `transport.go`. - Refactored tool runtime API to abstract provider-neutral operations in `runtime_types.go`. - Added comprehensive tests for ACP transport in `transport_test.go` and Agent lifecycle in `runtime_test.go`. - Enhanced tool configuration logic, consolidating system prompt methods with `configureSystemPrompt`. - Improved model selection and OpenCode provider prefix handling in `opencode_templates_test.go` and `settings_test.go`. - Simplified file-system handling and lifecycle hooks for agents. --- .../pkg/agentrun-harness/agentrun/v1/types.go | 6 +- .../pkg/agentrun-harness/tool/acp/acp.go | 409 ---------- .../tool/acp/acp_client_test.go | 119 --- .../pkg/agentrun-harness/tool/acp/acp_test.go | 718 ------------------ .../tool/acp/{acp_client.go => client.go} | 0 .../agentrun-harness/tool/acp/client_test.go | 77 ++ .../pkg/agentrun-harness/tool/acp/common.go | 20 - .../pkg/agentrun-harness/tool/acp/engine.go | 174 +++++ .../agentrun-harness/tool/acp/engine_test.go | 461 +++++++++++ .../pkg/agentrun-harness/tool/acp/opencode.go | 64 -- .../tool/acp/opencode_test.go | 33 - .../tool/acp/{acp_session.go => session.go} | 104 +-- .../agentrun-harness/tool/acp/session_test.go | 55 ++ .../tool/acp/{acp_mapping.go => tool_call.go} | 70 +- .../tool/acp/tool_call_test.go | 61 ++ .../pkg/agentrun-harness/tool/acp/types.go | 40 + .../tool/acp/{acp_turn.go => updates.go} | 96 +-- .../agentrun-harness/tool/opencode/agent.go | 225 ++++++ .../tool/opencode/agent_test.go | 294 +++++++ .../tool/opencode/artifacts.go | 50 -- .../agentrun-harness/tool/opencode/config.go | 100 +++ .../tool/opencode/opencode.go | 439 ----------- .../tool/opencode/opencode_acp_types.go | 23 - .../tool/opencode/opencode_args_test.go | 83 -- .../tool/opencode/opencode_config.go | 124 --- .../tool/opencode/opencode_provider_test.go | 26 - .../tool/opencode/opencode_stream_test.go | 51 -- .../tool/opencode/opencode_types.go | 355 --------- .../tool/opencode/provider_test.go | 29 + .../tool/opencode/settings.go | 74 ++ ...code_settings_test.go => settings_test.go} | 42 +- .../{opencode_templates.go => templates.go} | 0 .../opencode/templates/opencode.json.gotmpl | 4 +- ...de_templates_test.go => templates_test.go} | 16 +- .../tool/opencode/transport.go | 156 ++++ .../tool/opencode/transport_test.go | 63 ++ .../pkg/agentrun-harness/tool/tool.go | 12 +- .../pkg/agentrun-harness/tool/tool_test.go | 31 + .../pkg/agentrun-harness/tool/v1/runtime.go | 309 ++++++++ .../agentrun-harness/tool/v1/runtime_test.go | 422 ++++++++++ .../agentrun-harness/tool/v1/runtime_types.go | 160 ++++ .../pkg/agentrun-harness/tool/v1/tool.go | 38 +- 42 files changed, 2935 insertions(+), 2698 deletions(-) delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go rename go/deployment-operator/pkg/agentrun-harness/tool/acp/{acp_client.go => client.go} (100%) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/common.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode_test.go rename go/deployment-operator/pkg/agentrun-harness/tool/acp/{acp_session.go => session.go} (83%) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go rename go/deployment-operator/pkg/agentrun-harness/tool/acp/{acp_mapping.go => tool_call.go} (58%) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go rename go/deployment-operator/pkg/agentrun-harness/tool/acp/{acp_turn.go => updates.go} (79%) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/artifacts.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/config.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_acp_types.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_config.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_provider_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_stream_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_types.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/provider_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings.go rename go/deployment-operator/pkg/agentrun-harness/tool/opencode/{opencode_settings_test.go => settings_test.go} (58%) rename go/deployment-operator/pkg/agentrun-harness/tool/opencode/{opencode_templates.go => templates.go} (100%) rename go/deployment-operator/pkg/agentrun-harness/tool/opencode/{opencode_templates_test.go => templates_test.go} (96%) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_types.go diff --git a/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types.go b/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types.go index 031aee3c59..ba15599db9 100644 --- a/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types.go +++ b/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types.go @@ -305,7 +305,11 @@ func (ar *AgentRun) fromEnv(runtime *console.AgentRuntimeFragment) *AgentRuntime } func (ar *AgentRun) IsProxyEnabled() bool { - return ar.Runtime != nil && ar.Runtime.AiProxy + if ar == nil || ar.Runtime == nil { + return false + } + + return ar.Runtime.AiProxy } func (ar *AgentRun) IsStreamingProxyEnabled() bool { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go deleted file mode 100644 index 44f92a589d..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp.go +++ /dev/null @@ -1,409 +0,0 @@ -// Package acp contains the provider-neutral Agent Client Protocol harness. -// Provider adapters supply process launch, configuration, and native artifact -// export callbacks while this package owns the ACP session lifecycle and -// Console message mapping. -package acp - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "sync" - "time" - - acpsdk "github.com/coder/acp-go-sdk" - "k8s.io/klog/v2" - - console "github.com/pluralsh/console/go/client" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" -) - -const ( - defaultStopTimeout = 2 * time.Second -) - -// LaunchFunc starts one ACP agent process. A new process is started for every -// prompt, including resumed prompts; the ACP session itself remains the -// durable conversation state. -type LaunchFunc func(context.Context, []exec.Option) (*exec.StdioProcess, error) - -// ConfigureFunc writes provider configuration and any provider system prompt -// files needed before a run. -type ConfigureFunc func(consoleURL, consoleToken string) error - -// ExportFunc exports a provider-native session to outputPath. -type ExportFunc func(context.Context, string, string) error - -// Option configures the provider-neutral ACP tool. -type Option func(*Tool) - -// WithLauncher supplies the provider-specific ACP process launcher. -func WithLauncher(launch LaunchFunc) Option { - return func(tool *Tool) { tool.launch = launch } -} - -// WithConfigure supplies the provider-specific configuration callback. -func WithConfigure(configure ConfigureFunc) Option { - return func(tool *Tool) { tool.configure = configure } -} - -// WithBabysitConfigure supplies the provider-specific babysit configuration -// callback. -func WithBabysitConfigure(configure func() error) Option { - return func(tool *Tool) { tool.configureBabysit = configure } -} - -// WithExporter supplies provider-native session export behavior. -func WithExporter(export ExportFunc) Option { - return func(tool *Tool) { tool.export = export } -} - -// WithProviderName sets the name used when building the upload archive. -func WithProviderName(name string) Option { - return func(tool *Tool) { tool.providerName = name } -} - -// WithMode sets the provider ACP session mode. Empty mode leaves the agent's -// negotiated default untouched. -func WithMode(mode string) Option { - return func(tool *Tool) { tool.mode = mode } -} - -// WithModel sets the provider/model selection used by ACP session config -// options. The value must use the provider/model form advertised by the -// provider (for example, "openai/gpt-5.4"). -func WithModel(model string) Option { - return func(tool *Tool) { tool.model = model } -} - -// WithStopTimeout sets the bounded wait after session/cancel before the agent -// process is killed. -func WithStopTimeout(timeout time.Duration) Option { - return func(tool *Tool) { - if timeout > 0 { - tool.stopTimeout = timeout - } - } -} - -var _ toolv1.Tool = (*Tool)(nil) - -// Tool implements v1.Tool for an ACP-speaking provider. -type Tool struct { - toolv1.DefaultTool - - launch LaunchFunc - configure ConfigureFunc - configureBabysit func() error - export ExportFunc - providerName string - mode string - - stopTimeout time.Duration - model string - - mu sync.RWMutex - onMessage toolv1.MessageCallback - sessionID string -} - -// New creates a provider-neutral ACP tool. Provider adapters normally pass -// WithLauncher, WithConfigure, WithBabysitConfigure, and WithExporter. -func New(config toolv1.Config, options ...Option) *Tool { - if config.Usage == nil { - config.Usage = usage.New(nil) - } - tool := &Tool{ - DefaultTool: toolv1.DefaultTool{Config: config}, - providerName: "acp", - stopTimeout: defaultStopTimeout, - } - for _, option := range options { - option(tool) - } - return tool -} - -// Run starts the initial ACP prompt in the background. -func (tool *Tool) Run(ctx context.Context, options ...exec.Option) { - initialOptions := append([]exec.Option(nil), options...) - go func() { - if tool.Config.SkipInitialRun { - return - } - if tool.Config.Run == nil { - tool.reportError(errors.New("agent run is not set")) - return - } - tool.emit(&console.AgentMessageAttributes{Message: tool.Config.Run.Prompt, Role: console.AiRoleUser}, "") - if err := tool.runPromptWithOptions(ctx, tool.Config.Run.Prompt, initialOptions); err != nil { - tool.reportError(err) - } - }() -} - -// BabysitRun resumes the current ACP session when the babysit loop provides a -// changed prompt. A nil context means no prompt is needed. -func (tool *Tool) BabysitRun(ctx context.Context, babysit *toolv1.BabysitContext) bool { - if babysit == nil { - return false - } - tool.emit(&console.AgentMessageAttributes{Message: babysit.Prompt, Role: console.AiRoleUser}, "") - if err := tool.runPrompt(ctx, babysit.Prompt); err != nil { - tool.reportError(err) - } - return false -} - -// Configure configures the provider adapter. -func (tool *Tool) Configure(consoleURL, consoleToken string) error { - if tool.configure == nil { - return errors.New("acp configure function is not set") - } - return tool.configure(consoleURL, consoleToken) -} - -// ConfigureBabysitRun configures provider files used by resumed prompts. -func (tool *Tool) ConfigureBabysitRun() error { - if tool.configureBabysit == nil { - return nil - } - return tool.configureBabysit() -} - -// OnMessage registers the Console message callback. -func (tool *Tool) OnMessage(callback toolv1.MessageCallback) { - tool.mu.Lock() - tool.onMessage = callback - tool.mu.Unlock() -} - -// FollowUpRun resumes the current ACP session. It deliberately does not emit -// the user prompt because the controller persists follow-up prompts itself. -func (tool *Tool) FollowUpRun(ctx context.Context, prompt string) error { - return tool.runPrompt(ctx, prompt) -} - -// UploadArtifacts exports and archives the native ACP provider session. -func (tool *Tool) UploadArtifacts(ctx context.Context) (*artifacts.UploadArtifacts, error) { - // Kept in a small adapter method so provider-specific export remains outside - // the protocol implementation. - if tool.export == nil { - return nil, errors.New("acp session exporter is not set") - } - tool.mu.RLock() - sessionID := tool.sessionID - providerName := tool.providerName - tool.mu.RUnlock() - if sessionID == "" { - return nil, errors.New("acp session id is not set") - } - - sourcePath, err := os.MkdirTemp(tool.Config.WorkDir, "acp-session-export-*") - if err != nil { - return nil, fmt.Errorf("create acp session export dir: %w", err) - } - defer os.RemoveAll(sourcePath) - - sessionPath := filepath.Join(sourcePath, artifacts.SessionJSONName) - if err := tool.export(ctx, sessionPath, sessionID); err != nil { - return nil, err - } - return tool.BuildUploadArtifacts(ctx, artifacts.BuildArtifactsOptions{ - Provider: providerName, - Source: artifacts.SessionSource{ - Path: sourcePath, - ArchivePath: providerName, - }, - SessionID: sessionID, - }) -} - -func (tool *Tool) startAttempt(ctx context.Context, options []exec.Option) (*sessionAttempt, error) { - tool.mu.RLock() - launch := tool.launch - priorSessionID := tool.sessionID - tool.mu.RUnlock() - if launch == nil { - return nil, errors.New("acp launcher is not set") - } - - process, err := launch(ctx, options) - if err != nil { - return nil, err - } - if process == nil || process.Stdin == nil || process.Stdout == nil { - if process != nil { - _ = process.Stop() - _ = process.Wait() - } - return nil, errors.New("acp launcher returned an incomplete stdio process") - } - return newSessionAttempt(tool, ctx, process, priorSessionID), nil -} - -func (tool *Tool) reportError(err error) { - if err == nil || tool.Config.ErrorChan == nil { - return - } - klog.V(log.LogLevelDefault).ErrorS(err, "ACP execution failed") - tool.Config.ErrorChan <- err -} - -func (tool *Tool) emit(message *console.AgentMessageAttributes, callID string) { - if message == nil { - return - } - tool.mu.RLock() - callback := tool.onMessage - tool.mu.RUnlock() - if callback == nil { - return - } - defer func() { - if recovered := recover(); recovered != nil { - klog.ErrorS(fmt.Errorf("panic in acp message callback: %v", recovered), "ACP message callback panicked") - } - }() - callback(message, callID) -} - -func (tool *Tool) setSessionID(sessionID string) { - tool.mu.Lock() - tool.sessionID = sessionID - tool.mu.Unlock() -} - -func (tool *Tool) sessionIDValue() string { - tool.mu.RLock() - defer tool.mu.RUnlock() - return tool.sessionID -} - -func (tool *Tool) setSessionConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption) error { - tool.mu.RLock() - mode := tool.mode - model := tool.model - tool.mu.RUnlock() - - if err := tool.setModelConfig(ctx, connection, sessionID, options, model); err != nil { - return err - } - return tool.setModeConfig(ctx, connection, sessionID, modes, options, mode) -} - -func (tool *Tool) setModelConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, model string) error { - if model == "" { - return nil - } - found, err := tool.setConfigOption(ctx, connection, sessionID, options, "model", model) - if err != nil { - return err - } - if !found { - klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a model config option") - } - return nil -} - -func (tool *Tool) setModeConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption, mode string) error { - if mode == "" { - return nil - } - if tool.modeAvailable(modes, mode) { - if _, err := connection.SetSessionMode(ctx, acpsdk.SetSessionModeRequest{ - SessionId: acpsdk.SessionId(sessionID), - ModeId: acpsdk.SessionModeId(mode), - }); err != nil { - return fmt.Errorf("acp session/set_mode: %w", err) - } - return nil - } - if found, err := tool.setConfigOption(ctx, connection, sessionID, options, "mode", mode); err != nil { - return err - } else if found { - return nil - } - klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a mode config option", "mode", mode) - return nil -} - -func (tool *Tool) modeAvailable(modes *acpsdk.SessionModeState, mode string) bool { - if modes == nil { - return false - } - for _, available := range modes.AvailableModes { - if string(available.Id) == mode { - return true - } - } - return false -} - -func (tool *Tool) setConfigOption(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, configID, value string) (bool, error) { - for _, option := range options { - if option.Select == nil || string(option.Select.Id) != configID { - continue - } - wanted := acpsdk.SessionConfigValueId(value) - if option.Select.CurrentValue == wanted { - return true, nil - } - if !tool.configOptionContains(option.Select.Options, wanted) { - return true, fmt.Errorf("acp %s %q is not advertised", configID, value) - } - if _, err := connection.SetSessionConfigOption(ctx, acpsdk.SetSessionConfigOptionRequest{ - ValueId: &acpsdk.SetSessionConfigOptionValueId{ - ConfigId: option.Select.Id, - SessionId: acpsdk.SessionId(sessionID), - Value: wanted, - }, - }); err != nil { - return true, fmt.Errorf("acp session/set_config_option %s: %w", configID, err) - } - return true, nil - } - return false, nil -} - -func (tool *Tool) configOptionContains(options acpsdk.SessionConfigSelectOptions, wanted acpsdk.SessionConfigValueId) bool { - if options.Ungrouped != nil { - for _, option := range *options.Ungrouped { - if option.Value == wanted { - return true - } - } - } - if options.Grouped != nil { - for _, group := range *options.Grouped { - for _, option := range group.Options { - if option.Value == wanted { - return true - } - } - } - } - return false -} - -func (tool *Tool) validate() error { - if tool.Config.Run == nil { - return errors.New("agent run is not set") - } - if tool.Config.RepositoryDir == "" { - return errors.New("repository directory is not set") - } - if tool.Config.WorkDir == "" { - return errors.New("work directory is not set") - } - if tool.Config.ErrorChan == nil { - return errors.New("error channel is not set") - } - return nil -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client_test.go deleted file mode 100644 index 71898e704c..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package acp - -import ( - "context" - "errors" - "os" - "path/filepath" - "testing" - - acpsdk "github.com/coder/acp-go-sdk" -) - -func testACPClient(t *testing.T) (*client, string) { - t.Helper() - tool := &Tool{} - turn := newTurn(tool, "session-1") - return &client{turn: turn}, t.TempDir() -} - -func TestReadAndWriteTextFile(t *testing.T) { - client, cwd := testACPClient(t) - path := filepath.Join(cwd, "nested", "file.txt") - request := acpsdk.WriteTextFileRequest{SessionId: "session-1", Path: path, Content: "one\ntwo\nthree\n"} - if _, err := client.WriteTextFile(context.Background(), request); err != nil { - t.Fatalf("write text file: %v", err) - } - - line, limit := 2, 2 - response, err := client.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{ - SessionId: "session-1", Path: path, Line: &line, Limit: &limit, - }) - if err != nil { - t.Fatalf("read text file: %v", err) - } - if response.Content != "two\nthree" { - t.Fatalf("read content = %q, want %q", response.Content, "two\nthree") - } - - emptyPath := filepath.Join(cwd, "nested", "empty.txt") - request.Path = emptyPath - request.Content = "" - if _, err := client.WriteTextFile(context.Background(), request); err != nil { - t.Fatalf("write empty text file: %v", err) - } - response, err = client.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{ - SessionId: "session-1", Path: emptyPath, - }) - if err != nil { - t.Fatalf("read empty text file: %v", err) - } - if response.Content != "" { - t.Fatalf("empty read content = %q, want empty", response.Content) - } - - if _, err := client.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{ - SessionId: "session-1", Path: "relative.txt", - }); err == nil { - t.Fatal("relative read path unexpectedly succeeded") - } - if _, err := client.WriteTextFile(context.Background(), acpsdk.WriteTextFileRequest{ - SessionId: "session-1", Path: "relative.txt", Content: "content", - }); err == nil { - t.Fatal("relative write path unexpectedly succeeded") - } - if _, err := client.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{ - SessionId: "wrong-session", Path: path, - }); err == nil { - t.Fatal("read for another session unexpectedly succeeded") - } - if _, err := client.WriteTextFile(context.Background(), acpsdk.WriteTextFileRequest{ - SessionId: "wrong-session", Path: path, Content: "content", - }); err == nil { - t.Fatal("write for another session unexpectedly succeeded") - } -} - -func TestWriteTextFileCreatesEmptyFile(t *testing.T) { - client, cwd := testACPClient(t) - path := filepath.Join(cwd, "empty", "file") - if _, err := client.WriteTextFile(context.Background(), acpsdk.WriteTextFileRequest{ - SessionId: "session-1", Path: path, - }); err != nil { - t.Fatalf("write empty file: %v", err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatalf("stat empty file: %v", err) - } - if info.Size() != 0 { - t.Fatalf("empty file size = %d, want 0", info.Size()) - } -} - -func TestReadTextFileRejectsCanceledAndOversizedReads(t *testing.T) { - client, cwd := testACPClient(t) - path := filepath.Join(cwd, "large.txt") - file, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - if err := file.Truncate(maxTextFileBytes + 1); err != nil { - t.Fatal(err) - } - if err := file.Close(); err != nil { - t.Fatal(err) - } - - request := acpsdk.ReadTextFileRequest{SessionId: "session-1", Path: path} - if _, err := client.ReadTextFile(context.Background(), request); err == nil { - t.Fatal("oversized read unexpectedly succeeded") - } - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - request.Path = filepath.Join(cwd, "missing.txt") - if _, err := client.ReadTextFile(ctx, request); !errors.Is(err, context.Canceled) { - t.Fatalf("canceled read error = %v, want context.Canceled", err) - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go deleted file mode 100644 index 1cb54dc13b..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_test.go +++ /dev/null @@ -1,718 +0,0 @@ -package acp - -import ( - "context" - "io" - "log/slog" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - acpsdk "github.com/coder/acp-go-sdk" - console "github.com/pluralsh/console/go/client" - agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" -) - -type scriptedState struct { - mu sync.Mutex - - initializations []acpsdk.InitializeRequest - newSessions []acpsdk.NewSessionRequest - newSessionUpdates []acpsdk.SessionNotification - resumedSessions []acpsdk.ResumeSessionRequest - configOptions []acpsdk.SessionConfigOption - setConfig []acpsdk.SetSessionConfigOptionRequest - modes *acpsdk.SessionModeState - setModes []acpsdk.SetSessionModeRequest - prompts []string - stopReason acpsdk.StopReason -} - -type scriptedAgent struct { - state *scriptedState - conn *acpsdk.AgentSideConnection -} - -func (agent *scriptedAgent) Authenticate(context.Context, acpsdk.AuthenticateRequest) (acpsdk.AuthenticateResponse, error) { - return acpsdk.AuthenticateResponse{}, nil -} - -func (agent *scriptedAgent) Initialize(_ context.Context, request acpsdk.InitializeRequest) (acpsdk.InitializeResponse, error) { - agent.state.mu.Lock() - agent.state.initializations = append(agent.state.initializations, request) - agent.state.mu.Unlock() - return acpsdk.InitializeResponse{ProtocolVersion: acpsdk.ProtocolVersionNumber}, nil -} - -func (agent *scriptedAgent) Logout(context.Context, acpsdk.LogoutRequest) (acpsdk.LogoutResponse, error) { - return acpsdk.LogoutResponse{}, nil -} - -func (agent *scriptedAgent) Cancel(context.Context, acpsdk.CancelNotification) error { - return nil -} - -func (agent *scriptedAgent) CloseSession(context.Context, acpsdk.CloseSessionRequest) (acpsdk.CloseSessionResponse, error) { - return acpsdk.CloseSessionResponse{}, nil -} - -func (agent *scriptedAgent) ListSessions(context.Context, acpsdk.ListSessionsRequest) (acpsdk.ListSessionsResponse, error) { - return acpsdk.ListSessionsResponse{}, nil -} - -func (agent *scriptedAgent) NewSession(ctx context.Context, request acpsdk.NewSessionRequest) (acpsdk.NewSessionResponse, error) { - agent.state.mu.Lock() - agent.state.newSessions = append(agent.state.newSessions, request) - updates := append([]acpsdk.SessionNotification(nil), agent.state.newSessionUpdates...) - agent.state.mu.Unlock() - for _, update := range updates { - if err := agent.conn.SessionUpdate(ctx, update); err != nil { - return acpsdk.NewSessionResponse{}, err - } - } - agent.state.mu.Lock() - configOptions := agent.state.configOptions - modes := agent.state.modes - agent.state.mu.Unlock() - return acpsdk.NewSessionResponse{SessionId: "session-1", ConfigOptions: configOptions, Modes: modes}, nil -} - -func TestRunPromptHandlesUpdatesSentBeforeNewSessionResponse(t *testing.T) { - state := &scriptedState{ - newSessionUpdates: []acpsdk.SessionNotification{ - {SessionId: "session-1", Update: acpsdk.UpdateAgentMessageText("early ")}, - {SessionId: "session-1", Update: acpsdk.UpdateAgentMessageText("update ")}, - }, - } - tool := testTool(t, state) - var messages []string - tool.OnMessage(func(message *console.AgentMessageAttributes, _ string) { - if message.Role == console.AiRoleAssistant { - messages = append(messages, message.Message) - } - }) - - if err := tool.FollowUpRun(context.Background(), "prompt"); err != nil { - t.Fatalf("prompt: %v", err) - } - - if len(messages) != 1 || messages[0] != "early update response: prompt" { - t.Fatalf("assistant messages = %v, want [early update response: prompt]", messages) - } -} - -func TestRunPromptRejectsMismatchedInitialSessionUpdate(t *testing.T) { - state := &scriptedState{ - newSessionUpdates: []acpsdk.SessionNotification{ - {SessionId: "session-other", Update: acpsdk.UpdateAgentMessageText("wrong session")}, - }, - } - tool := testTool(t, state) - - err := tool.FollowUpRun(context.Background(), "prompt") - if err == nil { - t.Fatal("prompt with mismatched initial session update unexpectedly succeeded") - } - if !strings.Contains(err.Error(), `belongs to session "session-other", expected "session-1"`) { - t.Fatalf("mismatched initial update error = %v", err) - } - if got := tool.sessionIDValue(); got != "" { - t.Fatalf("tool session id = %q, want empty after rejected session", got) - } -} - -func TestSessionUpdateRejectsEmptySessionIDBeforeBinding(t *testing.T) { - turn := newTurn(&Tool{}, "") - - err := turn.handle(acpsdk.SessionNotification{Update: acpsdk.UpdateAgentMessageText("ignored")}) - if err == nil { - t.Fatal("empty session update unexpectedly succeeded") - } - if got := err.Error(); got != "acp session update has an empty session id" { - t.Fatalf("empty session update error = %q", got) - } - if got := turn.sessionID(); got != "" { - t.Fatalf("provisional session id = %q, want empty", got) - } -} - -func (agent *scriptedAgent) Prompt(ctx context.Context, request acpsdk.PromptRequest) (acpsdk.PromptResponse, error) { - prompt := "" - if len(request.Prompt) > 0 && request.Prompt[0].Text != nil { - prompt = request.Prompt[0].Text.Text - } - agent.state.mu.Lock() - agent.state.prompts = append(agent.state.prompts, prompt) - stopReason := agent.state.stopReason - agent.state.mu.Unlock() - if err := agent.conn.SessionUpdate(ctx, acpsdk.SessionNotification{ - SessionId: request.SessionId, - Update: acpsdk.UpdateAgentMessageText("response: " + prompt), - }); err != nil { - return acpsdk.PromptResponse{}, err - } - if stopReason == "" { - stopReason = acpsdk.StopReasonEndTurn - } - return acpsdk.PromptResponse{StopReason: stopReason}, nil -} - -func (agent *scriptedAgent) ResumeSession(_ context.Context, request acpsdk.ResumeSessionRequest) (acpsdk.ResumeSessionResponse, error) { - agent.state.mu.Lock() - agent.state.resumedSessions = append(agent.state.resumedSessions, request) - agent.state.mu.Unlock() - agent.state.mu.Lock() - configOptions := agent.state.configOptions - modes := agent.state.modes - agent.state.mu.Unlock() - return acpsdk.ResumeSessionResponse{ConfigOptions: configOptions, Modes: modes}, nil -} - -func (agent *scriptedAgent) SetSessionConfigOption(_ context.Context, request acpsdk.SetSessionConfigOptionRequest) (acpsdk.SetSessionConfigOptionResponse, error) { - agent.state.mu.Lock() - agent.state.setConfig = append(agent.state.setConfig, request) - configOptions := agent.state.configOptions - agent.state.mu.Unlock() - return acpsdk.SetSessionConfigOptionResponse{ConfigOptions: configOptions}, nil -} - -func (agent *scriptedAgent) SetSessionMode(_ context.Context, request acpsdk.SetSessionModeRequest) (acpsdk.SetSessionModeResponse, error) { - agent.state.mu.Lock() - agent.state.setModes = append(agent.state.setModes, request) - agent.state.mu.Unlock() - return acpsdk.SetSessionModeResponse{}, nil -} - -func scriptedProcess(state *scriptedState) *exec.StdioProcess { - clientToAgentReader, clientToAgentWriter := io.Pipe() - agentToClientReader, agentToClientWriter := io.Pipe() - agent := &scriptedAgent{state: state} - agent.conn = acpsdk.NewAgentSideConnection(agent, agentToClientWriter, clientToAgentReader) - agent.conn.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) - - var closeOnce sync.Once - closePipes := func() { - closeOnce.Do(func() { - _ = clientToAgentWriter.Close() - _ = clientToAgentReader.Close() - _ = agentToClientWriter.Close() - _ = agentToClientReader.Close() - }) - } - return exec.NewStdioProcess(clientToAgentWriter, agentToClientReader, io.NopCloser(strings.NewReader("")), exec.StdioProcessHooks{ - Wait: func() error { - closePipes() - return nil - }, - Kill: func() error { - closePipes() - return nil - }, - Stop: func() error { - closePipes() - return nil - }, - Close: func() error { - closePipes() - return nil - }, - }) -} - -func testTool(t *testing.T, state *scriptedState) *Tool { - t.Helper() - repositoryDir := t.TempDir() - workDir := t.TempDir() - run := &agentrunv1.AgentRun{Prompt: "initial"} - tool := New(toolv1.Config{ - WorkDir: workDir, - RepositoryDir: repositoryDir, - Run: run, - Usage: usage.New(nil), - ErrorChan: make(chan error, 1), - }, WithLauncher(func(context.Context, []exec.Option) (*exec.StdioProcess, error) { - return scriptedProcess(state), nil - })) - return tool -} - -func TestRunPromptCreatesAndResumesSession(t *testing.T) { - state := &scriptedState{} - tool := testTool(t, state) - var messages []string - tool.OnMessage(func(message *console.AgentMessageAttributes, _ string) { - if message.Role == console.AiRoleAssistant { - messages = append(messages, message.Message) - } - }) - - if err := tool.FollowUpRun(context.Background(), "first"); err != nil { - t.Fatalf("first prompt: %v", err) - } - if err := tool.FollowUpRun(context.Background(), "second"); err != nil { - t.Fatalf("resumed prompt: %v", err) - } - - state.mu.Lock() - defer state.mu.Unlock() - if len(state.newSessions) != 1 || len(state.resumedSessions) != 1 { - t.Fatalf("session setup = new %d, resume %d; want one each", len(state.newSessions), len(state.resumedSessions)) - } - if len(state.prompts) != 2 || state.prompts[0] != "first" || state.prompts[1] != "second" { - t.Fatalf("prompts = %v", state.prompts) - } - if got := state.newSessions[0].Cwd; !filepath.IsAbs(got) { - t.Fatalf("new session cwd = %q, want absolute", got) - } - if got := state.resumedSessions[0].Cwd; !filepath.IsAbs(got) { - t.Fatalf("resumed session cwd = %q, want absolute", got) - } - if len(messages) != 2 { - t.Fatalf("assistant messages = %v, want two", messages) - } - if len(state.initializations) != 2 { - t.Fatalf("initializations = %d, want one per process", len(state.initializations)) - } - capabilities := state.initializations[0].ClientCapabilities - if !capabilities.Fs.ReadTextFile || !capabilities.Fs.WriteTextFile || capabilities.Terminal || capabilities.Auth.Terminal { - t.Fatal("unexpected ACP capabilities") - } -} - -func TestRunPromptAppliesOpenCodeModelAndModeConfigOptions(t *testing.T) { - modelOptions := acpsdk.SessionConfigSelectOptions{ - Ungrouped: &acpsdk.SessionConfigSelectOptionsUngrouped{ - {Name: "Default", Value: "provider/default"}, - {Name: "Configured", Value: "provider/configured"}, - }, - } - modeOptions := acpsdk.SessionConfigSelectOptions{ - Ungrouped: &acpsdk.SessionConfigSelectOptionsUngrouped{ - {Name: "Default", Value: "default"}, - {Name: "Analysis", Value: "analysis"}, - }, - } - state := &scriptedState{ - configOptions: []acpsdk.SessionConfigOption{ - {Select: &acpsdk.SessionConfigOptionSelect{Id: "model", CurrentValue: "provider/default", Options: modelOptions}}, - {Select: &acpsdk.SessionConfigOptionSelect{Id: "mode", CurrentValue: "default", Options: modeOptions}}, - }, - } - tool := testTool(t, state) - tool.model = "provider/configured" - tool.mode = "analysis" - - if err := tool.FollowUpRun(context.Background(), "configured"); err != nil { - t.Fatal(err) - } - - state.mu.Lock() - defer state.mu.Unlock() - if len(state.setConfig) != 2 { - t.Fatalf("config option updates = %d, want model and mode", len(state.setConfig)) - } - if got := string(state.setConfig[0].ValueId.Value); got != "provider/configured" { - t.Fatalf("model config value = %q", got) - } - if got := string(state.setConfig[1].ValueId.Value); got != "analysis" { - t.Fatalf("mode config value = %q", got) - } - if len(state.setModes) != 0 { - t.Fatalf("direct mode updates = %d, want config option update", len(state.setModes)) - } -} - -func TestRunUsesLifecycleOptionsOnlyForInitialPrompt(t *testing.T) { - state := &scriptedState{} - tool := testTool(t, state) - var launchOptionCounts []int - tool.launch = func(_ context.Context, options []exec.Option) (*exec.StdioProcess, error) { - state.mu.Lock() - launchOptionCounts = append(launchOptionCounts, len(options)) - state.mu.Unlock() - return scriptedProcess(state), nil - } - initialDone := make(chan struct{}) - tool.OnMessage(func(message *console.AgentMessageAttributes, _ string) { - if message.Role == console.AiRoleAssistant { - select { - case <-initialDone: - default: - close(initialDone) - } - } - }) - - tool.Run(context.Background(), exec.WithArgs([]string{"initial-only"})) - select { - case <-initialDone: - case <-time.After(time.Second): - t.Fatal("initial prompt did not complete") - } - if err := tool.FollowUpRun(context.Background(), "follow-up"); err != nil { - t.Fatal(err) - } - - if len(launchOptionCounts) != 2 { - t.Fatalf("launches = %v, want initial and follow-up", launchOptionCounts) - } - if launchOptionCounts[0] != 1 || launchOptionCounts[1] != 0 { - t.Fatalf("launch option counts = %v, want [1 0]", launchOptionCounts) - } -} - -func TestToolOutputEmitsAccumulatedStdout(t *testing.T) { - state := &scriptedState{} - tool := testTool(t, state) - var outputs []string - tool.OnOutput(func(callID, stdout string) { - if callID == "call-1" { - outputs = append(outputs, stdout) - } - }) - - turn := newTurn(tool, "session-1") - start := acpsdk.StartToolCall( - "call-1", - "shell", - acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress), - acpsdk.WithStartContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("first"))}), - ) - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { - t.Fatal(err) - } - updates := []acpsdk.SessionNotification{ - {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("first\nsecond"))}))}, - {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), acpsdk.WithUpdateContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("first\nsecond\nfinal"))}))}, - } - for _, update := range updates { - if err := turn.handle(update); err != nil { - t.Fatal(err) - } - } - - want := []string{"first", "first\nsecond", "first\nsecond\nfinal"} - if len(outputs) != len(want) { - t.Fatalf("output callbacks = %v, want %v", outputs, want) - } - for i := range want { - if outputs[i] != want[i] { - t.Fatalf("output callback %d = %q, want %q", i, outputs[i], want[i]) - } - } -} - -func TestToolNonterminalMessagesTrackMeaningfulMetadataChanges(t *testing.T) { - state := &scriptedState{} - tool := testTool(t, state) - var messages []*console.AgentMessageAttributes - tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { - if callID == "call-1" { - messages = append(messages, message) - } - }) - - turn := newTurn(tool, "session-1") - start := acpsdk.StartToolCall("call-1", "shell", acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress)) - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { - t.Fatal(err) - } - updates := []acpsdk.SessionNotification{ - {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawOutput("output-only"))}, - {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateTitle("renamed"))}, - {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateTitle("renamed"))}, - {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawInput(map[string]string{"command": "ls"}))}, - {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawInput(map[string]string{"command": "ls"}))}, - {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusPending))}, - {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusPending))}, - } - for _, update := range updates { - if err := turn.handle(update); err != nil { - t.Fatal(err) - } - } - - if len(messages) != 4 { - t.Fatalf("nonterminal messages = %d, want start plus three metadata changes", len(messages)) - } - if got := *messages[1].Metadata.Tool.Name; got != "renamed" { - t.Fatalf("title update = %q, want renamed", got) - } - if got := *messages[2].Metadata.Tool.Input; got != "{\"command\":\"ls\"}" { - t.Fatalf("input update = %q, want command input", got) - } - if got := *messages[3].Metadata.Tool.State; got != console.AgentMessageToolStatePending { - t.Fatalf("status update = %q, want pending", got) - } -} - -func TestToolOutputOnlyUpdatesDoNotRewriteMessage(t *testing.T) { - state := &scriptedState{} - tool := testTool(t, state) - var messages []*console.AgentMessageAttributes - var outputs []string - tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { - if callID == "call-1" { - messages = append(messages, message) - } - }) - tool.OnOutput(func(callID, stdout string) { - if callID == "call-1" { - outputs = append(outputs, stdout) - } - }) - - turn := newTurn(tool, "session-1") - start := acpsdk.StartToolCall("call-1", "shell", acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress)) - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { - t.Fatal(err) - } - for _, output := range []string{"first", "first\nsecond"} { - update := acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawOutput(output)) - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: update}); err != nil { - t.Fatal(err) - } - } - - if len(messages) != 1 { - t.Fatalf("output-only messages = %d, want start message only", len(messages)) - } - if len(outputs) != 2 || outputs[0] != "first" || outputs[1] != "first\nsecond" { - t.Fatalf("output snapshots = %v, want [first first\\nsecond]", outputs) - } -} - -func TestToolStartFallsBackToRawOutput(t *testing.T) { - state := &scriptedState{} - tool := testTool(t, state) - var messageOutput string - var streamedOutput string - tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { - if callID == "call-1" && message.Metadata != nil && message.Metadata.Tool != nil && message.Metadata.Tool.Output != nil { - messageOutput = *message.Metadata.Tool.Output - } - }) - tool.OnOutput(func(callID, stdout string) { - if callID == "call-1" { - streamedOutput = stdout - } - }) - - turn := newTurn(tool, "session-1") - start := acpsdk.StartToolCall( - "call-1", - "shell", - acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress), - acpsdk.WithStartRawOutput(map[string]string{"result": "structured output"}), - ) - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { - t.Fatal(err) - } - - want := "{\"result\":\"structured output\"}" - if messageOutput != want { - t.Fatalf("start message output = %q, want %q", messageOutput, want) - } - if streamedOutput != want { - t.Fatalf("start streamed output = %q, want %q", streamedOutput, want) - } -} - -func TestToolOutputCallbackOrdering(t *testing.T) { - state := &scriptedState{} - tool := testTool(t, state) - var events []string - tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { - state := "message" - if message.Metadata != nil && message.Metadata.Tool != nil && message.Metadata.Tool.State != nil { - state = string(*message.Metadata.Tool.State) - } - events = append(events, state+":"+callID) - }) - tool.OnOutput(func(callID, stdout string) { - events = append(events, "output:"+callID+":"+stdout) - }) - - turn := newTurn(tool, "session-1") - start := acpsdk.StartToolCall( - "call-1", - "shell", - acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress), - acpsdk.WithStartContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("start"))}), - ) - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { - t.Fatal(err) - } - terminal := acpsdk.UpdateToolCall( - "call-1", - acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), - acpsdk.WithUpdateContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("start\nterminal"))}), - ) - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: terminal}); err != nil { - t.Fatal(err) - } - - want := []string{ - "RUNNING:call-1", - "output:call-1:start", - "output:call-1:start\nterminal", - "COMPLETED:call-1", - } - if len(events) != len(want) { - t.Fatalf("callback events = %v, want %v", events, want) - } - for i := range want { - if events[i] != want[i] { - t.Fatalf("callback event %d = %q, want %q", i, events[i], want[i]) - } - } -} - -func TestToolOutputSkipsNonMonotonicSnapshots(t *testing.T) { - state := &scriptedState{} - tool := testTool(t, state) - var outputs []string - var terminalOutput string - tool.OnOutput(func(callID, stdout string) { - if callID == "call-1" { - outputs = append(outputs, stdout) - } - }) - tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { - if callID != "call-1" || message.Metadata == nil || message.Metadata.Tool == nil || message.Metadata.Tool.State == nil || *message.Metadata.Tool.State != console.AgentMessageToolStateCompleted { - return - } - if message.Metadata.Tool.Output != nil { - terminalOutput = *message.Metadata.Tool.Output - } - }) - - turn := newTurn(tool, "session-1") - start := acpsdk.StartToolCall("call-1", "shell", acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress)) - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { - t.Fatal(err) - } - updates := []acpsdk.SessionNotification{ - {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawOutput("abc"))}, - {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateRawOutput("done"))}, - {SessionId: "session-1", Update: acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), acpsdk.WithUpdateRawOutput("ab"))}, - } - for _, update := range updates { - if err := turn.handle(update); err != nil { - t.Fatal(err) - } - } - - if len(outputs) != 1 || outputs[0] != "abc" { - t.Fatalf("output callbacks = %v, want [abc]", outputs) - } - if terminalOutput != "ab" { - t.Fatalf("terminal metadata output = %q, want ab", terminalOutput) - } -} - -func TestToolOutputPrefersContentOverRawOutput(t *testing.T) { - state := &scriptedState{} - tool := testTool(t, state) - var outputs []string - tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { - if callID != "call-1" || message.Metadata == nil || message.Metadata.Tool == nil || message.Metadata.Tool.Output == nil { - return - } - outputs = append(outputs, *message.Metadata.Tool.Output) - }) - - turn := newTurn(tool, "session-1") - start := acpsdk.StartToolCall("call-1", "shell", acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress)) - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { - t.Fatal(err) - } - update := acpsdk.UpdateToolCall( - "call-1", - acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), - acpsdk.WithUpdateContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("display output"))}), - acpsdk.WithUpdateRawOutput(map[string]any{"result": "structured output"}), - ) - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: update}); err != nil { - t.Fatal(err) - } - - if len(outputs) != 2 || outputs[1] != "display output" { - t.Fatalf("outputs = %v, want initial and display output", outputs) - } -} - -func TestToolOutputFallsBackToRawOutputWithoutContent(t *testing.T) { - state := &scriptedState{} - tool := testTool(t, state) - var outputs []string - tool.OnMessage(func(message *console.AgentMessageAttributes, callID string) { - if callID != "call-1" || message.Metadata == nil || message.Metadata.Tool == nil || message.Metadata.Tool.Output == nil { - return - } - outputs = append(outputs, *message.Metadata.Tool.Output) - }) - - turn := newTurn(tool, "session-1") - start := acpsdk.StartToolCall("call-1", "shell", acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress)) - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: start}); err != nil { - t.Fatal(err) - } - update := acpsdk.UpdateToolCall( - "call-1", - acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), - acpsdk.WithUpdateRawOutput(map[string]any{"result": "structured output"}), - ) - if err := turn.handle(acpsdk.SessionNotification{SessionId: "session-1", Update: update}); err != nil { - t.Fatal(err) - } - - if len(outputs) != 2 || outputs[1] != `{"result":"structured output"}` { - t.Fatalf("outputs = %v, want initial and structured JSON output", outputs) - } -} - -func TestPromptStopReasonIsFailure(t *testing.T) { - state := &scriptedState{stopReason: acpsdk.StopReasonRefusal} - tool := testTool(t, state) - if err := tool.FollowUpRun(context.Background(), "refuse"); err == nil { - t.Fatal("refusal stop reason unexpectedly succeeded") - } -} - -func TestNewInitializesUsageForCumulativeCostUpdates(t *testing.T) { - tool := New(toolv1.Config{}) - if tool.Config.Usage == nil { - t.Fatal("ACP tool did not initialize usage") - } - - var message *console.AgentMessageAttributes - tool.OnMessage(func(got *console.AgentMessageAttributes, _ string) { - message = got - }) - turn := newTurn(tool, "session-1") - turn.usageUpdate(&acpsdk.SessionUsageUpdate{Cost: &acpsdk.Cost{Amount: 4}}) - turn.emitAssistant(nil) - - attrs := tool.Config.Usage.Attributes() - if attrs == nil || attrs.TotalCost == nil || *attrs.TotalCost != 4 { - t.Fatalf("recorded total cost = %v, want 4", attrs) - } - if message == nil || message.Cost == nil || message.Cost.Total != 4 { - t.Fatalf("assistant cost = %v, want 4", message) - } -} - -func TestNewPreservesProvidedUsage(t *testing.T) { - provided := usage.New(nil) - tool := New(toolv1.Config{Usage: provided}) - - if tool.Config.Usage != provided { - t.Fatal("ACP tool replaced the provided usage recorder") - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go similarity index 100% rename from go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_client.go rename to go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go new file mode 100644 index 0000000000..8863762867 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -0,0 +1,77 @@ +package acp + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +import acpsdk "github.com/coder/acp-go-sdk" + +func newTestClient(t *testing.T) (*client, string) { + t.Helper() + engine := NewEngine(Config{}) + return &client{turn: newTurn(engine, &testSink{}, "session-1")}, t.TempDir() +} + +func TestClientReadsAndWritesTextFiles(t *testing.T) { + acpClient, directory := newTestClient(t) + path := filepath.Join(directory, "nested", "file.txt") + if _, err := acpClient.WriteTextFile(context.Background(), acpsdk.WriteTextFileRequest{SessionId: "session-1", Path: path, Content: "one\ntwo\nthree\n"}); err != nil { + t.Fatalf("write text file: %v", err) + } + line, limit := 2, 2 + response, err := acpClient.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{SessionId: "session-1", Path: path, Line: &line, Limit: &limit}) + if err != nil { + t.Fatalf("read text file: %v", err) + } + if response.Content != "two\nthree" { + t.Fatalf("read content = %q", response.Content) + } +} + +func TestClientRejectsRelativeAndForeignSessionPaths(t *testing.T) { + acpClient, directory := newTestClient(t) + for _, request := range []acpsdk.ReadTextFileRequest{ + {SessionId: "session-1", Path: "relative.txt"}, + {SessionId: "other", Path: filepath.Join(directory, "file.txt")}, + } { + if _, err := acpClient.ReadTextFile(context.Background(), request); err == nil { + t.Fatalf("read request unexpectedly succeeded: %+v", request) + } + } + for _, request := range []acpsdk.WriteTextFileRequest{ + {SessionId: "session-1", Path: "relative.txt", Content: "content"}, + {SessionId: "other", Path: filepath.Join(directory, "file.txt"), Content: "content"}, + } { + if _, err := acpClient.WriteTextFile(context.Background(), request); err == nil { + t.Fatalf("write request unexpectedly succeeded: %+v", request) + } + } +} + +func TestClientRejectsOversizedAndCanceledReads(t *testing.T) { + acpClient, directory := newTestClient(t) + path := filepath.Join(directory, "large.txt") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(maxTextFileBytes + 1); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if _, err := acpClient.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{SessionId: "session-1", Path: path}); err == nil { + t.Fatal("oversized read unexpectedly succeeded") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = acpClient.ReadTextFile(ctx, acpsdk.ReadTextFileRequest{SessionId: "session-1", Path: path}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled read error = %v", err) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/common.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/common.go deleted file mode 100644 index 995cd09b99..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/common.go +++ /dev/null @@ -1,20 +0,0 @@ -package acp - -import ( - "encoding/json" - "fmt" -) - -func formatValue(value any) string { - if value == nil { - return "" - } - if stringValue, ok := value.(string); ok { - return stringValue - } - encoded, err := json.Marshal(value) - if err != nil { - return fmt.Sprintf("%v", value) - } - return string(encoded) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go new file mode 100644 index 0000000000..4470b75686 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go @@ -0,0 +1,174 @@ +// Package acp contains the provider-neutral Agent Client Protocol engine. +// Providers launch their own processes and project their settings into the +// protocol identifiers consumed here. +package acp + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + acpsdk "github.com/coder/acp-go-sdk" + "k8s.io/klog/v2" + + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" + "github.com/pluralsh/console/go/deployment-operator/pkg/log" +) + +const defaultStopTimeout = 2 * time.Second + +// Engine owns one provider-neutral ACP protocol implementation. It does not +// launch processes or retain provider configuration. +type Engine struct { + stopTimeout time.Duration + costs *usage.Usage +} + +func (engine *Engine) setSessionConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption, settings SessionSettings) error { + if err := engine.setModelConfig(ctx, connection, sessionID, options, settings.ModelID); err != nil { + return err + } + return engine.setModeConfig(ctx, connection, sessionID, modes, options, settings.ModeID) +} + +func (engine *Engine) setModelConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, model string) error { + if model == "" { + return nil + } + found, err := engine.setConfigOption(ctx, connection, sessionID, options, "model", model) + if err != nil { + return err + } + if !found { + klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a model config option") + } + return nil +} + +func (engine *Engine) setModeConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption, mode string) error { + if mode == "" { + return nil + } + if engine.modeAvailable(modes, mode) { + if _, err := connection.SetSessionMode(ctx, acpsdk.SetSessionModeRequest{SessionId: acpsdk.SessionId(sessionID), ModeId: acpsdk.SessionModeId(mode)}); err != nil { + return fmt.Errorf("acp session/set_mode: %w", err) + } + return nil + } + found, err := engine.setConfigOption(ctx, connection, sessionID, options, "mode", mode) + if err != nil { + return err + } + if !found { + klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a mode config option", "mode", mode) + } + return nil +} + +func (*Engine) modeAvailable(modes *acpsdk.SessionModeState, mode string) bool { + if modes == nil { + return false + } + for _, available := range modes.AvailableModes { + if string(available.Id) == mode { + return true + } + } + return false +} + +func (engine *Engine) setConfigOption(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, configID, value string) (bool, error) { + for _, option := range options { + if option.Select == nil || string(option.Select.Id) != configID { + continue + } + wanted := acpsdk.SessionConfigValueId(value) + if option.Select.CurrentValue == wanted { + return true, nil + } + if !engine.configOptionContains(option.Select.Options, wanted) { + return true, fmt.Errorf("acp %s %q is not advertised", configID, value) + } + _, err := connection.SetSessionConfigOption(ctx, acpsdk.SetSessionConfigOptionRequest{ValueId: &acpsdk.SetSessionConfigOptionValueId{ + ConfigId: option.Select.Id, SessionId: acpsdk.SessionId(sessionID), Value: wanted, + }}) + if err != nil { + return true, fmt.Errorf("acp session/set_config_option %s: %w", configID, err) + } + return true, nil + } + return false, nil +} + +func (*Engine) configOptionContains(options acpsdk.SessionConfigSelectOptions, wanted acpsdk.SessionConfigValueId) bool { + if options.Ungrouped != nil { + if slices.ContainsFunc(*options.Ungrouped, func(option acpsdk.SessionConfigSelectOption) bool { + return option.Value == wanted + }) { + return true + } + } + + if options.Grouped == nil { + return false + } + + for _, group := range *options.Grouped { + if slices.ContainsFunc(group.Options, func(option acpsdk.SessionConfigSelectOption) bool { + return option.Value == wanted + }) { + return true + } + } + + return false +} + +// Turn drives one ACP process through initialization, session setup, prompt, +// event mapping, and bounded shutdown. The caller owns process launch and +// must pass a process with usable stdin and stdout streams. +func (engine *Engine) Turn(ctx context.Context, process *exec.StdioProcess, request Request, sink Sink) (Result, error) { + if request.Cwd == "" { + if process != nil { + _ = process.Stop() + _ = process.Wait() + } + return Result{SessionID: request.SessionID}, errors.New("acp working directory is not set") + } + if sink == nil { + if process != nil { + _ = process.Stop() + _ = process.Wait() + } + return Result{}, errors.New("acp turn sink is not set") + } + if process == nil || process.Stdin == nil || process.Stdout == nil { + if process != nil { + _ = process.Stop() + _ = process.Wait() + } + return Result{}, errors.New("acp process is incomplete") + } + if ctx == nil { + ctx = context.Background() + } + attempt := newSessionAttempt(engine, ctx, process, request, sink) + defer attempt.close() + err := attempt.run(request.Prompt) + return Result{SessionID: attempt.sessionID}, err +} + +// NewEngine creates an ACP engine with a bounded process shutdown grace +// period. +func NewEngine(config Config) *Engine { + if config.StopTimeout <= 0 { + config.StopTimeout = defaultStopTimeout + } + return &Engine{ + stopTimeout: config.StopTimeout, + costs: usage.New(nil), + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go new file mode 100644 index 0000000000..16accc2ad7 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -0,0 +1,461 @@ +package acp + +import ( + "context" + "errors" + "io" + "log/slog" + "strings" + "sync" + "testing" + "time" + + acpsdk "github.com/coder/acp-go-sdk" + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" +) + +type testState struct { + mu sync.Mutex + sessionID string + newSessions []acpsdk.NewSessionRequest + resumedSessions []acpsdk.ResumeSessionRequest + prompts []string + initializations []acpsdk.InitializeRequest + setConfig []acpsdk.SetSessionConfigOptionRequest + setModes []acpsdk.SetSessionModeRequest + cancels []acpsdk.CancelNotification + newSessionUpdates []acpsdk.SessionNotification + promptUpdates []acpsdk.SessionUpdate + configOptions []acpsdk.SessionConfigOption + modes *acpsdk.SessionModeState + responseUsage *acpsdk.Usage + stopReason acpsdk.StopReason + promptStarted chan struct{} + promptRelease chan struct{} + promptOnce sync.Once + protocolVersion int +} + +type testAgent struct { + state *testState + conn *acpsdk.AgentSideConnection +} + +func (agent *testAgent) Authenticate(context.Context, acpsdk.AuthenticateRequest) (acpsdk.AuthenticateResponse, error) { + return acpsdk.AuthenticateResponse{}, nil +} + +func (agent *testAgent) Initialize(_ context.Context, request acpsdk.InitializeRequest) (acpsdk.InitializeResponse, error) { + agent.state.mu.Lock() + agent.state.initializations = append(agent.state.initializations, request) + version := agent.state.protocolVersion + agent.state.mu.Unlock() + if version == 0 { + version = acpsdk.ProtocolVersionNumber + } + return acpsdk.InitializeResponse{ProtocolVersion: acpsdk.ProtocolVersion(version)}, nil +} + +func (agent *testAgent) Logout(context.Context, acpsdk.LogoutRequest) (acpsdk.LogoutResponse, error) { + return acpsdk.LogoutResponse{}, nil +} + +func (agent *testAgent) Cancel(_ context.Context, request acpsdk.CancelNotification) error { + agent.state.mu.Lock() + agent.state.cancels = append(agent.state.cancels, request) + agent.state.mu.Unlock() + return nil +} + +func (agent *testAgent) CloseSession(context.Context, acpsdk.CloseSessionRequest) (acpsdk.CloseSessionResponse, error) { + return acpsdk.CloseSessionResponse{}, nil +} + +func (agent *testAgent) ListSessions(context.Context, acpsdk.ListSessionsRequest) (acpsdk.ListSessionsResponse, error) { + return acpsdk.ListSessionsResponse{}, nil +} + +func (agent *testAgent) NewSession(ctx context.Context, request acpsdk.NewSessionRequest) (acpsdk.NewSessionResponse, error) { + agent.state.mu.Lock() + agent.state.newSessions = append(agent.state.newSessions, request) + updates := append([]acpsdk.SessionNotification(nil), agent.state.newSessionUpdates...) + sessionID := agent.state.sessionID + options := append([]acpsdk.SessionConfigOption(nil), agent.state.configOptions...) + modes := agent.state.modes + agent.state.mu.Unlock() + for _, update := range updates { + if err := agent.conn.SessionUpdate(ctx, update); err != nil { + return acpsdk.NewSessionResponse{}, err + } + } + return acpsdk.NewSessionResponse{SessionId: acpsdk.SessionId(sessionID), ConfigOptions: options, Modes: modes}, nil +} + +func (agent *testAgent) Prompt(ctx context.Context, request acpsdk.PromptRequest) (acpsdk.PromptResponse, error) { + prompt := "" + if len(request.Prompt) > 0 && request.Prompt[0].Text != nil { + prompt = request.Prompt[0].Text.Text + } + agent.state.mu.Lock() + agent.state.prompts = append(agent.state.prompts, prompt) + updates := append([]acpsdk.SessionUpdate(nil), agent.state.promptUpdates...) + usageValue := agent.state.responseUsage + stopReason := agent.state.stopReason + release := agent.state.promptRelease + started := agent.state.promptStarted + agent.state.mu.Unlock() + if started != nil { + agent.state.promptOnce.Do(func() { close(started) }) + } + if release != nil { + select { + case <-release: + case <-ctx.Done(): + return acpsdk.PromptResponse{}, ctx.Err() + } + } + for _, update := range updates { + if err := agent.conn.SessionUpdate(ctx, acpsdk.SessionNotification{SessionId: request.SessionId, Update: update}); err != nil { + return acpsdk.PromptResponse{}, err + } + } + if stopReason == "" { + stopReason = acpsdk.StopReasonEndTurn + } + return acpsdk.PromptResponse{StopReason: stopReason, Usage: usageValue}, nil +} + +func (agent *testAgent) ResumeSession(_ context.Context, request acpsdk.ResumeSessionRequest) (acpsdk.ResumeSessionResponse, error) { + agent.state.mu.Lock() + agent.state.resumedSessions = append(agent.state.resumedSessions, request) + options := append([]acpsdk.SessionConfigOption(nil), agent.state.configOptions...) + modes := agent.state.modes + agent.state.mu.Unlock() + return acpsdk.ResumeSessionResponse{ConfigOptions: options, Modes: modes}, nil +} + +func (agent *testAgent) SetSessionConfigOption(_ context.Context, request acpsdk.SetSessionConfigOptionRequest) (acpsdk.SetSessionConfigOptionResponse, error) { + agent.state.mu.Lock() + agent.state.setConfig = append(agent.state.setConfig, request) + agent.state.mu.Unlock() + return acpsdk.SetSessionConfigOptionResponse{}, nil +} + +func (agent *testAgent) SetSessionMode(_ context.Context, request acpsdk.SetSessionModeRequest) (acpsdk.SetSessionModeResponse, error) { + agent.state.mu.Lock() + agent.state.setModes = append(agent.state.setModes, request) + agent.state.mu.Unlock() + return acpsdk.SetSessionModeResponse{}, nil +} + +type testSink struct { + mu sync.Mutex + events []string + sessions []string + messages []*console.AgentMessageAttributes + outputs []string + usages []usage.Record +} + +func (sink *testSink) Session(sessionID string) { + sink.mu.Lock() + sink.sessions = append(sink.sessions, sessionID) + sink.events = append(sink.events, "session:"+sessionID) + sink.mu.Unlock() +} + +func (sink *testSink) Message(message *console.AgentMessageAttributes, callID string) { + sink.mu.Lock() + sink.messages = append(sink.messages, message) + sink.events = append(sink.events, "message:"+callID+":"+message.Message) + sink.mu.Unlock() +} + +func (sink *testSink) ToolCallOutput(callID, output string) { + sink.mu.Lock() + sink.outputs = append(sink.outputs, callID+":"+output) + sink.events = append(sink.events, "output:"+callID+":"+output) + sink.mu.Unlock() +} + +func (sink *testSink) Usage(record usage.Record) { + sink.mu.Lock() + sink.usages = append(sink.usages, record) + sink.events = append(sink.events, "usage") + sink.mu.Unlock() +} + +type testProcess struct { + stdinCloseEnds bool + done chan struct{} + finishOnce sync.Once + pipeCloseOnce sync.Once + stdin *testWriter + clientIn *io.PipeReader + clientOut *io.PipeReader + agentIn *io.PipeReader + agentOut *io.PipeWriter + kills int + mu sync.Mutex +} + +type testWriter struct { + *io.PipeWriter + process *testProcess +} + +func (writer *testWriter) Close() error { + err := writer.PipeWriter.Close() + if writer.process.stdinCloseEnds { + writer.process.finish() + } + return err +} + +func (process *testProcess) finish() { + process.finishOnce.Do(func() { close(process.done) }) +} + +func (process *testProcess) closePipes() { + process.pipeCloseOnce.Do(func() { + _ = process.clientIn.Close() + _ = process.clientOut.Close() + _ = process.agentIn.Close() + _ = process.agentOut.Close() + }) +} + +func (process *testProcess) wait() error { + <-process.done + process.closePipes() + return nil +} + +func (process *testProcess) kill() error { + process.mu.Lock() + process.kills++ + process.mu.Unlock() + process.finish() + process.closePipes() + return nil +} + +func (process *testProcess) close() error { + process.finish() + process.closePipes() + return nil +} + +func newTestProcess(agent *testAgent, stdinCloseEnds bool) (*testProcess, *exec.StdioProcess) { + clientToAgentReader, clientToAgentWriter := io.Pipe() + agentToClientReader, agentToClientWriter := io.Pipe() + process := &testProcess{ + stdinCloseEnds: stdinCloseEnds, + done: make(chan struct{}), + clientIn: clientToAgentReader, + clientOut: agentToClientReader, + agentIn: clientToAgentReader, + agentOut: agentToClientWriter, + } + process.stdin = &testWriter{PipeWriter: clientToAgentWriter, process: process} + agent.conn = acpsdk.NewAgentSideConnection(agent, agentToClientWriter, clientToAgentReader) + agent.conn.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) + stdio := exec.NewStdioProcess(process.stdin, agentToClientReader, io.NopCloser(strings.NewReader("")), exec.StdioProcessHooks{ + Wait: process.wait, + Kill: process.kill, + Stop: process.kill, + Close: process.close, + }) + return process, stdio +} + +func newTestState() *testState { + return &testState{sessionID: "session-1", promptStarted: make(chan struct{})} +} + +func newTestAgentProcess(state *testState, stdinCloseEnds bool) (*testState, *exec.StdioProcess, *testProcess) { + agent := &testAgent{state: state} + process, stdio := newTestProcess(agent, stdinCloseEnds) + return state, stdio, process +} + +func (state *testState) snapshot() (newCount, resumeCount, promptCount, configCount, modeCount, cancelCount int, prompts []string) { + state.mu.Lock() + defer state.mu.Unlock() + return len(state.newSessions), len(state.resumedSessions), len(state.prompts), len(state.setConfig), len(state.setModes), len(state.cancels), append([]string(nil), state.prompts...) +} + +func (state *testState) configValues() (model, mode string) { + state.mu.Lock() + defer state.mu.Unlock() + if len(state.setConfig) > 0 && state.setConfig[0].ValueId != nil { + model = string(state.setConfig[0].ValueId.Value) + } + if len(state.setConfig) > 1 && state.setConfig[1].ValueId != nil { + mode = string(state.setConfig[1].ValueId.Value) + } + return model, mode +} + +func (process *testProcess) killCount() int { + process.mu.Lock() + defer process.mu.Unlock() + return process.kills +} + +func TestEngineTurnCreatesAndResumesSession(t *testing.T) { + state := newTestState() + engine := NewEngine(Config{StopTimeout: time.Second}) + firstSink := &testSink{} + _, firstProcess, _ := newTestAgentProcess(state, true) + first, err := engine.Turn(context.Background(), firstProcess, Request{Cwd: t.TempDir(), Prompt: "first"}, firstSink) + if err != nil { + t.Fatalf("create turn: %v", err) + } + secondSink := &testSink{} + _, secondProcess, _ := newTestAgentProcess(state, true) + second, err := engine.Turn(context.Background(), secondProcess, Request{Cwd: t.TempDir(), Prompt: "second", SessionID: first.SessionID}, secondSink) + if err != nil { + t.Fatalf("resume turn: %v", err) + } + newCount, resumeCount, promptCount, _, _, _, prompts := state.snapshot() + if newCount != 1 || resumeCount != 1 || promptCount != 2 || second.SessionID != first.SessionID { + t.Fatalf("sessions = new %d resume %d prompts %d result %q", newCount, resumeCount, promptCount, second.SessionID) + } + if strings.Join(prompts, ",") != "first,second" { + t.Fatalf("prompts = %v", prompts) + } +} + +func TestEngineTurnAppliesModelAndModeConfig(t *testing.T) { + state := newTestState() + state.configOptions = []acpsdk.SessionConfigOption{ + {Select: &acpsdk.SessionConfigOptionSelect{Id: "model", CurrentValue: "default", Options: acpsdk.SessionConfigSelectOptions{Ungrouped: &acpsdk.SessionConfigSelectOptionsUngrouped{{Value: "default"}, {Value: "configured"}}}}}, + {Select: &acpsdk.SessionConfigOptionSelect{Id: "mode", CurrentValue: "default", Options: acpsdk.SessionConfigSelectOptions{Ungrouped: &acpsdk.SessionConfigSelectOptionsUngrouped{{Value: "default"}, {Value: "analysis"}}}}}, + } + engine := NewEngine(Config{}) + _, process, _ := newTestAgentProcess(state, true) + _, err := engine.Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "configure", Settings: SessionSettings{ModelID: "configured", ModeID: "analysis"}}, &testSink{}) + if err != nil { + t.Fatalf("configured turn: %v", err) + } + model, mode := state.configValues() + if model != "configured" || mode != "analysis" { + t.Fatalf("config values = %q, %q", model, mode) + } +} + +func TestEngineTurnStreamsMessagesToolsUsageAndOrdering(t *testing.T) { + state := newTestState() + cached, thought := 2, 3 + state.responseUsage = &acpsdk.Usage{InputTokens: 10, OutputTokens: 5, CachedReadTokens: &cached, ThoughtTokens: &thought} + state.promptUpdates = []acpsdk.SessionUpdate{ + acpsdk.UpdateAgentMessageText("hello "), + acpsdk.UpdateAgentThoughtText("thinking"), + acpsdk.StartToolCall("call-1", "shell", acpsdk.WithStartStatus(acpsdk.ToolCallStatusInProgress), acpsdk.WithStartContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("first"))})), + acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("first\nsecond"))})), + acpsdk.UpdateToolCall("call-1", acpsdk.WithUpdateStatus(acpsdk.ToolCallStatusCompleted), acpsdk.WithUpdateContent([]acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("first\nsecond\nfinal"))})), + acpsdk.UpdateAgentMessageText("done"), + } + state.promptUpdates = append(state.promptUpdates, acpsdk.SessionUpdate{UsageUpdate: &acpsdk.SessionUsageUpdate{Cost: &acpsdk.Cost{Amount: 4}}}) + state.promptUpdates = append(state.promptUpdates, acpsdk.SessionUpdate{UsageUpdate: &acpsdk.SessionUsageUpdate{Cost: &acpsdk.Cost{Amount: 7}}}) + sink := &testSink{} + _, process, _ := newTestAgentProcess(state, true) + if _, err := NewEngine(Config{}).Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "stream"}, sink); err != nil { + t.Fatalf("streaming turn: %v", err) + } + sink.mu.Lock() + events := append([]string(nil), sink.events...) + usages := append([]usage.Record(nil), sink.usages...) + messages := append([]*console.AgentMessageAttributes(nil), sink.messages...) + sink.mu.Unlock() + if len(messages) < 3 || messages[len(messages)-1].Message != "hello done" { + t.Fatalf("messages = %+v", messages) + } + if messages[len(messages)-1].Cost == nil || messages[len(messages)-1].Cost.Total != 7 { + t.Fatalf("assistant cumulative cost = %+v", messages[len(messages)-1].Cost) + } + want := []string{"output:call-1:first", "output:call-1:first\nsecond", "output:call-1:first\nsecond\nfinal"} + for _, event := range want { + found := false + for _, got := range events { + if got == event { + found = true + break + } + } + if !found { + t.Fatalf("events = %v, missing %q", events, event) + } + } + if len(usages) != 3 || usages[0].TotalCost != 4 || usages[1].TotalCost != 3 || usages[2].InputTokens != 10 || usages[2].TotalTokens != 18 { + t.Fatalf("usage events = %+v", usages) + } + startMessage, terminalMessage, firstOutput, secondOutput := -1, -1, -1, -1 + for i, event := range events { + switch event { + case "message:call-1:Called tool": + if startMessage == -1 { + startMessage = i + } else { + terminalMessage = i + } + case "output:call-1:first": + firstOutput = i + case "output:call-1:first\nsecond": + secondOutput = i + } + } + if startMessage < 0 || firstOutput < startMessage || secondOutput < firstOutput || terminalMessage < secondOutput { + t.Fatalf("tool events are out of order: %v", events) + } +} + +func TestEngineTurnRejectsMismatchedEarlyBinding(t *testing.T) { + state := newTestState() + state.newSessionUpdates = []acpsdk.SessionNotification{{SessionId: "other", Update: acpsdk.UpdateAgentMessageText("wrong")}} + _, process, _ := newTestAgentProcess(state, true) + _, err := NewEngine(Config{}).Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "mismatch"}, &testSink{}) + if err == nil || !strings.Contains(err.Error(), `belongs to session "other"`) { + t.Fatalf("mismatch error = %v", err) + } +} + +func TestEngineTurnCancellationKillsUncooperativeProcess(t *testing.T) { + state := newTestState() + state.promptRelease = make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + processState, process, processFixture := newTestAgentProcess(state, false) + startedAt := time.Now() + result := make(chan error, 1) + go func() { + _, err := NewEngine(Config{StopTimeout: 20 * time.Millisecond}).Turn(ctx, process, Request{Cwd: t.TempDir(), Prompt: "cancel"}, &testSink{}) + result <- err + }() + select { + case <-processState.promptStarted: + cancel() + case <-time.After(time.Second): + t.Fatal("prompt did not start") + } + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation error = %v", err) + } + if time.Since(startedAt) > 500*time.Millisecond { + t.Fatalf("canceled turn exceeded bounded shutdown: %v", time.Since(startedAt)) + } + case <-time.After(time.Second): + t.Fatal("canceled turn did not stop") + } + if processFixture.killCount() == 0 { + t.Fatal("cancellation did not kill an uncooperative process") + } + _, _, _, _, _, cancelCount, _ := processState.snapshot() + if cancelCount == 0 { + t.Fatal("cancellation did not send session/cancel") + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode.go deleted file mode 100644 index ee9f16982b..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode.go +++ /dev/null @@ -1,64 +0,0 @@ -package acp - -import ( - "context" - "fmt" - "path/filepath" - - console "github.com/pluralsh/console/go/client" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/opencode" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" -) - -// NewOpenCode creates the OpenCode provider adapter for the provider-neutral -// ACP tool. OpenCode-specific configuration and native export remain owned by -// the opencode package; ACP session lifecycle stays in this package. -func NewOpenCode(config toolv1.Config) toolv1.Tool { - settings := opencode.ResolveACPSettings(config) - repositoryDir := config.RepositoryDir - if absolute, err := filepath.Abs(repositoryDir); err == nil { - repositoryDir = absolute - } - mode := opencode.DefaultWriteAgent - if config.Run.Mode == console.AgentRunModeAnalyze { - mode = opencode.DefaultAnalysisAgent - } - - return New(config, - WithProviderName("opencode"), - WithMode(mode), - WithModel(settings.Provider+"/"+settings.Model), - WithConfigure(func(consoleURL, consoleToken string) error { - return opencode.Configure(config, consoleURL, consoleToken, opencode.Provider(settings.Provider), settings.Model, settings.OpenAICompatible) - }), - WithBabysitConfigure(func() error { - defaultTool := toolv1.DefaultTool{Config: config} - if err := defaultTool.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeOpencode); err != nil { - return err - } - return defaultTool.ConfigureSkills(opencode.ACPSkillsPath(config)) - }), - WithLauncher(func(ctx context.Context, options []exec.Option) (*exec.StdioProcess, error) { - configPath, err := filepath.Abs(opencode.ACPConfigPath(config)) - if err != nil { - return nil, fmt.Errorf("resolve opencode ACP config: %w", err) - } - options = append([]exec.Option(nil), options...) - options = append(options, - exec.WithArgs([]string{"acp"}), - exec.WithEnv(opencode.ACPEnvironment(config, configPath)), - exec.WithDir(repositoryDir), - exec.WithTimeout(config.Run.Runtime.Config.OpenCode.Timeout), - ) - // ACP owns cancellation ordering: send session/cancel first, then - // close stdin and kill if the process does not exit. Do not attach - // the prompt context directly to CommandContext, which would kill - // OpenCode before the ACP cancellation request is delivered. - return exec.StartWithStdio(context.Background(), "opencode", options...) - }), - WithExporter(func(ctx context.Context, outputPath, sessionID string) error { - return opencode.ExportSession(ctx, config, sessionID, outputPath) - }), - ) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode_test.go deleted file mode 100644 index 4d75109f5a..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/opencode_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package acp - -import ( - "testing" - - console "github.com/pluralsh/console/go/client" - agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" -) - -func TestNewOpenCodeSelectsConfiguredAgentForRunMode(t *testing.T) { - for _, test := range []struct { - name string - mode console.AgentRunMode - want string - }{ - {name: "write", mode: console.AgentRunModeWrite, want: "autonomous"}, - {name: "analyze", mode: console.AgentRunModeAnalyze, want: "analysis"}, - } { - t.Run(test.name, func(t *testing.T) { - config := toolv1.Config{Run: &agentrunv1.AgentRun{ - Mode: test.mode, - Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{ - OpenCode: &agentrunv1.OpencodeConfig{}, - }}, - }} - tool := NewOpenCode(config).(*Tool) - if tool.mode != test.want { - t.Fatalf("ACP mode = %q, want configured OpenCode agent %q", tool.mode, test.want) - } - }) - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go similarity index 83% rename from go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go rename to go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go index e4e3498fce..69c5b79030 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_session.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go @@ -7,7 +7,6 @@ import ( "io" "log/slog" "os" - "path/filepath" "time" acpsdk "github.com/coder/acp-go-sdk" @@ -17,44 +16,16 @@ import ( "github.com/pluralsh/console/go/deployment-operator/pkg/log" ) -func (tool *Tool) runPrompt(ctx context.Context, prompt string) error { - return tool.runPromptWithOptions(ctx, prompt, nil) -} - -func (tool *Tool) runPromptWithOptions(ctx context.Context, prompt string, options []exec.Option) error { - if ctx == nil { - ctx = context.Background() - } - if err := tool.validate(); err != nil { - return err - } - - return tool.runAttempt(ctx, prompt, options) -} - -func (tool *Tool) runAttempt(ctx context.Context, prompt string, options []exec.Option) error { - if ctx == nil { - ctx = context.Background() - } - cwd, err := filepath.Abs(tool.Config.RepositoryDir) - if err != nil { - return fmt.Errorf("resolve acp repository directory: %w", err) - } - attempt, err := tool.startAttempt(ctx, options) - if err != nil { - return err - } - defer attempt.close() - return attempt.run(cwd, prompt) -} - type sessionAttempt struct { - tool *Tool + engine *Engine ctx context.Context process *exec.StdioProcess connection *acpsdk.ClientSideConnection turn *turnState + settings SessionSettings + cwd string priorSessionID string + sessionID string } type sessionDetails struct { @@ -63,24 +34,7 @@ type sessionDetails struct { configOptions []acpsdk.SessionConfigOption } -func (attempt *sessionAttempt) drainStderr() { - if attempt.process.Stderr == nil { - return - } - go func() { - if _, err := io.Copy(io.Discard, attempt.process.Stderr); err != nil && !errors.Is(err, io.ErrClosedPipe) { - klog.V(log.LogLevelDebug).InfoS("ACP stderr drain ended", "error", err) - } - }() -} - -func (attempt *sessionAttempt) close() { - // The process is stopped explicitly during the run. This final guard - // handles setup failures and keeps test launchers from leaking children. - _ = attempt.process.Close() -} - -func (attempt *sessionAttempt) run(cwd, prompt string) error { +func (attempt *sessionAttempt) run(prompt string) error { initialize, err := attempt.initialize() if err != nil { return attempt.fail(fmt.Errorf("acp initialize: %w", err), attempt.cancelled()) @@ -89,7 +43,7 @@ func (attempt *sessionAttempt) run(cwd, prompt string) error { return attempt.fail(fmt.Errorf("acp protocol version %d is unsupported", initialize.ProtocolVersion), false) } - details, err := attempt.openSession(cwd) + details, err := attempt.openSession(attempt.cwd) if err != nil { return attempt.fail(err, attempt.cancelled()) } @@ -118,9 +72,9 @@ func (attempt *sessionAttempt) run(cwd, prompt string) error { } func (attempt *sessionAttempt) configureSession(details sessionDetails) error { - err := attempt.tool.setSessionConfig(attempt.ctx, attempt.connection, details.sessionID, details.modes, details.configOptions) + err := attempt.engine.setSessionConfig(attempt.ctx, attempt.connection, details.sessionID, details.modes, details.configOptions, attempt.settings) if err != nil && attempt.priorSessionID == "" { - attempt.tool.setSessionID("") + attempt.turn.setSessionID("") } return err } @@ -137,14 +91,13 @@ func (attempt *sessionAttempt) initialize() (acpsdk.InitializeResponse, error) { ReadTextFile: true, WriteTextFile: true, }, - Terminal: false, - Auth: acpsdk.AuthCapabilities{}, + Auth: acpsdk.AuthCapabilities{}, }, }) } func (attempt *sessionAttempt) openSession(cwd string) (sessionDetails, error) { - existingSession := attempt.tool.sessionIDValue() + existingSession := attempt.priorSessionID if existingSession == "" { return attempt.createSession(cwd) } @@ -166,8 +119,9 @@ func (attempt *sessionAttempt) createSession(cwd string) (sessionDetails, error) if provisionalID := attempt.turn.sessionID(); provisionalID != "" && provisionalID != sessionID { return sessionDetails{}, attempt.turn.sessionUpdateMismatch(acpsdk.SessionId(provisionalID), sessionID) } - attempt.tool.setSessionID(sessionID) attempt.turn.setSessionID(sessionID) + attempt.sessionID = sessionID + attempt.turn.sink.Session(sessionID) return sessionDetails{ sessionID: sessionID, modes: created.Modes, @@ -185,6 +139,8 @@ func (attempt *sessionAttempt) resumeSession(cwd, sessionID string) (sessionDeta return sessionDetails{}, fmt.Errorf("acp session/resume: %w", err) } attempt.turn.setSessionID(sessionID) + attempt.sessionID = sessionID + attempt.turn.sink.Session(sessionID) return sessionDetails{ sessionID: sessionID, modes: resumed.Modes, @@ -203,6 +159,23 @@ func (attempt *sessionAttempt) finishTurn(usage *acpsdk.Usage) { attempt.turn.emitAssistant(usage) } +func (attempt *sessionAttempt) drainStderr() { + if attempt.process.Stderr == nil { + return + } + go func() { + if _, err := io.Copy(io.Discard, attempt.process.Stderr); err != nil && !errors.Is(err, io.ErrClosedPipe) { + klog.V(log.LogLevelDebug).InfoS("ACP stderr drain ended", "error", err) + } + }() +} + +func (attempt *sessionAttempt) close() { + // The process is stopped explicitly during the run. This final guard + // handles setup failures and keeps test launchers from leaking children. + _ = attempt.process.Close() +} + func (attempt *sessionAttempt) promptFailure(err error) error { cancelled := attempt.cancelled() _ = attempt.stop(cancelled) @@ -252,7 +225,7 @@ func (attempt *sessionAttempt) cancelSession() { if sessionID == "" { return } - cancelCtx, cancel := context.WithTimeout(context.Background(), attempt.tool.stopTimeout) + cancelCtx, cancel := context.WithTimeout(context.Background(), attempt.engine.stopTimeout) err := attempt.connection.Cancel(cancelCtx, acpsdk.CancelNotification{SessionId: acpsdk.SessionId(sessionID)}) cancel() if err != nil { @@ -263,7 +236,7 @@ func (attempt *sessionAttempt) cancelSession() { func (attempt *sessionAttempt) waitForExit() error { waitCh := make(chan error, 1) go func() { waitCh <- attempt.process.Wait() }() - timer := time.NewTimer(attempt.tool.stopTimeout) + timer := time.NewTimer(attempt.engine.stopTimeout) defer timer.Stop() select { case waitErr := <-waitCh: @@ -294,15 +267,18 @@ func (attempt *sessionAttempt) promptResult(reason acpsdk.StopReason) error { } } -func newSessionAttempt(tool *Tool, ctx context.Context, process *exec.StdioProcess, priorSessionID string) *sessionAttempt { - turn := newTurn(tool, priorSessionID) +func newSessionAttempt(engine *Engine, ctx context.Context, process *exec.StdioProcess, request Request, sink Sink) *sessionAttempt { + turn := newTurn(engine, sink, request.SessionID) attempt := &sessionAttempt{ - tool: tool, + engine: engine, ctx: ctx, process: process, connection: acpsdk.NewClientSideConnection(&client{turn: turn}, process.Stdin, process.Stdout), turn: turn, - priorSessionID: priorSessionID, + settings: request.Settings, + cwd: request.Cwd, + priorSessionID: request.SessionID, + sessionID: request.SessionID, } attempt.connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) attempt.drainStderr() diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go new file mode 100644 index 0000000000..c1cfa38c6f --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go @@ -0,0 +1,55 @@ +package acp + +import ( + "context" + "strings" + "testing" + + acpsdk "github.com/coder/acp-go-sdk" +) + +func TestEngineTurnDeliversUpdatesSentBeforeSessionResponse(t *testing.T) { + state := newTestState() + state.newSessionUpdates = []acpsdk.SessionNotification{ + {SessionId: "session-1", Update: acpsdk.UpdateAgentMessageText("early ")}, + } + state.promptUpdates = []acpsdk.SessionUpdate{acpsdk.UpdateAgentMessageText("response")} + sink := &testSink{} + _, process, _ := newTestAgentProcess(state, true) + if _, err := NewEngine(Config{}).Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "prompt"}, sink); err != nil { + t.Fatalf("early update turn: %v", err) + } + sink.mu.Lock() + defer sink.mu.Unlock() + if len(sink.messages) != 1 || sink.messages[0].Message != "early response" { + t.Fatalf("assistant messages = %+v", sink.messages) + } +} + +func TestEngineTurnUsesAdvertisedSessionMode(t *testing.T) { + state := newTestState() + state.modes = &acpsdk.SessionModeState{ + AvailableModes: []acpsdk.SessionMode{{Id: "analysis"}}, + CurrentModeId: "default", + } + _, process, _ := newTestAgentProcess(state, true) + if _, err := NewEngine(Config{}).Turn(context.Background(), process, Request{ + Cwd: t.TempDir(), Prompt: "mode", Settings: SessionSettings{ModeID: "analysis"}, + }, &testSink{}); err != nil { + t.Fatalf("mode turn: %v", err) + } + _, _, _, configCount, modeCount, _, _ := state.snapshot() + if configCount != 0 || modeCount != 1 { + t.Fatalf("mode configuration = config %d, direct mode %d", configCount, modeCount) + } +} + +func TestEngineTurnRejectsUnsupportedProtocolVersion(t *testing.T) { + state := newTestState() + state.protocolVersion = acpsdk.ProtocolVersionNumber + 1 + _, process, _ := newTestAgentProcess(state, true) + _, err := NewEngine(Config{}).Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "version"}, &testSink{}) + if err == nil || !strings.Contains(err.Error(), "protocol version") { + t.Fatalf("protocol version error = %v", err) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go similarity index 58% rename from go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go rename to go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go index 4b1f314a46..90b8e898bc 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_mapping.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go @@ -1,19 +1,83 @@ package acp import ( + "encoding/json" "fmt" + "strings" acpsdk "github.com/coder/acp-go-sdk" console "github.com/pluralsh/console/go/client" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" ) +const runningToolOutput = "running..." + +type toolCall struct { + id string + name string + input string + output string + state console.AgentMessageToolState +} + +func (call *toolCall) addOutput(output string) { + if output == "" || output == call.output { + return + } + call.output = output +} + +func (*toolCall) formatValue(value any) string { + if value == nil { + return "" + } + if stringValue, ok := value.(string); ok { + return stringValue + } + encoded, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(encoded) +} + +func (call *toolCall) contentOutput(content []acpsdk.ToolCallContent) string { + var builder strings.Builder + for _, item := range content { + switch { + case item.Content != nil: + if item.Content.Content.Text != nil { + builder.WriteString(item.Content.Content.Text.Text) + } + case item.Diff != nil: + builder.WriteString(item.Diff.NewText) + case item.Terminal != nil: + builder.WriteString(item.Terminal.TerminalId) + } + } + return builder.String() +} + +func (call *toolCall) toolOutput(content []acpsdk.ToolCallContent, rawOutput any) string { + output := call.contentOutput(content) + if output == "" && rawOutput != nil { + return call.formatValue(rawOutput) + } + return output +} + +type toolUpdateEvents struct { + message *console.AgentMessageAttributes + output string + streamOutput bool + terminal bool +} + func (call *toolCall) message() *console.AgentMessageAttributes { name := call.name output := call.output if output == "" && (call.state == console.AgentMessageToolStateRunning || call.state == console.AgentMessageToolStatePending) { - output = toolv1.RunningToolOutput + output = runningToolOutput } message := &console.AgentMessageAttributes{ Role: console.AiRoleAssistant, @@ -48,7 +112,7 @@ func (call *toolCall) updateMetadata(update *acpsdk.SessionToolCallUpdate) bool changed = true } if update.RawInput != nil { - input := formatValue(update.RawInput) + input := call.formatValue(update.RawInput) if call.input != input { call.input = input changed = true diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go new file mode 100644 index 0000000000..d2e4a8f1aa --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go @@ -0,0 +1,61 @@ +package acp + +import ( + "testing" + + acpsdk "github.com/coder/acp-go-sdk" + console "github.com/pluralsh/console/go/client" +) + +func TestToolCallPrefersContentAndFormatsRawOutput(t *testing.T) { + call := &toolCall{state: console.AgentMessageToolStateRunning} + content := []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("displayed"))} + if got := call.toolOutput(content, map[string]string{"result": "raw"}); got != "displayed" { + t.Fatalf("content output = %q", got) + } + if got := call.toolOutput(nil, map[string]string{"result": "raw"}); got != `{"result":"raw"}` { + t.Fatalf("raw output = %q", got) + } + call.setName("", acpsdk.ToolKindExecute) + if call.name != string(acpsdk.ToolKindExecute) { + t.Fatalf("fallback tool name = %q", call.name) + } +} + +func TestToolCallMessageUsesRunningOutputAndInput(t *testing.T) { + call := &toolCall{name: "shell", input: `{"command":"ls"}`, state: console.AgentMessageToolStateRunning} + message := call.message() + if message.Metadata == nil || message.Metadata.Tool == nil { + t.Fatal("tool metadata missing") + } + if *message.Metadata.Tool.Output != runningToolOutput { + t.Fatalf("running output = %q", *message.Metadata.Tool.Output) + } + if *message.Metadata.Tool.Input != `{"command":"ls"}` { + t.Fatalf("tool input = %q", *message.Metadata.Tool.Input) + } +} + +func TestToolCallStatusMapping(t *testing.T) { + call := &toolCall{} + for _, test := range []struct { + status acpsdk.ToolCallStatus + state console.AgentMessageToolState + end bool + }{ + {acpsdk.ToolCallStatusPending, console.AgentMessageToolStatePending, false}, + {acpsdk.ToolCallStatusInProgress, console.AgentMessageToolStateRunning, false}, + {acpsdk.ToolCallStatusCompleted, console.AgentMessageToolStateCompleted, true}, + {acpsdk.ToolCallStatusFailed, console.AgentMessageToolStateError, true}, + } { + status := test.status + end, changed, err := call.updateStatus(&status) + if err != nil || !changed || end != test.end || call.state != test.state { + t.Fatalf("status %q = end %v changed %v state %q err %v", test.status, end, changed, call.state, err) + } + } + unknown := acpsdk.ToolCallStatus("unknown") + if _, _, err := call.updateStatus(&unknown); err == nil { + t.Fatal("unknown status unexpectedly succeeded") + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go new file mode 100644 index 0000000000..93cf141211 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go @@ -0,0 +1,40 @@ +package acp + +import ( + "time" + + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" +) + +// SessionSettings are the ACP-native identifiers projected by a provider. +type SessionSettings struct { + ModeID string + ModelID string +} + +// Request contains the provider-neutral inputs for one ACP turn. +type Request struct { + Cwd string + Prompt string + SessionID string + Settings SessionSettings +} + +// Result contains the latest session state observed by the ACP engine. +type Result struct { + SessionID string +} + +// Sink receives provider-neutral events from an ACP turn. +type Sink interface { + Session(string) + Message(*console.AgentMessageAttributes, string) + ToolCallOutput(string, string) + Usage(usage.Record) +} + +// Config controls the ACP process shutdown grace period. +type Config struct { + StopTimeout time.Duration +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go similarity index 79% rename from go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go rename to go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go index 7d0c9b37b7..21f04746b2 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/acp_turn.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go @@ -14,30 +14,9 @@ import ( "github.com/pluralsh/console/go/deployment-operator/pkg/log" ) -type toolCall struct { - id string - name string - input string - output string - state console.AgentMessageToolState -} - -func (call *toolCall) addOutput(output string) { - if output == "" || output == call.output { - return - } - call.output = output -} - -type toolUpdateEvents struct { - message *console.AgentMessageAttributes - output string - streamOutput bool - terminal bool -} - type turnState struct { - tool *Tool + engine *Engine + sink Sink mu sync.Mutex sessionIDValue string errValue error @@ -54,31 +33,6 @@ func (turn *turnState) contentText(content acpsdk.ContentBlock) (string, error) return "", errors.New("expected text content") } -func (turn *turnState) contentOutput(content []acpsdk.ToolCallContent) string { - var builder strings.Builder - for _, item := range content { - switch { - case item.Content != nil: - if item.Content.Content.Text != nil { - builder.WriteString(item.Content.Content.Text.Text) - } - case item.Diff != nil: - builder.WriteString(item.Diff.NewText) - case item.Terminal != nil: - builder.WriteString(item.Terminal.TerminalId) - } - } - return builder.String() -} - -func (turn *turnState) toolOutput(content []acpsdk.ToolCallContent, rawOutput any) string { - output := turn.contentOutput(content) - if output == "" && rawOutput != nil { - return formatValue(rawOutput) - } - return output -} - func (turn *turnState) normalizeUsage(providerUsage *acpsdk.Usage) (input, output, total, cached, thought int64) { input = int64(max(providerUsage.InputTokens, 0)) output = int64(max(providerUsage.OutputTokens, 0)) @@ -206,22 +160,22 @@ func (turn *turnState) startTool(update *acpsdk.SessionUpdateToolCall) error { turn.mu.Unlock() return turn.fail(fmt.Sprintf("acp tool call %q was started twice", id)) } - call := &toolCall{ - id: id, - input: formatValue(update.RawInput), - } + call := &toolCall{id: id} + call.input = call.formatValue(update.RawInput) call.setName(update.Title, update.Kind) if _, _, err := call.updateStatus(&update.Status); err != nil { turn.mu.Unlock() return turn.fail(err.Error()) } - call.output = turn.toolOutput(update.Content, update.RawOutput) + call.output = call.toolOutput(update.Content, update.RawOutput) turn.tools[id] = call message := call.message() output := call.output turn.mu.Unlock() - turn.tool.emit(message, id) - turn.tool.EmitOutput(id, output) + turn.sink.Message(message, id) + if output != "" { + turn.sink.ToolCallOutput(id, output) + } return nil } @@ -245,7 +199,7 @@ func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (to } metadataChanged := call.updateMetadata(update) previousOutput := call.output - if output := turn.toolOutput(update.Content, update.RawOutput); output != "" { + if output := call.toolOutput(update.Content, update.RawOutput); output != "" { call.addOutput(output) } streamOutput := call.output != previousOutput && (previousOutput == "" || strings.HasPrefix(call.output, previousOutput)) @@ -270,14 +224,12 @@ func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (to } func (turn *turnState) emitToolUpdate(id acpsdk.ToolCallId, events toolUpdateEvents) { - if events.terminal && events.streamOutput { - turn.tool.EmitOutput(string(id), events.output) + if events.streamOutput { + turn.sink.ToolCallOutput(string(id), events.output) } + if events.message != nil { - turn.tool.emit(events.message, string(id)) - } - if !events.terminal && events.streamOutput { - turn.tool.EmitOutput(string(id), events.output) + turn.sink.Message(events.message, string(id)) } } @@ -296,12 +248,10 @@ func (turn *turnState) emitAssistant(responseUsage *acpsdk.Usage) { } if responseUsage != nil { input, output, total, cached, thought := turn.normalizeUsage(responseUsage) - if turn.tool.Config.Usage != nil { - turn.tool.Config.Usage.RecordUsage(usage.Record{ - InputTokens: input, OutputTokens: output, TotalTokens: total, - CachedTokens: cached, ReasoningTokens: thought, - }) - } + turn.sink.Usage(usage.Record{ + InputTokens: input, OutputTokens: output, TotalTokens: total, + CachedTokens: cached, ReasoningTokens: thought, + }) inputValue := float64(input) outputValue := float64(output) thoughtValue := float64(thought) @@ -323,7 +273,7 @@ func (turn *turnState) emitAssistant(responseUsage *acpsdk.Usage) { } message.Message = "__plrl_ignore__" } - turn.tool.emit(message, "") + turn.sink.Message(message, "") } func (turn *turnState) usageUpdate(update *acpsdk.SessionUsageUpdate) { @@ -331,10 +281,11 @@ func (turn *turnState) usageUpdate(update *acpsdk.SessionUsageUpdate) { klog.V(log.LogLevelDebug).InfoS("ACP usage update omitted optional cost") return } - delta := turn.tool.Config.Usage.RecordCumulativeCost(turn.sessionID(), update.Cost.Amount) + delta := turn.engine.costs.RecordCumulativeCost(turn.sessionID(), update.Cost.Amount) if delta <= 0 { return } + turn.sink.Usage(usage.Record{TotalCost: delta}) turn.mu.Lock() turn.cost += delta turn.mu.Unlock() @@ -346,9 +297,10 @@ func (turn *turnState) fail(message string) error { return err } -func newTurn(tool *Tool, sessionID string) *turnState { +func newTurn(engine *Engine, sink Sink, sessionID string) *turnState { return &turnState{ - tool: tool, + engine: engine, + sink: sink, sessionIDValue: sessionID, tools: make(map[string]*toolCall), } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent.go new file mode 100644 index 0000000000..83a17de431 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent.go @@ -0,0 +1,225 @@ +package opencode + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +// Agent owns the provider-specific settings and configuration for OpenCode. +// Turn execution remains the responsibility of a v1.Transport. +type Agent struct { + config toolv1.Config +} + +var _ toolv1.Agent = (*Agent)(nil) + +// NewAgent creates an OpenCode Agent for one agent run. Credentials in the run +// configuration are used only while writing provider configuration; resolved +// settings never contain credentials. +func NewAgent(config toolv1.Config) *Agent { + return &Agent{config: config} +} + +// Type identifies the Console runtime implemented by Agent. +func (*Agent) Type() console.AgentRuntimeType { + return console.AgentRuntimeTypeOpencode +} + +// Capabilities advertises the modes supported by OpenCode's configured agents. +func (*Agent) Capabilities() toolv1.AgentCapabilities { + return toolv1.AgentCapabilities{Modes: []console.AgentRunMode{ + console.AgentRunModeAnalyze, + console.AgentRunModeWrite, + console.AgentRunModeReview, + }} +} + +// ResolveSettings resolves provider/model defaults without copying credentials +// into the provider-neutral runtime settings. +func (agent *Agent) ResolveSettings(run *agentrunv1.AgentRun) (toolv1.Settings, error) { + openCode, err := agent.runConfig(run) + if err != nil { + return toolv1.Settings{}, err + } + + resolved := agent.resolveSettings(openCode.Provider, openCode.Model, openCode.OpenAICompatible, run.IsProxyEnabled()) + return toolv1.Settings{ + Mode: run.Mode, + Model: toolv1.ModelSelection{ + Provider: agent.aiProvider(resolved.provider), + Name: resolved.model, + }, + Timeout: openCode.Timeout, + Proxy: run.IsProxyEnabled(), + }, nil +} + +// Prepare writes the OpenCode system prompt and run skills for a phase. +func (agent *Agent) Prepare(ctx context.Context, request toolv1.FileSystemRequest) error { + if ctx != nil && ctx.Err() != nil { + return ctx.Err() + } + config, err := agent.configForFilesystem(request) + if err != nil { + return err + } + + defaultTool := toolv1.DefaultTool{Config: config} + switch request.Phase { + case toolv1.ConfigurePhaseInitial: + if err := defaultTool.ConfigureSystemPrompt(console.AgentRuntimeTypeOpencode); err != nil { + return err + } + case toolv1.ConfigurePhaseBabysit: + if err := defaultTool.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeOpencode); err != nil { + return err + } + default: + return fmt.Errorf("unsupported opencode configuration phase %q", request.Phase) + } + + if ctx != nil && ctx.Err() != nil { + return ctx.Err() + } + return defaultTool.ConfigureSkills(agent.skillsPath(config)) +} + +// Configure writes the native OpenCode provider configuration. Shared prompt +// and skill files are prepared by Prepare, while ConsoleToken is used only for +// this configuration pass when the Plural proxy is selected. +func (agent *Agent) Configure(ctx context.Context, request toolv1.ConfigureRequest) error { + if ctx != nil && ctx.Err() != nil { + return ctx.Err() + } + if request.Phase != toolv1.ConfigurePhaseInitial && request.Phase != toolv1.ConfigurePhaseBabysit { + return fmt.Errorf("unsupported opencode configuration phase %q", request.Phase) + } + if request.Phase == toolv1.ConfigurePhaseBabysit { + // Babysit reuses the provider configuration written during the initial + // pass. Rewriting it would discard the transient proxy token. + return nil + } + + openCode, err := agent.configWithOpenCode() + if err != nil { + return err + } + resolved := agent.resolveSettings(openCode.Provider, openCode.Model, openCode.OpenAICompatible, agent.config.Run.IsProxyEnabled()) + model := request.Settings.Model.Name + if model == "" { + model = resolved.model + } + + if err := agent.configureNative( + agent.config, + request.ConsoleURL, + request.ConsoleToken, + resolved.provider, + model, + resolved.openaiCompatible, + openCode.Token, + ); err != nil { + return err + } + if ctx != nil { + return ctx.Err() + } + return nil +} + +// Export writes the native OpenCode session export into OutputDir and returns +// that directory as the source for the shared artifact builder. +func (agent *Agent) Export(ctx context.Context, request toolv1.ExportRequest) (toolv1.ExportResult, error) { + if ctx != nil && ctx.Err() != nil { + return toolv1.ExportResult{}, ctx.Err() + } + if request.SessionID == "" { + return toolv1.ExportResult{}, fmt.Errorf("opencode session id is not set") + } + if request.OutputDir == "" { + return toolv1.ExportResult{}, fmt.Errorf("opencode export output directory is not set") + } + + if _, err := agent.configWithOpenCode(); err != nil { + return toolv1.ExportResult{}, err + } + + outputPath := filepath.Join(request.OutputDir, artifacts.SessionJSONName) + if err := agent.exportSession(ctx, agent.config, request.SessionID, outputPath); err != nil { + return toolv1.ExportResult{}, err + } + + return toolv1.ExportResult{SessionSource: artifacts.SessionSource{ + Path: request.OutputDir, + ArchivePath: "opencode", + }}, nil +} + +func (agent *Agent) configWithOpenCode() (*agentrunv1.OpencodeConfig, error) { + if agent.config.WorkDir == "" { + return nil, fmt.Errorf("work directory is not set") + } + if agent.config.RepositoryDir == "" { + return nil, fmt.Errorf("repository directory is not set") + } + return agent.runConfig(agent.config.Run) +} + +func (*Agent) runConfig(run *agentrunv1.AgentRun) (*agentrunv1.OpencodeConfig, error) { + if run == nil { + return nil, fmt.Errorf("agent run is not set") + } + if run.Runtime == nil || run.Runtime.Config == nil || run.Runtime.Config.OpenCode == nil { + return nil, fmt.Errorf("opencode runtime configuration is not set") + } + return run.Runtime.Config.OpenCode, nil +} + +func (*Agent) aiProvider(provider Provider) *console.AiProvider { + var mapped console.AiProvider + switch strings.ToLower(string(provider)) { + case string(ProviderPlural), string(ProviderOpenAI): + mapped = console.AiProviderOpenai + case string(ProviderAnthropic): + mapped = console.AiProviderAnthropic + case string(ProviderOllama): + mapped = console.AiProviderOllama + case string(ProviderAzure): + mapped = console.AiProviderAzure + case string(ProviderAmazonBedrock), string(ProviderBedrock): + mapped = console.AiProviderBedrock + case string(ProviderGoogleVertex), string(ProviderVertex): + mapped = console.AiProviderVertex + case string(ProviderOpenAICompatible): + mapped = console.AiProviderOpenaiCompatible + case string(ProviderXAI): + mapped = console.AiProviderXai + default: + return nil + } + return &mapped +} + +func (agent *Agent) configForFilesystem(request toolv1.FileSystemRequest) (toolv1.Config, error) { + if request.WorkDir == "" { + return toolv1.Config{}, fmt.Errorf("work directory is not set") + } + if request.RepositoryDir == "" { + return toolv1.Config{}, fmt.Errorf("repository directory is not set") + } + if agent.config.Run == nil { + return toolv1.Config{}, fmt.Errorf("agent run is not set") + } + + config := agent.config + config.WorkDir = request.WorkDir + config.RepositoryDir = request.RepositoryDir + return config, nil +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_test.go new file mode 100644 index 0000000000..4afd29185e --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_test.go @@ -0,0 +1,294 @@ +package opencode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestAgentResolveSettings(t *testing.T) { + tests := []struct { + name string + provider string + model string + compat bool + proxy bool + want console.AiProvider + wantName string + wantACP string + }{ + {name: "default", want: console.AiProviderOpenai, wantName: defaultModel, wantACP: "plural/" + defaultModel}, + {name: "native anthropic", provider: "anthropic", model: "claude-sonnet-4-5", want: console.AiProviderAnthropic, wantName: "claude-sonnet-4-5", wantACP: "anthropic/claude-sonnet-4-5"}, + {name: "native bedrock", provider: "amazon-bedrock", model: "anthropic.claude-3", want: console.AiProviderBedrock, wantName: "anthropic.claude-3", wantACP: "amazon-bedrock/anthropic.claude-3"}, + {name: "native bedrock alias", provider: "bedrock", model: "anthropic.claude-3", want: console.AiProviderBedrock, wantName: "anthropic.claude-3", wantACP: "bedrock/anthropic.claude-3"}, + {name: "native vertex", provider: "google-vertex", model: "gemini-2.5-pro", want: console.AiProviderVertex, wantName: "gemini-2.5-pro", wantACP: "google-vertex/gemini-2.5-pro"}, + {name: "native vertex alias", provider: "vertex", model: "gemini-2.5-pro", want: console.AiProviderVertex, wantName: "gemini-2.5-pro", wantACP: "vertex/gemini-2.5-pro"}, + {name: "native ollama", provider: "ollama", model: "qwen3", want: console.AiProviderOllama, wantName: "qwen3", wantACP: "ollama/qwen3"}, + {name: "native azure", provider: "azure", model: "gpt-5", want: console.AiProviderAzure, wantName: "gpt-5", wantACP: "azure/gpt-5"}, + {name: "native xai", provider: "xai", model: "grok-4", want: console.AiProviderXai, wantName: "grok-4", wantACP: "xai/grok-4"}, + {name: "native google has no Console equivalent", provider: "google", model: "gemini-2.5-pro", wantName: "gemini-2.5-pro", wantACP: "google/gemini-2.5-pro"}, + {name: "proxy", provider: "anthropic", model: "gpt-5.4", proxy: true, want: console.AiProviderOpenai, wantName: "openai/gpt-5.4", wantACP: "plural/openai/gpt-5.4"}, + {name: "proxy preserves model provider", model: "openai/gpt-5.4", proxy: true, want: console.AiProviderOpenai, wantName: "openai/gpt-5.4", wantACP: "plural/openai/gpt-5.4"}, + {name: "openai compatible", provider: "litellm", model: "custom-model", compat: true, want: console.AiProviderOpenaiCompatible, wantName: "custom-model", wantACP: "openai-compatible/custom-model"}, + {name: "unknown native provider", provider: "mistral", model: "large-latest", wantName: "large-latest", wantACP: "mistral/large-latest"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + run := agentRun(tt.provider, tt.model, tt.compat, tt.proxy) + agent := NewAgent(toolv1.Config{Run: run, RepositoryDir: t.TempDir()}) + settings, err := agent.ResolveSettings(run) + if err != nil { + t.Fatalf("ResolveSettings() error = %v", err) + } + if settings.Mode != console.AgentRunModeWrite { + t.Fatalf("mode = %q, want %q", settings.Mode, console.AgentRunModeWrite) + } + if tt.want == "" { + if settings.Model.Provider != nil { + t.Fatalf("provider = %q, want nil for unknown native provider", *settings.Model.Provider) + } + } else if settings.Model.Provider == nil || *settings.Model.Provider != tt.want { + t.Fatalf("provider = %v, want %q", settings.Model.Provider, tt.want) + } + if settings.Model.Name != tt.wantName { + t.Fatalf("model = %q, want %q", settings.Model.Name, tt.wantName) + } + transport, err := NewTransport(agent) + if err != nil { + t.Fatal(err) + } + projected, err := transport.sessionSettings(settings) + if err != nil { + t.Fatal(err) + } + if projected.ModelID != tt.wantACP { + t.Fatalf("ACP model = %q, want %q", projected.ModelID, tt.wantACP) + } + if projected.ModeID != writeModeID { + t.Fatalf("ACP mode = %q, want %q", projected.ModeID, writeModeID) + } + if settings.Timeout != 9*time.Minute { + t.Fatalf("timeout = %s, want 9m", settings.Timeout) + } + if settings.Proxy != tt.proxy { + t.Fatalf("proxy = %v, want %v", settings.Proxy, tt.proxy) + } + }) + } +} + +func TestAgentResolveSettingsMapsACPMode(t *testing.T) { + tests := []struct { + mode console.AgentRunMode + want string + }{ + {mode: console.AgentRunModeAnalyze, want: analysisModeID}, + {mode: console.AgentRunModeWrite, want: writeModeID}, + {mode: console.AgentRunModeReview, want: reviewModeID}, + } + for _, test := range tests { + run := agentRun("openai", "gpt-5.4", false, false) + run.Mode = test.mode + agent := NewAgent(toolv1.Config{Run: run, RepositoryDir: t.TempDir()}) + settings, err := agent.ResolveSettings(run) + if err != nil { + t.Fatal(err) + } + transport, err := NewTransport(agent) + if err != nil { + t.Fatal(err) + } + projected, err := transport.sessionSettings(settings) + if err != nil { + t.Fatal(err) + } + if projected.ModeID != test.want { + t.Fatalf("mode %q mapped to %q, want %q", test.mode, projected.ModeID, test.want) + } + } +} + +func TestAgentCapabilities(t *testing.T) { + capabilities := NewAgent(toolv1.Config{}).Capabilities() + for _, mode := range []console.AgentRunMode{ + console.AgentRunModeAnalyze, + console.AgentRunModeWrite, + console.AgentRunModeReview, + } { + if !capabilities.Supports(mode) { + t.Fatalf("Capabilities() does not support %q", mode) + } + } +} + +func TestAgentPreparePhases(t *testing.T) { + useTestSystemTemplates(t) + workDir := t.TempDir() + repositoryDir := t.TempDir() + run := agentRun("anthropic", "claude-sonnet-4-5", false, false) + run.Prompt = "initial prompt" + run.Skills = []agentrunv1.AgentSkill{{Name: "review-guide", Contents: "check the diff"}} + agent := NewAgent(toolv1.Config{Run: run}) + + request := toolv1.FileSystemRequest{ + Phase: toolv1.ConfigurePhaseInitial, + WorkDir: workDir, + RepositoryDir: repositoryDir, + } + if err := agent.Prepare(context.Background(), request); err != nil { + t.Fatalf("Prepare(initial) error = %v", err) + } + promptPath := filepath.Join(workDir, ".opencode", "prompts", toolv1.SystemPromptFile) + prompt, err := os.ReadFile(promptPath) + if err != nil { + t.Fatalf("read initial prompt: %v", err) + } + if !strings.Contains(string(prompt), "initial prompt") { + t.Fatalf("initial prompt does not contain run prompt: %s", prompt) + } + if _, err := os.Stat(filepath.Join(workDir, ".opencode", "skills", "review-guide", "SKILL.md")); err != nil { + t.Fatalf("skill file was not prepared: %v", err) + } + + request.Phase = toolv1.ConfigurePhaseBabysit + if err := agent.Prepare(context.Background(), request); err != nil { + t.Fatalf("Prepare(babysit) error = %v", err) + } + babysitPrompt, err := os.ReadFile(promptPath) + if err != nil { + t.Fatalf("read babysit prompt: %v", err) + } + if string(babysitPrompt) == string(prompt) { + t.Fatal("babysit preparation did not replace the system prompt") + } +} + +func TestAgentConfigurePreservesNativeConfigForBabysit(t *testing.T) { + useTestSystemTemplates(t) + workDir := t.TempDir() + config := toolv1.Config{ + WorkDir: workDir, + RepositoryDir: t.TempDir(), + Run: agentRun("anthropic", "claude-sonnet-4-5", false, false), + } + agent := NewAgent(config) + initial := toolv1.FileSystemRequest{Phase: toolv1.ConfigurePhaseInitial, WorkDir: config.WorkDir, RepositoryDir: config.RepositoryDir} + if err := agent.Prepare(context.Background(), initial); err != nil { + t.Fatalf("Prepare(initial) error = %v", err) + } + settings, err := agent.ResolveSettings(config.Run) + if err != nil { + t.Fatalf("ResolveSettings() error = %v", err) + } + configure := toolv1.ConfigureRequest{ + Phase: toolv1.ConfigurePhaseInitial, + ConsoleURL: "https://console.example", + ConsoleToken: "console-token", + Settings: settings, + } + if err := agent.Configure(context.Background(), configure); err != nil { + t.Fatalf("Configure(initial) error = %v", err) + } + configPath := filepath.Join(workDir, ".opencode", ConfigFileName) + before, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read native config: %v", err) + } + + babysit := initial + babysit.Phase = toolv1.ConfigurePhaseBabysit + if err := agent.Prepare(context.Background(), babysit); err != nil { + t.Fatalf("Prepare(babysit) error = %v", err) + } + configure.Phase = toolv1.ConfigurePhaseBabysit + configure.ConsoleToken = "" + if err := agent.Configure(context.Background(), configure); err != nil { + t.Fatalf("Configure(babysit) error = %v", err) + } + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read native config after babysit: %v", err) + } + if string(before) != string(after) { + t.Fatal("babysit configuration unexpectedly rewrote native config") + } + + var native map[string]any + if err := json.Unmarshal(before, &native); err != nil { + t.Fatalf("decode native config: %v", err) + } + if native["model"] != "anthropic/claude-sonnet-4-5" { + t.Fatalf("native model = %v", native["model"]) + } +} + +func TestAgentExportStagesNativeSession(t *testing.T) { + binDir := t.TempDir() + opencodePath := filepath.Join(binDir, "opencode") + if err := os.WriteFile(opencodePath, []byte("#!/bin/sh\nprintf '%s' '{\"id\":\"session-1\"}'\n"), 0755); err != nil { + t.Fatalf("write fake opencode: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: agentRun("openai", "gpt-5.4", false, false)} + outputDir := t.TempDir() + result, err := NewAgent(config).Export(context.Background(), toolv1.ExportRequest{SessionID: "session-1", OutputDir: outputDir}) + if err != nil { + t.Fatalf("Export() error = %v", err) + } + if result.SessionSource.Path != outputDir || result.SessionSource.ArchivePath != "opencode" { + t.Fatalf("session source = %#v", result.SessionSource) + } + data, err := os.ReadFile(filepath.Join(outputDir, artifacts.SessionJSONName)) + if err != nil { + t.Fatalf("read staged session: %v", err) + } + if string(data) != `{"id":"session-1"}` { + t.Fatalf("staged session = %q", data) + } +} + +func agentRun(provider, model string, compat, proxy bool) *agentrunv1.AgentRun { + return &agentrunv1.AgentRun{ + ID: "run-1", + Mode: console.AgentRunModeWrite, + Runtime: &agentrunv1.AgentRuntime{ + AiProxy: proxy, + Config: &agentrunv1.AgentRuntimeConfig{ + OpenCode: &agentrunv1.OpencodeConfig{ + Provider: provider, + Model: model, + OpenAICompatible: compat, + Timeout: 9 * time.Minute, + Token: "native-token", + }, + }, + }, + } +} + +func useTestSystemTemplates(t *testing.T) { + t.Helper() + root := t.TempDir() + systemDir := filepath.Join(root, "system") + if err := os.Mkdir(systemDir, 0755); err != nil { + t.Fatalf("create system template directory: %v", err) + } + for _, name := range []string{"analyze", "write", "review", "babysit"} { + path := filepath.Join(systemDir, name+".md.tmpl") + if err := os.WriteFile(path, []byte(name+" {{.Prompt}}"), 0644); err != nil { + t.Fatalf("write %s template: %v", name, err) + } + } + t.Chdir(root) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/artifacts.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/artifacts.go deleted file mode 100644 index e293e7e958..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/artifacts.go +++ /dev/null @@ -1,50 +0,0 @@ -package opencode - -import ( - "context" - "fmt" - "os" - "path/filepath" - - "k8s.io/klog/v2" - - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" -) - -func (in *Opencode) UploadArtifacts(ctx context.Context) (*artifacts.UploadArtifacts, error) { - klog.V(log.LogLevelInfo).InfoS( - "collecting opencode upload artifacts", - "agentRunID", in.Config.Run.ID, - "sessionID", in.sessionID, - "workDir", in.Config.WorkDir, - "repositoryDir", in.Config.RepositoryDir, - ) - - sourcePath, err := os.MkdirTemp(in.Config.WorkDir, "opencode-session-export-*") - if err != nil { - return nil, fmt.Errorf("create opencode session export dir: %w", err) - } - defer os.RemoveAll(sourcePath) - - if err := in.exportSession(ctx, filepath.Join(sourcePath, artifacts.SessionJSONName)); err != nil { - return nil, err - } - - return in.BuildUploadArtifacts(ctx, artifacts.BuildArtifactsOptions{ - Provider: "opencode", - Source: artifacts.SessionSource{ - Path: sourcePath, - ArchivePath: "opencode", - }, - SessionID: in.sessionID, - }) -} - -func (in *Opencode) exportSession(ctx context.Context, path string) error { - if in.sessionID == "" { - return fmt.Errorf("opencode session id is not set") - } - - return ExportSession(ctx, in.Config, in.sessionID, path) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/config.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/config.go new file mode 100644 index 0000000000..d91ca423bb --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/config.go @@ -0,0 +1,100 @@ +package opencode + +import ( + "bytes" + "context" + "fmt" + "os" + stdexec "os/exec" + "path/filepath" + + "github.com/pluralsh/console/go/deployment-operator/internal/helpers" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/common" +) + +// configureNative writes only the provider-native OpenCode configuration. The +// shared prompt and skill files are prepared separately by Agent.Prepare. +func (agent *Agent) configureNative(config toolv1.Config, consoleURL, consoleToken string, provider Provider, model string, openaiCompatible bool, token string) error { + input := &ConfigTemplateInput{ + ConsoleURL: consoleURL, + ConsoleToken: consoleToken, + AgentRunID: config.Run.ID, + Provider: provider, + OpenAICompatible: openaiCompatible, + Endpoint: config.Run.Runtime.Config.OpenCode.Endpoint, + Model: model, + Token: token, + Mode: config.Run.Mode, + DindEnabled: config.Run.DindEnabled, + StreamingProxy: config.Run.IsStreamingProxyEnabled(), + StreamingProxyBaseURL: common.AgentOpenAIBaseURL, + } + + _, content, err := configTemplate(input) + if err != nil { + return err + } + + configPath := agent.configPath(config) + if err = helpers.File().Create(configPath, content, 0644); err != nil { + return fmt.Errorf("failed configuring opencode config file %q: %w", ConfigFileName, err) + } + return nil +} + +func (*Agent) providerPath(config toolv1.Config) string { + return filepath.Join(config.WorkDir, ".opencode") +} + +func (agent *Agent) configPath(config toolv1.Config) string { + return filepath.Join(agent.providerPath(config), ConfigFileName) +} + +func (agent *Agent) skillsPath(config toolv1.Config) string { + return filepath.Join(agent.providerPath(config), "skills") +} + +func (*Agent) configHome(config toolv1.Config) string { + return filepath.Join(config.WorkDir, ".config") +} + +func (*Agent) dataHome(config toolv1.Config) string { + return filepath.Join(config.WorkDir, ".local", "share") +} + +func (agent *Agent) env(config toolv1.Config, configPath string) []string { + return []string{ + fmt.Sprintf("OPENCODE_CONFIG=%s", configPath), + fmt.Sprintf("XDG_CONFIG_HOME=%s", agent.configHome(config)), + fmt.Sprintf("XDG_DATA_HOME=%s", agent.dataHome(config)), + } +} + +// exportSession writes an OpenCode native session export to outputPath. +func (agent *Agent) exportSession(ctx context.Context, config toolv1.Config, sessionID, outputPath string) error { + if sessionID == "" { + return fmt.Errorf("opencode session id is not set") + } + configPath, err := filepath.Abs(agent.configPath(config)) + if err != nil { + return err + } + + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create opencode session export %q: %w", outputPath, err) + } + defer file.Close() + + cmd := stdexec.CommandContext(ctx, "opencode", "export", sessionID) + cmd.Env = append(os.Environ(), agent.env(config, configPath)...) + cmd.Dir = config.RepositoryDir + cmd.Stdout = file + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("opencode export session %q: %w: %s", sessionID, err, stderr.String()) + } + return nil +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go deleted file mode 100644 index 90a3538dd9..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go +++ /dev/null @@ -1,439 +0,0 @@ -package opencode - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/samber/lo" - "k8s.io/klog/v2" - - console "github.com/pluralsh/console/go/client" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/environment" - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" -) - -func (in *Opencode) Run(ctx context.Context, options ...exec.Option) { - go in.start(ctx, options...) -} - -func (in *Opencode) Configure(consoleURL, consoleToken string) error { - return Configure(in.Config, consoleURL, consoleToken, in.provider, in.model, in.openaiCompatible) -} - -func (in *Opencode) OnMessage(f v1.MessageCallback) { - in.onMessage = f -} - -func (in *Opencode) start(ctx context.Context, options ...exec.Option) { - configFilePath, err := filepath.Abs(in.configFilePath()) - if err != nil { - in.Config.ErrorChan <- err - return - } - - runCtx, cancel := context.WithCancelCause(ctx) - defer cancel(nil) - - in.executable = exec.NewExecutable( - "opencode", - append( - options, - exec.WithEnv(in.env(configFilePath)), - exec.WithArgs(in.args("", false)), - exec.WithDir(in.Config.RepositoryDir), - exec.WithTimeout(in.Config.Run.Runtime.Config.OpenCode.Timeout), - )..., - ) - - klog.V(log.LogLevelInfo).InfoS("opencode executable configured", "timeout", in.Config.Run.Runtime.Config.OpenCode.Timeout) - - // Send the initial prompt as a message too - if in.onMessage != nil { - in.onMessage(&console.AgentMessageAttributes{Message: in.Config.Run.Prompt, Role: console.AiRoleUser}, "") - } - - state := &streamState{ - events: make(map[string]*Event), - } - - err = in.executable.RunStream(runCtx, in.streamLineHandler(state, cancel)) - if ctxErr := context.Cause(runCtx); ctxErr != nil { - klog.V(log.LogLevelDefault).ErrorS(ctxErr, "opencode execution failed") - in.Config.ErrorChan <- ctxErr - return - } - - if err != nil { - klog.V(log.LogLevelDefault).ErrorS(err, "opencode execution failed") - in.Config.ErrorChan <- err - return - } - - klog.V(log.LogLevelExtended).InfoS("opencode execution finished") - // FinishedChan is closed by the controller after the babysit loop exits. -} - -func (in *Opencode) streamLineHandler(state *streamState, cancel context.CancelCauseFunc) func([]byte) { - return func(line []byte) { - in.handleStreamCallback(line, state, cancel) - } -} - -func (in *Opencode) handleStreamCallback(line []byte, state *streamState, cancel context.CancelCauseFunc) { - err := in.handleStreamLine(line, state) - if err != nil { - klog.V(log.LogLevelDebug).ErrorS(err, "failed to process opencode stream line", "line", string(line)) - cancel(err) - return - } -} - -func (in *Opencode) handleStreamLine(line []byte, state *streamState) error { - event := &EventListResponse{} - if err := json.Unmarshal(line, event); err != nil { - klog.V(log.LogLevelDebug).InfoS("ignoring non-event opencode stream line", "line", string(line), "error", err.Error()) - return nil - } - - klog.V(log.LogLevelDebug).InfoS("opencode event received", "event", event) - if event.Error != nil { - var message string - if event.Error.Data != nil { - message = event.Error.Data.Message - } - - klog.ErrorS( - fmt.Errorf("opencode stream error"), - "opencode stream error event", - "name", event.Error.Name, - "message", message, - "session_id", event.SessionID, - "events", len(state.events), - "raw_line", truncateForLog(string(line), 4096), - ) - return fmt.Errorf("opencode error: %s: %s", event.Error.Name, message) - } - - in.recordSessionID(event.SessionID) - in.processEvent(state, *event) - return nil -} - -func (in *Opencode) processEvent(state *streamState, event EventListResponse) { - id := in.getID(event) - if len(id) == 0 { - return - } - - if in.emitToolEvent(event) { - return - } - - // Step boundaries should not create synthetic aggregated messages. - // We only aggregate content-bearing events (for example text) and finalize on step_finish. - if event.Part != nil && event.Part.Type == StreamPartTypeStepStart { - return - } - - aggregated, exists := state.events[id] - if !exists { - // Ignore step finish without any accumulated message payload. - if event.Part != nil && event.Part.Type == StreamPartTypeStepFinish { - return - } - - aggregated = &Event{} - } - - aggregated.FromEventResponse(event, in.Config.Usage) - state.events[id] = aggregated - - if !aggregated.Done { - return - } - - aggregated.Sanitize() - if in.onMessage != nil { - in.onMessage(aggregated.Message, "") - } - - delete(state.events, id) -} - -func (in *Opencode) emitToolEvent(event EventListResponse) bool { - if event.Part == nil || event.Part.Type != StreamPartTypeTool { - return false - } - - if event.Part.State == nil { - return true - } - - switch event.Part.State.Status { - case StreamToolStatusRunning, StreamToolStatusPending, StreamToolStatusCompleted, StreamToolStatusError: - default: - return true - } - - if event.Part.State.Status == StreamToolStatusError { - klog.ErrorS( - fmt.Errorf("opencode tool call failed"), - "opencode tool event returned error status", - "session_id", event.SessionID, - "tool", event.Part.Tool, - "call_id", event.Part.CallID, - "message_id", event.Part.MessageID, - "input", truncateForLog(string(event.Part.State.Input), 4096), - "output", truncateForLog(event.Part.State.Output, 4096), - ) - } - - toolEvent := &Event{} - toolEvent.FromEventResponse(event, in.Config.Usage) - if event.Part.State.Status == StreamToolStatusRunning || event.Part.State.Status == StreamToolStatusPending { - if toolEvent.Message.Metadata != nil && toolEvent.Message.Metadata.Tool != nil { - if toolEvent.Message.Metadata.Tool.Output == nil || *toolEvent.Message.Metadata.Tool.Output == "" { - toolEvent.Message.Metadata.Tool.Output = lo.ToPtr(v1.RunningToolOutput) - } - } - } - toolEvent.Sanitize() - - callID := event.Part.CallID - if callID == "" { - callID = event.Part.ID - } - - if in.onMessage != nil { - in.onMessage(toolEvent.Message, callID) - } - - return true -} - -func (in *Opencode) getID(e EventListResponse) string { - if e.Part == nil { - return "" - } - - return e.Part.MessageID -} - -func (in *Opencode) args(prompt string, resume bool) []string { - if len(prompt) == 0 { - prompt = in.Config.Run.Prompt - if overridePrompt := os.Getenv(environment.EnvOverrideSystemPrompt); len(overridePrompt) > 0 { - prompt = overridePrompt - } - } - - args := []string{ - "run", - "--format", "json", - "--agent", in.agent(), - "--model", fmt.Sprintf("%s/%s", in.provider, in.model), - } - if resume && in.sessionID != "" { - args = append(args, "--session", in.sessionID) - } - return append(args, prompt) -} - -func (in *Opencode) agent() string { - switch in.Config.Run.Mode { - case console.AgentRunModeAnalyze: - return DefaultAnalysisAgent - case console.AgentRunModeReview: - return DefaultReviewAgent - } - - return DefaultWriteAgent -} - -func (in *Opencode) configFilePath() string { - return opencodeConfigFilePath(in.Config) -} - -func (in *Opencode) skillsPath() string { - return opencodeSkillsPath(in.Config) -} - -func (in *Opencode) providerPath() string { - return opencodeProviderPath(in.Config) -} - -func truncateForLog(value string, limit int) string { - if limit <= 0 || len(value) <= limit { - return value - } - - return value[:limit] + "...(truncated)" -} - -func (in *Opencode) ensure() error { - if len(in.Config.WorkDir) == 0 { - return fmt.Errorf("work directory is not set") - } - - if len(in.Config.RepositoryDir) == 0 { - return fmt.Errorf("repository directory is not set") - } - - if in.Config.FinishedChan == nil { - return fmt.Errorf("finished channel is not set") - } - - if in.Config.ErrorChan == nil { - return fmt.Errorf("error channel is not set") - } - - if in.Config.Run == nil { - return fmt.Errorf("agent run is not set") - } - - return nil -} - -func (in *Opencode) BabysitRun(ctx context.Context, bCtx *v1.BabysitContext) bool { - if bCtx == nil { - return false - } - configFilePath, err := filepath.Abs(in.configFilePath()) - if err != nil { - in.Config.ErrorChan <- err - return false - } - - runCtx, cancel := context.WithCancelCause(ctx) - defer cancel(nil) - - in.executable = exec.NewExecutable( - "opencode", - exec.WithEnv(in.env(configFilePath)), - exec.WithArgs(in.args(bCtx.Prompt, true)), - exec.WithDir(in.Config.RepositoryDir), - exec.WithTimeout(in.Config.Run.Runtime.Config.OpenCode.Timeout), - ) - - klog.V(log.LogLevelInfo).InfoS("opencode executable configured", "timeout", in.Config.Run.Runtime.Config.OpenCode.Timeout) - - // Send the initial prompt as a message too - if in.onMessage != nil { - in.onMessage(&console.AgentMessageAttributes{Message: bCtx.Prompt, Role: console.AiRoleUser}, "") - } - - state := &streamState{ - events: make(map[string]*Event), - } - - err = in.executable.RunStream(runCtx, in.streamLineHandler(state, cancel)) - if ctxErr := context.Cause(runCtx); ctxErr != nil { - klog.V(log.LogLevelDefault).ErrorS(ctxErr, "opencode execution failed") - in.Config.ErrorChan <- ctxErr - return false - } - - if err != nil { - klog.V(log.LogLevelDefault).ErrorS(err, "opencode execution failed") - in.Config.ErrorChan <- err - return false - } - - klog.V(log.LogLevelExtended).InfoS("opencode execution finished") - return false -} - -// FollowUpRun re-runs OpenCode with followUpPrompt. Errors are returned to the -// caller and must not be sent on ErrorChan. -func (in *Opencode) FollowUpRun(ctx context.Context, followUpPrompt string) error { - klog.V(log.LogLevelInfo).InfoS( - "follow-up: reprompting opencode", - "prompt_len", len(followUpPrompt), - "resumeSession", in.sessionID != "", - "sessionID", in.sessionID, - ) - - configFilePath, err := filepath.Abs(in.configFilePath()) - if err != nil { - return fmt.Errorf("opencode follow-up: %w", err) - } - - runCtx, cancel := context.WithCancelCause(ctx) - defer cancel(nil) - - in.executable = exec.NewExecutable( - "opencode", - exec.WithEnv(in.env(configFilePath)), - exec.WithArgs(in.args(followUpPrompt, true)), - exec.WithDir(in.Config.RepositoryDir), - exec.WithTimeout(in.Config.Run.Runtime.Config.OpenCode.Timeout), - ) - - state := &streamState{ - events: make(map[string]*Event), - } - - err = in.executable.RunStream(runCtx, in.streamLineHandler(state, cancel)) - if ctxErr := context.Cause(runCtx); ctxErr != nil { - return fmt.Errorf("opencode follow-up execution failed: %w", ctxErr) - } - if err != nil { - return fmt.Errorf("opencode follow-up execution failed: %w", err) - } - klog.V(log.LogLevelExtended).InfoS("opencode follow-up execution finished") - return nil -} - -func (in *Opencode) ConfigureBabysitRun() error { - if err := in.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeOpencode); err != nil { - return err - } - - return in.ConfigureSkills(in.skillsPath()) -} - -func (in *Opencode) env(configFilePath string) []string { - return opencodeEnv(in.Config, configFilePath) -} - -func (in *Opencode) configHome() string { - return opencodeConfigHome(in.Config) -} - -func (in *Opencode) dataPath() string { - return filepath.Join(opencodeDataHome(in.Config), "opencode") -} - -func (in *Opencode) dataHome() string { - return opencodeDataHome(in.Config) -} - -func (in *Opencode) recordSessionID(sessionID string) { - if sessionID == "" { - return - } - in.sessionID = sessionID -} - -func New(config v1.Config) v1.Tool { - oc := config.Run.Runtime.Config.OpenCode - settings := resolveOpenCodeSettings(oc.Provider, oc.Model, oc.OpenAICompatible, config.Run.IsProxyEnabled()) - - result := &Opencode{ - DefaultTool: v1.DefaultTool{Config: config}, - model: settings.model, - provider: settings.provider, - openaiCompatible: settings.openaiCompatible, - } - - if err := result.ensure(); err != nil { - klog.Fatalf("failed to initialize opencode tool: %v", err) - } - - return result -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_acp_types.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_acp_types.go deleted file mode 100644 index 1fab5c62a3..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_acp_types.go +++ /dev/null @@ -1,23 +0,0 @@ -package opencode - -import toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - -// ACPSettings contains the provider/model values OpenCode advertises through -// ACP session configuration options. -type ACPSettings struct { - Provider string - Model string - OpenAICompatible bool -} - -// ResolveACPSettings resolves the configured provider and model using the -// same rules as the legacy OpenCode adapter. -func ResolveACPSettings(config toolv1.Config) ACPSettings { - oc := config.Run.Runtime.Config.OpenCode - settings := resolveOpenCodeSettings(oc.Provider, oc.Model, oc.OpenAICompatible, config.Run.IsProxyEnabled()) - return ACPSettings{ - Provider: string(settings.provider), - Model: settings.model, - OpenAICompatible: settings.openaiCompatible, - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go deleted file mode 100644 index e968c7178a..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package opencode - -import ( - "testing" - - console "github.com/pluralsh/console/go/client" - agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" -) - -func TestOpencodeArgs(t *testing.T) { - oc := &Opencode{ - DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{ - Run: &agentrunv1.AgentRun{Mode: console.AgentRunModeWrite, Prompt: "initial"}, - }}, - provider: "anthropic", - model: "claude-sonnet-4-6", - } - - args := oc.args("fix bug", false) - want := []string{ - "run", - "--format", "json", - "--agent", DefaultWriteAgent, - "--model", "anthropic/claude-sonnet-4-6", - "fix bug", - } - assertArgsEqual(t, want, args) -} - -func TestOpencodeArgsResume(t *testing.T) { - sessionID := "ses_2132323b6ffeuRlYHhPcU8DaZ6" - oc := &Opencode{ - DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{ - Run: &agentrunv1.AgentRun{Mode: console.AgentRunModeAnalyze, Prompt: "initial"}, - }}, - provider: "anthropic", - model: "claude-sonnet-4-6", - sessionID: sessionID, - } - - args := oc.args("continue analysis", true) - want := []string{ - "run", - "--format", "json", - "--agent", DefaultAnalysisAgent, - "--model", "anthropic/claude-sonnet-4-6", - "--session", sessionID, - "continue analysis", - } - assertArgsEqual(t, want, args) -} - -func TestOpencodeArgsReview(t *testing.T) { - oc := &Opencode{ - DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{ - Run: &agentrunv1.AgentRun{Mode: console.AgentRunModeReview, Prompt: "review"}, - }}, - provider: "anthropic", - model: "claude-sonnet-4-6", - } - - args := oc.args("review changes", false) - assertArgsEqual(t, []string{ - "run", - "--format", "json", - "--agent", DefaultReviewAgent, - "--model", "anthropic/claude-sonnet-4-6", - "review changes", - }, args) -} - -func assertArgsEqual(t *testing.T, want, got []string) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("expected %d args, got %d: %v", len(want), len(got), got) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("arg[%d]: expected %q, got %q (full: %v)", i, want[i], got[i], got) - } - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_config.go deleted file mode 100644 index e793b2a6a2..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_config.go +++ /dev/null @@ -1,124 +0,0 @@ -package opencode - -import ( - "bytes" - "context" - "fmt" - "os" - stdexec "os/exec" - "path/filepath" - - console "github.com/pluralsh/console/go/client" - "github.com/pluralsh/console/go/deployment-operator/internal/helpers" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/common" -) - -// Configure writes the OpenCode provider configuration and shared system -// prompt files used by both the legacy and ACP adapters. -func Configure(config toolv1.Config, consoleURL, consoleToken string, provider Provider, model string, openaiCompatible bool) error { - defaultTool := toolv1.DefaultTool{Config: config} - if err := defaultTool.ConfigureSystemPrompt(console.AgentRuntimeTypeOpencode); err != nil { - return err - } - if err := defaultTool.ConfigureSkills(opencodeSkillsPath(config)); err != nil { - return err - } - - input := &ConfigTemplateInput{ - ConsoleURL: consoleURL, - ConsoleToken: consoleToken, - AgentRunID: config.Run.ID, - Provider: provider, - OpenAICompatible: openaiCompatible, - Endpoint: config.Run.Runtime.Config.OpenCode.Endpoint, - Model: model, - Token: config.Run.Runtime.Config.OpenCode.Token, - Mode: config.Run.Mode, - DindEnabled: config.Run.DindEnabled, - StreamingProxy: config.Run.IsStreamingProxyEnabled(), - StreamingProxyBaseURL: common.AgentOpenAIBaseURL, - } - - _, content, err := configTemplate(input) - if err != nil { - return err - } - - configPath := opencodeConfigFilePath(config) - if err = helpers.File().Create(configPath, content, 0644); err != nil { - return fmt.Errorf("failed configuring opencode config file %q: %w", ConfigFileName, err) - } - return nil -} - -func opencodeProviderPath(config toolv1.Config) string { - return filepath.Join(config.WorkDir, ".opencode") -} - -func opencodeConfigFilePath(config toolv1.Config) string { - return filepath.Join(opencodeProviderPath(config), ConfigFileName) -} - -func opencodeSkillsPath(config toolv1.Config) string { - return filepath.Join(opencodeProviderPath(config), "skills") -} - -func opencodeConfigHome(config toolv1.Config) string { - return filepath.Join(config.WorkDir, ".config") -} - -func opencodeDataHome(config toolv1.Config) string { - return filepath.Join(config.WorkDir, ".local", "share") -} - -func opencodeEnv(config toolv1.Config, configPath string) []string { - return []string{ - fmt.Sprintf("OPENCODE_CONFIG=%s", configPath), - fmt.Sprintf("XDG_CONFIG_HOME=%s", opencodeConfigHome(config)), - fmt.Sprintf("XDG_DATA_HOME=%s", opencodeDataHome(config)), - } -} - -// ACPConfigPath returns the path to the OpenCode configuration used by ACP. -func ACPConfigPath(config toolv1.Config) string { - return opencodeConfigFilePath(config) -} - -// ACPSkillsPath returns the path to OpenCode skills used by ACP. -func ACPSkillsPath(config toolv1.Config) string { - return opencodeSkillsPath(config) -} - -// ACPEnvironment returns the environment required by OpenCode ACP. -func ACPEnvironment(config toolv1.Config, configPath string) []string { - return opencodeEnv(config, configPath) -} - -// ExportSession writes an OpenCode native session export to outputPath. -func ExportSession(ctx context.Context, config toolv1.Config, sessionID, outputPath string) error { - if sessionID == "" { - return fmt.Errorf("opencode session id is not set") - } - configPath, err := filepath.Abs(opencodeConfigFilePath(config)) - if err != nil { - return err - } - - file, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("create opencode session export %q: %w", outputPath, err) - } - defer file.Close() - - cmd := stdexec.CommandContext(ctx, "opencode", "export", sessionID) - cmd.Env = append(os.Environ(), opencodeEnv(config, configPath)...) - cmd.Dir = config.RepositoryDir - cmd.Stdout = file - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("opencode export session %q: %w: %s", sessionID, err, stderr.String()) - } - return nil -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_provider_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_provider_test.go deleted file mode 100644 index 19d17297d0..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_provider_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package opencode - -import "testing" - -func TestEnsureProvider(t *testing.T) { - tests := []struct { - name string - provider string - proxy bool - want Provider - }{ - {name: "aiProxy forces plural", provider: "anthropic", proxy: true, want: ProviderPlural}, - {name: "empty defaults to plural", provider: "", proxy: false, want: ProviderPlural}, - {name: "passes through models.dev slug", provider: "anthropic", proxy: false, want: ProviderAnthropic}, - {name: "passes through amazon-bedrock", provider: "amazon-bedrock", proxy: false, want: ProviderAmazonBedrock}, - {name: "passes through google-vertex", provider: "google-vertex", proxy: false, want: ProviderGoogleVertex}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := EnsureProvider(tt.provider, tt.proxy); got != tt.want { - t.Fatalf("EnsureProvider(%q, %v) = %q, want %q", tt.provider, tt.proxy, got, tt.want) - } - }) - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_stream_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_stream_test.go deleted file mode 100644 index 78aa38ae7f..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_stream_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package opencode - -import ( - "encoding/json" - "testing" - - harnessusage "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" - "github.com/stretchr/testify/require" -) - -func TestToolUseSetsStartedAndCompletedAtFromEventTime(t *testing.T) { - line := `{"type":"tool_use","timestamp":1767036061199,"sessionID":"ses_1","part":{"id":"prt_1","sessionID":"ses_1","messageID":"msg_1","type":"tool","callID":"call_1","tool":"bash","state":{"status":"completed","input":{"command":"sleep 100"},"output":"","time":{"start":1767035960000,"end":1767036060000}}}}` - - event := &EventListResponse{} - require.NoError(t, json.Unmarshal([]byte(line), event)) - - aggregated := &Event{} - aggregated.FromEventResponse(*event, harnessusage.New(nil)) - - require.NotNil(t, aggregated.Message.Metadata) - require.Equal(t, msToRFC3339(1767035960000), *aggregated.Message.Metadata.StartedAt) - require.Equal(t, msToRFC3339(1767036060000), *aggregated.Message.Metadata.CompletedAt) - require.Equal(t, "", *aggregated.Message.Metadata.Tool.Output) -} - -func TestStepFinishRecordsUsage(t *testing.T) { - line := `{"type":"step_finish","timestamp":1767036064273,"sessionID":"ses_1","part":{"id":"prt_1","sessionID":"ses_1","messageID":"msg_1","type":"step-finish","cost":0.001,"tokens":{"input":671,"output":8,"reasoning":2,"cache":{"read":21,"write":5}}}}` - - event := &EventListResponse{} - require.NoError(t, json.Unmarshal([]byte(line), event)) - - recorder := harnessusage.New(nil) - aggregated := &Event{} - aggregated.FromEventResponse(*event, recorder) - - require.True(t, aggregated.Done) - require.NotNil(t, aggregated.Message.Cost) - require.Equal(t, 0.001, aggregated.Message.Cost.Total) - require.Equal(t, float64(671), *aggregated.Message.Cost.Tokens.Input) - require.Equal(t, float64(8), *aggregated.Message.Cost.Tokens.Output) - require.Equal(t, float64(2), *aggregated.Message.Cost.Tokens.Reasoning) - - attrs := recorder.Attributes() - require.NotNil(t, attrs) - require.Equal(t, int64(671), *attrs.InputTokens) - require.Equal(t, int64(8), *attrs.OutputTokens) - require.Equal(t, int64(679), *attrs.TotalTokens) - require.Equal(t, int64(26), *attrs.CachedTokens) - require.Equal(t, int64(2), *attrs.ReasoningTokens) - require.Equal(t, 0.001, *attrs.TotalCost) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_types.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_types.go deleted file mode 100644 index 1d93ecd642..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_types.go +++ /dev/null @@ -1,355 +0,0 @@ -package opencode - -import ( - "encoding/json" - "time" - - "github.com/samber/lo" - - console "github.com/pluralsh/console/go/client" - proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" - - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" -) - -const ( - DefaultAnalysisAgent = "analysis" - DefaultReviewAgent = "review" - DefaultWriteAgent = "autonomous" -) - -// Provider is an OpenCode provider id (https://models.dev). -type Provider string - -const ( - // ProviderPlural routes requests through the Console AI proxy (/ext/ai/v1). - ProviderPlural Provider = "plural" - ProviderOpenAI Provider = "openai" - - // Common models.dev provider ids for direct (non-proxy) usage. - ProviderAnthropic Provider = "anthropic" - ProviderAmazonBedrock Provider = "amazon-bedrock" - ProviderGoogleVertex Provider = "google-vertex" - ProviderGoogle Provider = "google" - - // ProviderOpenAICompatible is the fixed provider key for custom OpenAI-compatible endpoints. - ProviderOpenAICompatible Provider = "openai-compatible" -) - -// EnsureProvider selects the OpenCode provider block written to opencode.json. -// defaultProvider must be a models.dev provider slug (for example openai, anthropic, amazon-bedrock). -// When aiProxy is enabled, the harness always uses ProviderPlural (Console /ext/ai/v1); -// spec.config.opencode.provider is ignored in that mode. -func EnsureProvider(defaultProvider string, proxyEnabled bool) Provider { - if proxyEnabled { - return ProviderPlural - } - - if defaultProvider == "" { - return ProviderPlural - } - - return Provider(defaultProvider) -} - -type Model string - -const ( - ModelGPT5 Model = "gpt-5" - ModelGPT51 Model = "gpt-5.1" - ModelGPT52 Model = "gpt-5.2" - ModelGPT53 Model = "gpt-5.3" - ModelGPT54 Model = "gpt-5.4" -) - -func EnsureModel(model string) Model { - if len(model) == 0 { - return ModelGPT54 - } - - return Model(model) -} - -type opencodeSettings struct { - provider Provider - model string - openaiCompatible bool -} - -// resolveOpenCodeSettings selects provider/model wiring for opencode.json and CLI args. -// The aiProxy branch is kept separate so proxy behavior stays unchanged when openaiCompatible is added. -func resolveOpenCodeSettings(provider, model string, openaiCompatible, proxyEnabled bool) opencodeSettings { - if proxyEnabled { - return opencodeSettings{ - provider: EnsureProvider(provider, true), - model: proxymodel.ProxyModel(console.AgentRuntimeTypeOpencode, string(EnsureModel(model))), - } - } - - if openaiCompatible { - return opencodeSettings{ - provider: ProviderOpenAICompatible, - model: string(EnsureModel(model)), - openaiCompatible: true, - } - } - - return opencodeSettings{ - provider: EnsureProvider(provider, false), - model: string(EnsureModel(model)), - } -} - -// Opencode implements toolv1.Tool interface. -type Opencode struct { - toolv1.DefaultTool - - // model is the AI model used by opencode. - model string - - // provider is the named Opencode provider. - provider Provider - - // openaiCompatible selects the @ai-sdk/openai-compatible provider block in opencode.json. - openaiCompatible bool - - // executable is the opencode executable used to call CLI. - executable exec.Executable - - // onMessage is a callback called when a new message is received. - onMessage toolv1.MessageCallback - - // sessionID is the latest native OpenCode session identifier observed in stream events. - sessionID string -} - -type StreamPartType string - -const ( - StreamPartTypeText StreamPartType = "text" - StreamPartTypeTool StreamPartType = "tool" - StreamPartTypeStepStart StreamPartType = "step-start" - StreamPartTypeStepFinish StreamPartType = "step-finish" -) - -type StreamToolStatus string - -const ( - StreamToolStatusRunning StreamToolStatus = "running" - StreamToolStatusCompleted StreamToolStatus = "completed" - StreamToolStatusPending StreamToolStatus = "pending" - StreamToolStatusError StreamToolStatus = "error" -) - -type EventListResponse struct { - Timestamp int64 `json:"timestamp"` - SessionID string `json:"sessionID"` - Part *StreamPart `json:"part,omitempty"` - Error *StreamError `json:"error,omitempty"` -} - -type StreamPart struct { - ID string `json:"id"` - SessionID string `json:"sessionID"` - MessageID string `json:"messageID"` - CallID string `json:"callID,omitempty"` - Type StreamPartType `json:"type"` - Text string `json:"text,omitempty"` - Tool string `json:"tool,omitempty"` - Cost float64 `json:"cost,omitempty"` - Tokens *StreamTokens `json:"tokens,omitempty"` - State *StreamToolState `json:"state,omitempty"` -} - -type StreamTokens struct { - Total float64 `json:"total"` - Input float64 `json:"input"` - Output float64 `json:"output"` - Reasoning float64 `json:"reasoning"` - Cache *StreamTokenCache `json:"cache,omitempty"` -} - -type StreamTokenCache struct { - Read float64 `json:"read"` - Write float64 `json:"write"` -} - -// StreamToolTime is present on completed tool_use events from `opencode run --format json`. -type StreamToolTime struct { - Start int64 `json:"start,omitempty"` - End int64 `json:"end,omitempty"` -} - -type StreamToolState struct { - Status StreamToolStatus `json:"status"` - Input json.RawMessage `json:"input,omitempty"` - Output string `json:"output,omitempty"` - Time *StreamToolTime `json:"time,omitempty"` -} - -type StreamErrorData struct { - Message string `json:"message"` -} - -type StreamError struct { - Name string `json:"name"` - Data *StreamErrorData `json:"data,omitempty"` -} - -type Event struct { - ID string - Message *console.AgentMessageAttributes - Done bool - - seenToolCalls map[string]struct{} -} - -type streamState struct { - events map[string]*Event -} - -func (in *Event) FromEventResponse(e EventListResponse, recorder *usage.Usage) { - if in.Message == nil { - in.Message = &console.AgentMessageAttributes{} - } - - if e.Part == nil { - return - } - - in.ID = e.Part.MessageID - in.Message.Role = console.AiRoleAssistant - - switch e.Part.Type { - case StreamPartTypeText: - if len(e.Part.Text) > len(in.Message.Message) { - in.Message.Message = e.Part.Text - } - case StreamPartTypeTool: - in.fromToolState(e.Part) - if len(in.Message.Message) == 0 { - in.Message.Message = "Called tool" - } - case StreamPartTypeStepFinish: - in.fromTokens(e.Part.Tokens, e.Part.Cost, recorder) - in.Done = true - } -} - -func (in *Event) fromToolState(part *StreamPart) { - if part == nil || part.State == nil || len(part.Tool) == 0 { - return - } - - if in.Message.Metadata == nil { - in.Message.Metadata = &console.AgentMessageMetadataAttributes{} - } - - if in.Message.Metadata.Tool == nil { - in.Message.Metadata.Tool = &console.AgentMessageToolAttributes{} - } - - in.Message.Metadata.Tool.Name = lo.ToPtr(part.Tool) - in.Message.Metadata.Tool.State = lo.ToPtr(toAgentToolState(part.State.Status)) - in.Message.Metadata.Tool.Output = lo.ToPtr(part.State.Output) - - if len(part.State.Input) > 0 && string(part.State.Input) != "null" { - in.Message.Metadata.Tool.Input = lo.ToPtr(string(part.State.Input)) - } - - // OpenCode JSON format emits tools only when finished; use event timestamps for duration. - if part.State.Time != nil { - if part.State.Time.Start > 0 { - in.Message.Metadata.StartedAt = lo.ToPtr(msToRFC3339(part.State.Time.Start)) - } - if part.State.Time.End > 0 { - in.Message.Metadata.CompletedAt = lo.ToPtr(msToRFC3339(part.State.Time.End)) - } - } - - if in.seenToolCalls == nil { - in.seenToolCalls = make(map[string]struct{}) - } - - callID := part.CallID - if callID == "" { - callID = part.ID - } - - if _, exists := in.seenToolCalls[callID]; exists { - return - } - - if len(in.Message.Message) > 0 { - in.Message.Message += "\n" - } - in.Message.Message += "Called tool " + part.Tool - in.seenToolCalls[callID] = struct{}{} -} - -func (in *Event) fromTokens(tokens *StreamTokens, cost float64, recorder *usage.Usage) { - if in.Message.Cost == nil { - in.Message.Cost = &console.AgentMessageCostAttributes{} - } - - if in.Message.Cost.Total < cost { - in.Message.Cost.Total = cost - } - - if tokens == nil { - recorder.RecordUsage(usage.Record{TotalCost: cost}) - return - } - - cachedTokens := int64(0) - if tokens.Cache != nil { - cachedTokens = int64(tokens.Cache.Read + tokens.Cache.Write) - } - totalTokens := int64(tokens.Total) - if totalTokens == 0 { - totalTokens = int64(tokens.Input + tokens.Output) - } - recorder.RecordUsage(usage.Record{ - InputTokens: int64(tokens.Input), - OutputTokens: int64(tokens.Output), - TotalTokens: totalTokens, - CachedTokens: cachedTokens, - ReasoningTokens: int64(tokens.Reasoning), - TotalCost: cost, - }) - - if in.Message.Cost.Tokens == nil { - in.Message.Cost.Tokens = &console.AgentMessageTokensAttributes{} - } - - in.Message.Cost.Tokens.Input = lo.ToPtr(tokens.Input) - in.Message.Cost.Tokens.Output = lo.ToPtr(tokens.Output) - in.Message.Cost.Tokens.Reasoning = lo.ToPtr(tokens.Reasoning) -} - -func (in *Event) Sanitize() { - if len(in.Message.Message) == 0 { - in.Message.Message = "__plrl_ignore__" - } -} - -func toAgentToolState(state StreamToolStatus) console.AgentMessageToolState { - switch state { - case StreamToolStatusRunning: - return console.AgentMessageToolStateRunning - case StreamToolStatusCompleted: - return console.AgentMessageToolStateCompleted - case StreamToolStatusPending: - return console.AgentMessageToolStatePending - case StreamToolStatusError: - return console.AgentMessageToolStateError - default: - return console.AgentMessageToolStateCompleted - } -} - -func msToRFC3339(ms int64) string { - return time.UnixMilli(ms).UTC().Format(time.RFC3339Nano) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/provider_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/provider_test.go new file mode 100644 index 0000000000..b2b3862422 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/provider_test.go @@ -0,0 +1,29 @@ +package opencode + +import ( + "testing" + + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestResolveSettingsProvider(t *testing.T) { + tests := []struct { + name string + provider string + want Provider + }{ + {name: "empty defaults to plural", provider: "", want: ProviderPlural}, + {name: "passes through models.dev slug", provider: "anthropic", want: ProviderAnthropic}, + {name: "passes through amazon-bedrock", provider: "amazon-bedrock", want: ProviderAmazonBedrock}, + {name: "passes through google-vertex", provider: "google-vertex", want: ProviderGoogleVertex}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewAgent(toolv1.Config{}).resolveSettings(tt.provider, "model", false, false).provider + if got != tt.want { + t.Fatalf("resolveSettings(%q, false, false).provider = %q, want %q", tt.provider, got, tt.want) + } + }) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings.go new file mode 100644 index 0000000000..d4ee7a50e0 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings.go @@ -0,0 +1,74 @@ +package opencode + +import ( + "strings" + + console "github.com/pluralsh/console/go/client" + proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" +) + +const defaultModel = "gpt-5.6-luna" + +// Provider is an OpenCode provider id (https://models.dev). +type Provider string + +const ( + // ProviderPlural routes requests through the Console AI proxy (/ext/ai/v1). + ProviderPlural Provider = "plural" + ProviderOpenAI Provider = "openai" + + // Common models.dev provider ids for direct (non-proxy) usage. + ProviderAnthropic Provider = "anthropic" + ProviderAmazonBedrock Provider = "amazon-bedrock" + ProviderGoogleVertex Provider = "google-vertex" + ProviderOllama Provider = "ollama" + ProviderAzure Provider = "azure" + ProviderXAI Provider = "xai" + + // ProviderBedrock and ProviderVertex are legacy aliases accepted by the + // Console provider contract in addition to the canonical models.dev IDs. + ProviderBedrock Provider = "bedrock" + ProviderVertex Provider = "vertex" + + // ProviderOpenAICompatible is the fixed provider key for custom OpenAI-compatible endpoints. + ProviderOpenAICompatible Provider = "openai-compatible" +) + +type opencodeSettings struct { + provider Provider + model string + openaiCompatible bool +} + +// resolveSettings selects provider/model wiring for opencode.json and ACP. +// The aiProxy branch is kept separate so proxy behavior stays unchanged when openaiCompatible is added. +func (agent *Agent) resolveSettings(provider, model string, openaiCompatible, proxyEnabled bool) opencodeSettings { + if model == "" { + model = defaultModel + } + + if proxyEnabled { + return opencodeSettings{ + provider: ProviderPlural, + model: proxymodel.ProxyModel(console.AgentRuntimeTypeOpencode, model), + } + } + + if openaiCompatible { + return opencodeSettings{ + provider: ProviderOpenAICompatible, + model: strings.TrimPrefix(model, string(ProviderOpenAICompatible)+"/"), + openaiCompatible: true, + } + } + + selectedProvider := Provider(provider) + if selectedProvider == "" { + selectedProvider = ProviderPlural + } + + return opencodeSettings{ + provider: selectedProvider, + model: strings.TrimPrefix(model, string(selectedProvider)+"/"), + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_settings_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings_test.go similarity index 58% rename from go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_settings_test.go rename to go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings_test.go index 6a9f83cd30..b7aa510fb6 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_settings_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings_test.go @@ -1,6 +1,10 @@ package opencode -import "testing" +import ( + "testing" + + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) func TestResolveOpenCodeSettings(t *testing.T) { tests := []struct { @@ -48,17 +52,47 @@ func TestResolveOpenCodeSettings(t *testing.T) { wantOpenAICompat: true, }, { - name: "native provider passes through slug", + name: "openaiCompatible strips only its provider prefix", + provider: "litellm", + model: "openai-compatible/custom/model", + openaiCompatible: true, + wantProvider: ProviderOpenAICompatible, + wantModel: "custom/model", + wantOpenAICompat: true, + }, + { + name: "openaiCompatible preserves other slash-containing model names", + provider: "litellm", + model: "tenant/custom/model", + openaiCompatible: true, + wantProvider: ProviderOpenAICompatible, + wantModel: "tenant/custom/model", + wantOpenAICompat: true, + }, + { + name: "empty native provider defaults to plural", + wantProvider: ProviderPlural, + wantModel: defaultModel, + }, + { + name: "native provider strips its prefix", provider: "anthropic", - model: "claude-sonnet-4-5", + model: "anthropic/claude-sonnet-4-5", wantProvider: ProviderAnthropic, wantModel: "claude-sonnet-4-5", }, + { + name: "native provider strips exactly one prefix", + provider: "anthropic", + model: "anthropic/anthropic/claude-sonnet-4-5", + wantProvider: ProviderAnthropic, + wantModel: "anthropic/claude-sonnet-4-5", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := resolveOpenCodeSettings(tt.provider, tt.model, tt.openaiCompatible, tt.proxyEnabled) + got := NewAgent(toolv1.Config{}).resolveSettings(tt.provider, tt.model, tt.openaiCompatible, tt.proxyEnabled) if got.provider != tt.wantProvider { t.Fatalf("provider = %q, want %q", got.provider, tt.wantProvider) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates.go similarity index 100% rename from go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates.go rename to go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates.go diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl index ff4efd6a05..f0c8c7d59f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl @@ -119,8 +119,10 @@ "username": "plural", "provider": { "{{ .Provider }}": { - {{- if or .OpenAICompatible (eq .Provider "plural") }} + {{- if eq .Provider "plural" }} "npm": "@ai-sdk/openai", + {{- else if .OpenAICompatible }} + "npm": "@ai-sdk/openai-compatible", {{- end }} "name": "{{ .Provider }}", "options": { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates_test.go similarity index 96% rename from go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates_test.go rename to go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates_test.go index 3f2c01478b..02d8df1a35 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates_test.go @@ -26,7 +26,7 @@ func baseInput(mode console.AgentRunMode) *ConfigTemplateInput { ConsoleToken: testConsoleToken, AgentRunID: testAgentRunID, Provider: ProviderOpenAI, - Model: string(ModelGPT52), + Model: "gpt-5.2", Token: testToken, Mode: mode, } @@ -162,14 +162,14 @@ func TestConfigTemplate_AllowsSkillLoading(t *testing.T) { func TestEnvUsesHarnessHomeAndConfigHome(t *testing.T) { workDir := t.TempDir() - tool := &Opencode{DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{ + config := toolv1.Config{ WorkDir: workDir, Run: &agentrunv1.AgentRun{ Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{OpenCode: &agentrunv1.OpencodeConfig{}}}, }, - }}} + } - env := strings.Join(tool.env("/tmp/opencode.json"), "\n") + env := strings.Join(NewAgent(config).env(config, "/tmp/opencode.json"), "\n") for _, want := range []string{ "XDG_CONFIG_HOME=" + workDir + "/.config", "XDG_DATA_HOME=" + workDir + "/.local/share", @@ -328,13 +328,17 @@ func TestConfigTemplate_Provider(t *testing.T) { input.OpenAICompatible = true input.Endpoint = "https://litellm.example/v1" input.Token = "litellm-key" + input.Model = "tenant/custom-model" out := renderJSON(t, input) + if out["model"] != "openai-compatible/tenant/custom-model" { + t.Errorf("expected model=openai-compatible/tenant/custom-model, got %v", out["model"]) + } providers := out["provider"].(map[string]any) compat := providers[string(ProviderOpenAICompatible)].(map[string]any) - if compat["npm"] != "@ai-sdk/openai" { - t.Errorf("expected npm=@ai-sdk/openai, got %v", compat["npm"]) + if compat["npm"] != "@ai-sdk/openai-compatible" { + t.Errorf("expected npm=@ai-sdk/openai-compatible, got %v", compat["npm"]) } options := compat["options"].(map[string]any) if options["baseURL"] != "https://litellm.example/v1" { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go new file mode 100644 index 0000000000..ffbd965249 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go @@ -0,0 +1,156 @@ +package opencode + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" +) + +const ( + analysisModeID = "analysis" + reviewModeID = "review" + writeModeID = "autonomous" +) + +// Transport invokes OpenCode through its ACP interface. It owns the +// provider-specific process launch and projects Runtime settings into ACP +// model and mode identifiers. The protocol session itself belongs to acp.Engine. +type Transport struct { + agent *Agent + engine *acp.Engine + repositoryDir string +} + +var _ toolv1.Transport = (*Transport)(nil) + +// NewTransport creates the OpenCode ACP transport for an Agent. +func NewTransport(agent *Agent) (*Transport, error) { + if agent == nil { + return nil, errors.New("opencode agent is not set") + } + if agent.config.RepositoryDir == "" { + return nil, errors.New("repository directory is not set") + } + if _, err := agent.runConfig(agent.config.Run); err != nil { + return nil, err + } + + repositoryDir, err := filepath.Abs(agent.config.RepositoryDir) + if err != nil { + return nil, fmt.Errorf("resolve opencode repository directory: %w", err) + } + + return &Transport{ + agent: agent, + engine: acp.NewEngine(acp.Config{}), + repositoryDir: repositoryDir, + }, nil +} + +// Kind identifies this as an Agent Client Protocol transport. +func (*Transport) Kind() toolv1.TransportKind { + return toolv1.TransportKindACP +} + +// Capabilities reports the ACP features implemented by OpenCode. +func (*Transport) Capabilities() toolv1.TransportCapabilities { + return toolv1.TransportCapabilities{ + SessionResume: true, + ToolCallOutputStreaming: true, + UsageReporting: true, + FileSystemRead: true, + FileSystemWrite: true, + } +} + +// Turn launches OpenCode, then delegates ACP session lifecycle and event +// mapping to the provider-neutral engine. +func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest, sink toolv1.TurnSink) (toolv1.TurnResult, error) { + settings, err := transport.sessionSettings(request.Settings) + if err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + + process, err := transport.launch(ctx, request.Options) + if err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + + result, err := transport.engine.Turn(ctx, process, acp.Request{ + Cwd: transport.repositoryDir, + Prompt: request.Prompt, + SessionID: request.SessionID, + Settings: settings, + }, sink) + + return toolv1.TurnResult{SessionID: result.SessionID}, err +} + +func (transport *Transport) launch(ctx context.Context, options []exec.Option) (*exec.StdioProcess, error) { + if ctx != nil { + if err := ctx.Err(); err != nil { + return nil, err + } + } + openCode, err := transport.agent.runConfig(transport.agent.config.Run) + if err != nil { + return nil, err + } + + configPath, err := filepath.Abs(transport.agent.configPath(transport.agent.config)) + if err != nil { + return nil, fmt.Errorf("resolve opencode ACP config: %w", err) + } + + launchOptions := append([]exec.Option(nil), options...) + launchOptions = append(launchOptions, + exec.WithArgs([]string{"acp"}), + exec.WithEnv(transport.agent.env(transport.agent.config, configPath)), + exec.WithDir(transport.repositoryDir), + exec.WithTimeout(openCode.Timeout), + ) + + // ACP owns cancellation ordering. The engine sends session/cancel before + // closing stdin or killing the process, so the child must not be tied to + // the caller's context here. + return exec.StartWithStdio(context.Background(), "opencode", launchOptions...) +} + +func (transport *Transport) sessionSettings(settings toolv1.Settings) (acp.SessionSettings, error) { + openCode, err := transport.agent.runConfig(transport.agent.config.Run) + if err != nil { + return acp.SessionSettings{}, err + } + + resolved := transport.agent.resolveSettings(openCode.Provider, openCode.Model, openCode.OpenAICompatible, transport.agent.config.Run.IsProxyEnabled()) + model := settings.Model.Name + if model == "" { + model = resolved.model + } + + mode, err := transport.modeID(settings.Mode) + if err != nil { + return acp.SessionSettings{}, err + } + + return acp.SessionSettings{ModeID: mode, ModelID: string(resolved.provider) + "/" + model}, nil +} + +func (*Transport) modeID(mode console.AgentRunMode) (string, error) { + switch mode { + case console.AgentRunModeAnalyze: + return analysisModeID, nil + case console.AgentRunModeReview: + return reviewModeID, nil + case console.AgentRunModeWrite: + return writeModeID, nil + default: + return "", fmt.Errorf("unsupported opencode ACP mode %q", mode) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go new file mode 100644 index 0000000000..264c096464 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go @@ -0,0 +1,63 @@ +package opencode + +import ( + "context" + "io" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" + stackv1 "github.com/pluralsh/console/go/deployment-operator/pkg/harness/stackrun/v1" +) + +func TestTransportLaunchPreservesLifecycleHooks(t *testing.T) { + binDir := t.TempDir() + opencodePath := filepath.Join(binDir, "opencode") + if err := os.WriteFile(opencodePath, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil { + t.Fatalf("write fake opencode: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + config := toolv1.Config{ + WorkDir: t.TempDir(), + RepositoryDir: t.TempDir(), + Run: &agentrunv1.AgentRun{Runtime: &agentrunv1.AgentRuntime{ + Config: &agentrunv1.AgentRuntimeConfig{OpenCode: &agentrunv1.OpencodeConfig{Timeout: time.Minute}}, + }}, + } + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatalf("NewTransport() error = %v", err) + } + + var preStarts, postStarts atomic.Int32 + process, err := transport.launch(context.Background(), []exec.Option{ + exec.WithHook(stackv1.LifecyclePreStart, func() error { + preStarts.Add(1) + return nil + }), + exec.WithHook(stackv1.LifecyclePostStart, func() error { + postStarts.Add(1) + return nil + }), + }) + if err != nil { + t.Fatalf("launch() error = %v", err) + } + go io.Copy(io.Discard, process.Stdout) + go io.Copy(io.Discard, process.Stderr) + if err := process.Wait(); err != nil { + t.Fatalf("process.Wait() error = %v", err) + } + if got := preStarts.Load(); got != 1 { + t.Fatalf("pre-start hooks = %d, want 1", got) + } + if got := postStarts.Load(); got != 1 { + t.Fatalf("post-start hooks = %d, want 1", got) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go index c6d8049468..79af9d6d37 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go @@ -3,15 +3,16 @@ package tool import ( "fmt" + "k8s.io/klog/v2" + console "github.com/pluralsh/console/go/client" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/claude" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/codex" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/gemini" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/opencode" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/pi" v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/log" - "k8s.io/klog/v2" ) // New creates a specific tool implementation structure based on the provided @@ -21,7 +22,12 @@ func New(runtimeType console.AgentRuntimeType, config v1.Config) (v1.Tool, error switch runtimeType { case console.AgentRuntimeTypeOpencode: - return acp.NewOpenCode(config), nil + agent := opencode.NewAgent(config) + transport, err := opencode.NewTransport(agent) + if err != nil { + return nil, err + } + return v1.NewRuntime(config, agent, transport) case console.AgentRuntimeTypeClaude: return claude.New(config), nil case console.AgentRuntimeTypeGemini: diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go new file mode 100644 index 0000000000..74119d0243 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go @@ -0,0 +1,31 @@ +package tool + +import ( + "testing" + "time" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestNewComposesOpenCodeRuntime(t *testing.T) { + config := toolv1.Config{ + WorkDir: t.TempDir(), + RepositoryDir: t.TempDir(), + Run: &agentrunv1.AgentRun{ + Mode: console.AgentRunModeReview, + Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{ + OpenCode: &agentrunv1.OpencodeConfig{Provider: "anthropic", Model: "claude-sonnet-4-6", Timeout: time.Minute}, + }}, + }, + } + + created, err := New(console.AgentRuntimeTypeOpencode, config) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if _, ok := created.(*toolv1.Runtime); !ok { + t.Fatalf("OpenCode factory returned %T, want *v1.Runtime", created) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime.go b/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime.go new file mode 100644 index 0000000000..a10bb4d5b7 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime.go @@ -0,0 +1,309 @@ +package v1 + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "sync" + + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" +) + +// Runtime composes an Agent with a transport and presents the existing Tool +// contract to the controller. The transport is shared by all turn kinds, but +// only one turn is allowed to execute at a time. +type Runtime struct { + DefaultTool + + agent Agent + transport Transport + + mu sync.RWMutex + turnMu sync.Mutex + sessionID string + settings Settings + onMessage MessageCallback +} + +var _ Tool = (*Runtime)(nil) + +// NewRuntime resolves settings once and creates a compositional runtime for an +// existing v1.Config. The resolved settings are immutable for the lifetime of +// the run and contain no credentials. +func NewRuntime(config Config, agent Agent, transport Transport) (*Runtime, error) { + if agent == nil { + return nil, errors.New("agent is not set") + } + if config.Run == nil { + return nil, errors.New("agent run is not set") + } + if transport == nil { + return nil, errors.New("transport is not set") + } + if config.WorkDir == "" { + return nil, errors.New("work directory is not set") + } + if config.RepositoryDir == "" { + return nil, errors.New("repository directory is not set") + } + + runtime := &Runtime{ + DefaultTool: DefaultTool{Config: config}, + agent: agent, + transport: transport, + } + if runtime.Config.Usage == nil { + runtime.Config.Usage = usage.New(nil) + } + + settings, err := agent.ResolveSettings(config.Run) + if err != nil { + return nil, err + } + if settings.Mode == "" { + settings.Mode = config.Run.Mode + } + + if !agent.Capabilities().Supports(settings.Mode) { + return nil, fmt.Errorf("agent %s does not support run mode %q", agent.Type(), settings.Mode) + } + + runtime.settings = settings + return runtime, nil +} + +// Run starts the initial prompt asynchronously, matching the existing Tool +// contract. Initial failures are sent to ErrorChan. +func (runtime *Runtime) Run(ctx context.Context, options ...exec.Option) { + initialOptions := append([]exec.Option(nil), options...) + + go func() { + if runtime.Config.SkipInitialRun { + return + } + if runtime.Config.Run == nil { + runtime.reportError(errors.New("agent run is not set")) + return + } + + prompt := runtime.Config.Run.Prompt + runtime.emitMessage(&console.AgentMessageAttributes{Message: prompt, Role: console.AiRoleUser}, "") + if err := runtime.turn(ctx, TurnRequest{ + Kind: TurnKindInitial, + Prompt: prompt, + Options: initialOptions, + }); err != nil { + runtime.reportError(err) + } + }() +} + +// BabysitRun runs a changed-PR prompt synchronously. A nil context is a no-op, +// as required by the controller's babysit loop. Failures use ErrorChan just as +// initial asynchronous failures do. +func (runtime *Runtime) BabysitRun(ctx context.Context, babysit *BabysitContext) bool { + if babysit == nil { + return false + } + + runtime.emitMessage(&console.AgentMessageAttributes{Message: babysit.Prompt, Role: console.AiRoleUser}, "") + if err := runtime.turn(ctx, TurnRequest{Kind: TurnKindBabysit, Prompt: babysit.Prompt}); err != nil { + runtime.reportError(err) + } + + return false +} + +// Configure performs the initial provider configuration. Credentials are +// passed to the Agent for this call only and are not retained by Runtime. +func (runtime *Runtime) Configure(consoleURL, consoleToken string) error { + return runtime.configure(context.Background(), ConfigureRequest{ + Phase: ConfigurePhaseInitial, + ConsoleURL: consoleURL, + ConsoleToken: consoleToken, + }) +} + +// ConfigureBabysitRun performs the provider's babysit configuration pass. +// Existing provider configuration is reused; no Console credentials are +// copied into this request. +func (runtime *Runtime) ConfigureBabysitRun() error { + return runtime.configure(context.Background(), ConfigureRequest{Phase: ConfigurePhaseBabysit}) +} + +// OnMessage registers a callback for provider-neutral messages. A nil callback +// unregisters the current callback. +func (runtime *Runtime) OnMessage(callback MessageCallback) { + runtime.mu.Lock() + runtime.onMessage = callback + runtime.mu.Unlock() +} + +// FollowUpRun runs a follow-up prompt synchronously and returns its error to +// the caller. It deliberately does not emit the user prompt. +func (runtime *Runtime) FollowUpRun(ctx context.Context, prompt string) error { + return runtime.turn(ctx, TurnRequest{Kind: TurnKindFollowup, Prompt: prompt}) +} + +// UploadArtifacts exports provider-native state into a temporary staging +// directory, builds the normal upload artifacts, then removes the staging +// directory. Export and build failures are returned to the caller and are not +// sent to ErrorChan; artifact handling is a best-effort controller concern. +func (runtime *Runtime) UploadArtifacts(ctx context.Context) (*artifacts.UploadArtifacts, error) { + runtime.mu.RLock() + sessionID := runtime.sessionID + runtime.mu.RUnlock() + if sessionID == "" { + return nil, errors.New("agent session id is not set") + } + + stagingRoot := runtime.Config.WorkDir + if stagingRoot != "" { + if err := os.MkdirAll(stagingRoot, 0755); err != nil { + return nil, fmt.Errorf("create artifact staging parent: %w", err) + } + } + + stagingDir, err := os.MkdirTemp(stagingRoot, "agent-session-export-*") + if err != nil { + return nil, fmt.Errorf("create agent session staging directory: %w", err) + } + defer os.RemoveAll(stagingDir) + + export, err := runtime.agent.Export(ctx, ExportRequest{ + SessionID: sessionID, + OutputDir: stagingDir, + }) + if err != nil { + return nil, err + } + + return runtime.BuildUploadArtifacts(ctx, artifacts.BuildArtifactsOptions{ + Provider: strings.ToLower(runtime.agent.Type().String()), + Source: export.SessionSource, + SessionID: sessionID, + }) +} + +func (runtime *Runtime) configure(ctx context.Context, request ConfigureRequest) error { + filesystem := FileSystemRequest{ + Phase: request.Phase, + WorkDir: runtime.Config.WorkDir, + RepositoryDir: runtime.Config.RepositoryDir, + } + + if err := runtime.agent.Prepare(ctx, filesystem); err != nil { + return err + } + request.Settings = runtime.settings + + return runtime.agent.Configure(ctx, request) +} + +func (runtime *Runtime) turn(ctx context.Context, request TurnRequest) error { + if ctx == nil { + ctx = context.Background() + } + if request.Kind == "" { + return errors.New("turn kind is not set") + } + if request.Kind == TurnKindInitial && runtime.Config.Run == nil { + return errors.New("agent run is not set") + } + + runtime.turnMu.Lock() + defer runtime.turnMu.Unlock() + + runtime.mu.RLock() + request.SessionID = runtime.sessionID + request.Settings = runtime.settings + runtime.mu.RUnlock() + request.Options = append([]exec.Option(nil), request.Options...) + + result, err := runtime.transport.Turn(ctx, request, runtime.sink()) + // Update state before returning the error. Some transports can discover a + // usable session ID while also reporting a failed turn. + if result.SessionID != "" { + runtime.mu.Lock() + runtime.sessionID = result.SessionID + runtime.mu.Unlock() + } + + return err +} + +func (runtime *Runtime) sink() TurnSink { + return runtimeTurnSink{runtime: runtime} +} + +func (runtime *Runtime) emitMessage(message *console.AgentMessageAttributes, callID string) { + if message == nil { + return + } + + runtime.mu.RLock() + callback := runtime.onMessage + runtime.mu.RUnlock() + + if callback == nil { + return + } + defer func() { + _ = recover() + }() + + callback(message, callID) +} + +func (runtime *Runtime) reportError(err error) { + if err == nil || runtime.Config.ErrorChan == nil { + return + } + + defer func() { + _ = recover() + }() + + runtime.Config.ErrorChan <- err +} + +// runtimeTurnSink adapts the optional Tool callbacks and run-level usage +// accumulator to the non-optional Transport sink interface. +type runtimeTurnSink struct { + runtime *Runtime +} + +func (sink runtimeTurnSink) Session(sessionID string) { + if sessionID == "" { + return + } + + sink.runtime.mu.Lock() + sink.runtime.sessionID = sessionID + sink.runtime.mu.Unlock() +} + +func (sink runtimeTurnSink) Message(message *console.AgentMessageAttributes, callID string) { + sink.runtime.emitMessage(message, callID) +} + +func (sink runtimeTurnSink) ToolCallOutput(callID, stdout string) { + sink.runtime.EmitOutput(callID, stdout) +} + +func (sink runtimeTurnSink) Usage(record usage.Record) { + if sink.runtime.Config.Usage == nil { + sink.runtime.Config.Usage = usage.New(nil) + } + + sink.runtime.Config.Usage.RecordUsage(record) +} + +// Keep this assertion close to the adapter so changes to the callback +// contract fail at compile time. +var _ TurnSink = runtimeTurnSink{} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_test.go new file mode 100644 index 0000000000..6df74543f6 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_test.go @@ -0,0 +1,422 @@ +package v1 + +import ( + "context" + "errors" + "os" + stdexec "os/exec" + "path/filepath" + "sync" + "testing" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" +) + +type runtimeTestAgent struct { + settings Settings + modes []console.AgentRunMode + + mu sync.Mutex + prepare []FileSystemRequest + configure []ConfigureRequest + export ExportRequest + exportCalls int + resolveCalls int +} + +func (agent *runtimeTestAgent) Prepare(_ context.Context, request FileSystemRequest) error { + agent.mu.Lock() + agent.prepare = append(agent.prepare, request) + agent.mu.Unlock() + return nil +} + +func (*runtimeTestAgent) Type() console.AgentRuntimeType { return console.AgentRuntimeTypeOpencode } + +func (agent *runtimeTestAgent) Capabilities() AgentCapabilities { + modes := agent.modes + if len(modes) == 0 { + modes = []console.AgentRunMode{ + console.AgentRunModeAnalyze, + console.AgentRunModeWrite, + console.AgentRunModeReview, + } + } + return AgentCapabilities{Modes: modes} +} + +func (agent *runtimeTestAgent) ResolveSettings(*agentrunv1.AgentRun) (Settings, error) { + agent.mu.Lock() + agent.resolveCalls++ + agent.mu.Unlock() + return agent.settings, nil +} + +func (agent *runtimeTestAgent) Configure(_ context.Context, request ConfigureRequest) error { + agent.mu.Lock() + agent.configure = append(agent.configure, request) + agent.mu.Unlock() + return nil +} + +func (agent *runtimeTestAgent) Export(_ context.Context, request ExportRequest) (ExportResult, error) { + agent.mu.Lock() + agent.export = request + agent.exportCalls++ + agent.mu.Unlock() + if err := os.WriteFile(filepath.Join(request.OutputDir, "session.json"), []byte("{}"), 0644); err != nil { + return ExportResult{}, err + } + return ExportResult{SessionSource: artifacts.SessionSource{ + Path: request.OutputDir, + ArchivePath: "session", + }}, nil +} + +type runtimeTestTurn struct { + kind TurnKind + prompt string + sessionID string + options []exec.Option +} + +type runtimeTestTransport struct { + mu sync.Mutex + turns []runtimeTestTurn + results []TurnResult + errors []error + active int + maxActive int + onTurn func() + afterSession func() + completed chan struct{} +} + +func (*runtimeTestTransport) Kind() TransportKind { return TransportKindRaw } + +func (*runtimeTestTransport) Capabilities() TransportCapabilities { + return TransportCapabilities{ + SessionResume: true, + ToolCallOutputStreaming: true, + UsageReporting: true, + FileSystemRead: true, + FileSystemWrite: true, + } +} + +func (transport *runtimeTestTransport) Turn(_ context.Context, request TurnRequest, sink TurnSink) (TurnResult, error) { + transport.mu.Lock() + transport.turns = append(transport.turns, runtimeTestTurn{ + kind: request.Kind, + prompt: request.Prompt, + sessionID: request.SessionID, + options: request.Options, + }) + transport.active++ + if transport.active > transport.maxActive { + transport.maxActive = transport.active + } + onTurn := transport.onTurn + index := len(transport.turns) - 1 + result := TurnResult{} + if index < len(transport.results) { + result = transport.results[index] + } + var turnErr error + if index < len(transport.errors) { + turnErr = transport.errors[index] + } + transport.mu.Unlock() + + if result.SessionID != "" { + sink.Session(result.SessionID) + } + if transport.afterSession != nil { + transport.afterSession() + } + if onTurn != nil { + onTurn() + } + sink.Message(&console.AgentMessageAttributes{Message: "assistant", Role: console.AiRoleAssistant}, "") + sink.ToolCallOutput("call", "output") + sink.Usage(usage.Record{InputTokens: 1, OutputTokens: 2, TotalTokens: 3}) + + transport.mu.Lock() + transport.active-- + transport.mu.Unlock() + if transport.completed != nil { + transport.completed <- struct{}{} + } + return result, turnErr +} + +func TestRuntimeSessionEventEnablesLifecycleArtifactHook(t *testing.T) { + workDir := t.TempDir() + if err := stdexec.Command("git", "init", workDir).Run(); err != nil { + t.Fatal(err) + } + agent := &runtimeTestAgent{settings: Settings{Mode: console.AgentRunModeWrite}} + transport := &runtimeTestTransport{ + completed: make(chan struct{}, 1), + results: []TurnResult{{SessionID: "session-before-wait"}}, + } + runtime, err := NewRuntime(runtimeTestConfig(workDir, make(chan error, 1)), agent, transport) + if err != nil { + t.Fatal(err) + } + hookResult := make(chan error, 1) + transport.afterSession = func() { + _, err := runtime.UploadArtifacts(context.Background()) + hookResult <- err + } + runtime.Run(context.Background()) + <-transport.completed + if err := <-hookResult; err != nil { + t.Fatalf("lifecycle artifact hook before Turn return failed: %v", err) + } +} + +func runtimeTestConfig(workDir string, errors chan error) Config { + return Config{ + WorkDir: workDir, + RepositoryDir: workDir, + ErrorChan: errors, + Run: &agentrunv1.AgentRun{ + ID: "run-1", + Prompt: "initial prompt", + Mode: console.AgentRunModeWrite, + }, + } +} + +func newRuntimeTest(t *testing.T, transport *runtimeTestTransport) (*Runtime, *runtimeTestAgent, chan error) { + t.Helper() + errors := make(chan error, 4) + agent := &runtimeTestAgent{settings: Settings{Mode: console.AgentRunModeWrite}} + runtime, err := NewRuntime(runtimeTestConfig(t.TempDir(), errors), agent, transport) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + return runtime, agent, errors +} + +func TestRuntimeLifecycleAndErrorRouting(t *testing.T) { + turnErr := errors.New("babysit failed") + transport := &runtimeTestTransport{ + completed: make(chan struct{}, 3), + results: []TurnResult{ + {SessionID: "session-initial"}, + {SessionID: "session-babysit"}, + {SessionID: ""}, + }, + errors: []error{nil, turnErr, nil}, + } + runtime, agent, errorChan := newRuntimeTest(t, transport) + + var messages []string + var outputs []string + runtime.OnMessage(func(message *console.AgentMessageAttributes, _ string) { + messages = append(messages, message.Message) + }) + runtime.OnOutput(func(_, output string) { + outputs = append(outputs, output) + }) + + runtime.Run(context.Background(), exec.WithArgs([]string{"--test"})) + <-transport.completed + + if err := runtime.Configure("https://console.example", "secret"); err != nil { + t.Fatalf("Configure() error = %v", err) + } + if err := runtime.ConfigureBabysitRun(); err != nil { + t.Fatalf("ConfigureBabysitRun() error = %v", err) + } + runtime.BabysitRun(context.Background(), &BabysitContext{Prompt: "babysit prompt"}) + <-transport.completed + if err := <-errorChan; !errors.Is(err, turnErr) { + t.Fatalf("babysit error = %v, want %v", err, turnErr) + } + if err := runtime.FollowUpRun(context.Background(), "follow-up prompt"); err != nil { + t.Fatalf("FollowUpRun() error = %v", err) + } + <-transport.completed + + transport.mu.Lock() + turns := append([]runtimeTestTurn(nil), transport.turns...) + transport.mu.Unlock() + if len(turns) != 3 { + t.Fatalf("turn count = %d, want 3", len(turns)) + } + if turns[0].kind != TurnKindInitial || turns[1].kind != TurnKindBabysit || turns[2].kind != TurnKindFollowup { + t.Fatalf("turn kinds = %#v", turns) + } + if turns[2].sessionID != "session-babysit" { + t.Fatalf("follow-up session ID = %q, want session-babysit", turns[2].sessionID) + } + if turns[0].prompt != "initial prompt" || turns[1].prompt != "babysit prompt" || turns[2].prompt != "follow-up prompt" { + t.Fatalf("turn prompts = %#v", turns) + } + if len(messages) != 5 || messages[0] != "initial prompt" || messages[1] != "assistant" || messages[2] != "babysit prompt" || messages[3] != "assistant" || messages[4] != "assistant" { + t.Fatalf("messages = %#v", messages) + } + if len(outputs) != 3 { + t.Fatalf("outputs = %#v, want one per turn", outputs) + } + if got := runtime.Config.Usage.Attributes(); got == nil || *got.TotalTokens != 9 { + t.Fatalf("usage = %#v, want total tokens 9", got) + } + if len(agent.configure) != 2 || agent.configure[0].Phase != ConfigurePhaseInitial || agent.configure[1].Phase != ConfigurePhaseBabysit { + t.Fatalf("configure phases = %#v", agent.configure) + } + if agent.configure[0].ConsoleToken != "secret" || agent.configure[1].ConsoleToken != "" { + t.Fatalf("configure credentials were not phase scoped: %#v", agent.configure) + } +} + +func TestRuntimeInitialErrorRetainsSessionID(t *testing.T) { + turnErr := errors.New("initial failed") + transport := &runtimeTestTransport{ + completed: make(chan struct{}, 2), + results: []TurnResult{{SessionID: "session-after-error"}}, + errors: []error{turnErr}, + } + runtime, _, errorChan := newRuntimeTest(t, transport) + runtime.Run(context.Background()) + <-transport.completed + if err := <-errorChan; !errors.Is(err, turnErr) { + t.Fatalf("Run() error = %v, want %v", err, turnErr) + } + if err := runtime.FollowUpRun(context.Background(), "follow-up"); err != nil { + t.Fatalf("FollowUpRun() error = %v", err) + } + transport.mu.Lock() + defer transport.mu.Unlock() + if got := transport.turns[1].sessionID; got != "session-after-error" { + t.Fatalf("session ID after failed initial turn = %q", got) + } +} + +func TestRuntimeSerializesTurns(t *testing.T) { + transport := &runtimeTestTransport{} + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + transport.onTurn = func() { + once.Do(func() { close(started) }) + <-release + } + runtime, _, _ := newRuntimeTest(t, transport) + + firstDone := make(chan error, 1) + go func() { firstDone <- runtime.FollowUpRun(context.Background(), "first") }() + <-started + secondDone := make(chan bool, 1) + go func() { secondDone <- runtime.BabysitRun(context.Background(), &BabysitContext{Prompt: "second"}) }() + close(release) + if err := <-firstDone; err != nil { + t.Fatalf("first turn error = %v", err) + } + <-secondDone + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.maxActive != 1 { + t.Fatalf("max concurrent turns = %d, want 1", transport.maxActive) + } +} + +func TestAgentCapabilitiesSupports(t *testing.T) { + capabilities := AgentCapabilities{Modes: []console.AgentRunMode{console.AgentRunModeAnalyze, console.AgentRunModeReview}} + if !capabilities.Supports(console.AgentRunModeAnalyze) || !capabilities.Supports(console.AgentRunModeReview) { + t.Fatal("expected advertised modes to be supported") + } + if capabilities.Supports(console.AgentRunModeWrite) { + t.Fatal("did not expect unadvertised mode to be supported") + } +} + +func TestNewRuntimeValidatesInputsBeforeResolvingSettings(t *testing.T) { + agent := &runtimeTestAgent{settings: Settings{Mode: console.AgentRunModeWrite}} + transport := &runtimeTestTransport{} + + if _, err := NewRuntime(Config{}, agent, transport); err == nil { + t.Fatal("NewRuntime() unexpectedly accepted a nil AgentRun") + } + if agent.resolveCalls != 0 { + t.Fatalf("ResolveSettings() calls = %d, want 0 for invalid config", agent.resolveCalls) + } + + config := runtimeTestConfig(t.TempDir(), make(chan error, 1)) + if _, err := NewRuntime(config, agent, nil); err == nil { + t.Fatal("NewRuntime() unexpectedly accepted a nil Transport") + } + if agent.resolveCalls != 0 { + t.Fatalf("ResolveSettings() calls = %d, want 0 for invalid transport", agent.resolveCalls) + } + + config.WorkDir = "" + if _, err := NewRuntime(config, agent, transport); err == nil { + t.Fatal("NewRuntime() unexpectedly accepted an empty work directory") + } + config.WorkDir = t.TempDir() + config.RepositoryDir = "" + if _, err := NewRuntime(config, agent, transport); err == nil { + t.Fatal("NewRuntime() unexpectedly accepted an empty repository directory") + } + if agent.resolveCalls != 0 { + t.Fatalf("ResolveSettings() calls = %d, want 0 for invalid directories", agent.resolveCalls) + } +} + +func TestNewRuntimeValidatesResolvedCapabilities(t *testing.T) { + config := runtimeTestConfig(t.TempDir(), make(chan error, 1)) + agent := &runtimeTestAgent{ + settings: Settings{Mode: console.AgentRunModeAnalyze}, + modes: []console.AgentRunMode{console.AgentRunModeWrite}, + } + if _, err := NewRuntime(config, agent, &runtimeTestTransport{}); err == nil { + t.Fatal("NewRuntime() unexpectedly accepted an unsupported mode") + } + if agent.resolveCalls != 1 { + t.Fatalf("ResolveSettings() calls = %d, want 1", agent.resolveCalls) + } +} + +func TestRuntimeUploadArtifactsCleansExportStaging(t *testing.T) { + workDir := t.TempDir() + if err := stdexec.Command("git", "init", workDir).Run(); err != nil { + t.Fatalf("git init: %v", err) + } + errors := make(chan error, 1) + agent := &runtimeTestAgent{settings: Settings{Mode: console.AgentRunModeWrite}} + transport := &runtimeTestTransport{ + completed: make(chan struct{}, 1), + results: []TurnResult{{SessionID: "session-1"}}, + } + runtime, err := NewRuntime(runtimeTestConfig(workDir, errors), agent, transport) + if err != nil { + t.Fatalf("NewRuntime() error = %v", err) + } + runtime.Run(context.Background()) + <-transport.completed + + result, err := runtime.UploadArtifacts(context.Background()) + if err != nil { + t.Fatalf("UploadArtifacts() error = %v", err) + } + if result == nil || result.SessionPath == "" { + t.Fatalf("UploadArtifacts() = %#v, want session artifact", result) + } + if _, err := os.Stat(result.SessionPath); err != nil { + t.Fatalf("session artifact stat: %v", err) + } + agent.mu.Lock() + exportDir := agent.export.OutputDir + agent.mu.Unlock() + if _, err := os.Stat(exportDir); !os.IsNotExist(err) { + t.Fatalf("export staging directory still exists: %q, stat error %v", exportDir, err) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_types.go b/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_types.go new file mode 100644 index 0000000000..ad822c04bd --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_types.go @@ -0,0 +1,160 @@ +package v1 + +import ( + "context" + "slices" + "time" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" +) + +// TransportKind identifies the protocol used to invoke an agent. +type TransportKind string + +const ( + TransportKindRaw TransportKind = "raw" + TransportKindACP TransportKind = "acp" +) + +// TurnKind identifies where a turn occurs in an agent run. +type TurnKind string + +const ( + TurnKindInitial TurnKind = "initial" + TurnKindFollowup TurnKind = "followup" + TurnKindBabysit TurnKind = "babysit" +) + +// ConfigurePhase identifies the configuration pass being performed. +type ConfigurePhase string + +const ( + ConfigurePhaseInitial ConfigurePhase = "initial" + ConfigurePhaseBabysit ConfigurePhase = "babysit" +) + +// ModelSelection is the model and optional Console AI provider selected for a +// turn. Provider is nil when a provider-native configuration has no exact +// Console AI provider equivalent. +type ModelSelection struct { + Provider *console.AiProvider + Name string +} + +// Settings are the provider-neutral settings resolved by an Agent. +type Settings struct { + Mode console.AgentRunMode + Model ModelSelection + Timeout time.Duration + Proxy bool +} + +// FileSystemRequest describes the files an agent should prepare for a phase. +// It contains paths only; credentials and provider configuration remain owned +// by the Agent. +type FileSystemRequest struct { + Phase ConfigurePhase + WorkDir string + RepositoryDir string +} + +// FileSystemConfiguration prepares shared system prompt, skill, and template +// files for an agent phase. +type FileSystemConfiguration interface { + Prepare(context.Context, FileSystemRequest) error +} + +// AgentCapabilities describes the run modes supported by an agent. +type AgentCapabilities struct { + Modes []console.AgentRunMode +} + +// Supports reports whether mode is advertised by the agent. +func (in AgentCapabilities) Supports(mode console.AgentRunMode) bool { + return slices.Contains(in.Modes, mode) +} + +// TransportCapabilities describes protocol features available to Runtime. +type TransportCapabilities struct { + SessionResume bool + ToolCallOutputStreaming bool + UsageReporting bool + FileSystemRead bool + FileSystemWrite bool +} + +// ConfigureRequest carries one configuration pass. ConsoleToken is transient +// input and must not be copied into Settings or retained by Runtime. +type ConfigureRequest struct { + Phase ConfigurePhase + ConsoleURL string + ConsoleToken string + Settings Settings +} + +// ExportRequest asks an agent to stage its native session under OutputDir. +// Runtime owns OutputDir's lifetime and removes it after artifact building. +type ExportRequest struct { + SessionID string + OutputDir string +} + +// ExportResult contains the source directory returned by an Agent.Export. +type ExportResult struct { + SessionSource artifacts.SessionSource +} + +// TurnSink receives provider-neutral events from a Transport. Every callback +// is optional; Runtime supplies a nil-safe implementation when it starts a +// turn. +type TurnSink interface { + Session(string) + Message(*console.AgentMessageAttributes, string) + ToolCallOutput(string, string) + Usage(usage.Record) +} + +// TurnRequest is the complete input for one serialized turn. +type TurnRequest struct { + Kind TurnKind + Prompt string + SessionID string + Settings Settings + // Options contains harness lifecycle hooks for the initial turn. A + // Transport must apply every option when constructing the underlying + // execution. Follow-up and babysit turns receive no options. + Options []exec.Option +} + +// TurnResult contains the latest session state observed by a transport. A +// transport may return both a result and an error; Runtime retains a nonempty +// result session ID before routing the error. +type TurnResult struct { + SessionID string +} + +// Agent owns provider-specific settings, configuration, and session export. +// FileSystemConfiguration is embedded so every agent exposes the same +// preparation seam. +type Agent interface { + FileSystemConfiguration + Type() console.AgentRuntimeType + Capabilities() AgentCapabilities + ResolveSettings(*agentrunv1.AgentRun) (Settings, error) + Configure(context.Context, ConfigureRequest) error + Export(context.Context, ExportRequest) (ExportResult, error) +} + +// Transport owns one agent invocation protocol. Runtime serializes calls to +// Turn even when callers invoke FollowUpRun and BabysitRun concurrently. The +// transport must apply TurnRequest.Options to the initial execution so the +// controller's lifecycle hooks retain their existing behavior. +type Transport interface { + Kind() TransportKind + Capabilities() TransportCapabilities + Turn(context.Context, TurnRequest, TurnSink) (TurnResult, error) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/v1/tool.go b/go/deployment-operator/pkg/agentrun-harness/tool/v1/tool.go index 6f197d7047..89937ebd77 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/v1/tool.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/v1/tool.go @@ -34,21 +34,6 @@ const ( // ConfigureSystemPrompt prepares system prompt/context files for the provider and puts them in the required directory // for the agent CLI to read during the run. func (in *DefaultTool) ConfigureSystemPrompt(runtime console.AgentRuntimeType) error { - providerDir := "" - switch runtime { - case console.AgentRuntimeTypeClaude: - providerDir = ".claude/prompts" - case console.AgentRuntimeTypeGemini: - providerDir = ".gemini" - case console.AgentRuntimeTypeOpencode: - providerDir = ".opencode/prompts" - case console.AgentRuntimeTypeCodex: - providerDir = ".codex" - case console.AgentRuntimeTypePi: - providerDir = ".pi/agent" - } - - outputFile := path.Join(in.Config.WorkDir, providerDir, SystemPromptFile) templateFile := systemPromptTemplateDir switch in.Config.Run.Mode { @@ -60,20 +45,14 @@ func (in *DefaultTool) ConfigureSystemPrompt(runtime console.AgentRuntimeType) e templateFile = path.Join(templateFile, systemPromptReviewTemplateFile) } - content, err := systemPromptTemplate(templateFile, in.systemPromptInput()) - if err != nil { - return err - } - - if err = helpers.File().Create(outputFile, content, 0644); err != nil { - return fmt.Errorf("failed configuring %s system prompt/context file %q: %w", runtime, outputFile, err) - } - - klog.V(log.LogLevelExtended).InfoS("system prompt/context file configured", "output", outputFile) - return nil + return in.configureSystemPrompt(runtime, templateFile) } func (in *DefaultTool) ConfigureSystemPromptForBabysitRun(runtime console.AgentRuntimeType) error { + return in.configureSystemPrompt(runtime, path.Join(systemPromptTemplateDir, systemPromptBabysitTemplateFile)) +} + +func (in *DefaultTool) configureSystemPrompt(runtime console.AgentRuntimeType, templateFile string) error { providerDir := "" switch runtime { case console.AgentRuntimeTypeClaude: @@ -89,17 +68,16 @@ func (in *DefaultTool) ConfigureSystemPromptForBabysitRun(runtime console.AgentR } outputFile := path.Join(in.Config.WorkDir, providerDir, SystemPromptFile) - templateFile := path.Join(systemPromptTemplateDir, systemPromptBabysitTemplateFile) - content, err := systemPromptTemplate(templateFile, in.systemPromptInput()) if err != nil { - return fmt.Errorf("failed to render babysit system prompt template %q: %w", templateFile, err) + return err } if err = helpers.File().Create(outputFile, content, 0644); err != nil { - return fmt.Errorf("failed to write babysit system prompt %q: %w", outputFile, err) + return fmt.Errorf("failed configuring %s system prompt/context file %q: %w", runtime, outputFile, err) } + klog.V(log.LogLevelExtended).InfoS("system prompt/context file configured", "output", outputFile) return nil } From dd0fca52c8244c1806f11e0a396c5e1dbc775732 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 4 Sep 2026 16:57:34 +0200 Subject: [PATCH 13/46] feat(tool): remove Codex tool implementation and associated tests - Deleted `codex` package and its implementation files (`artifacts.go`, `codex.go`, `codex_stream.go`) to streamline codebase. - Removed Codex-specific tests from `codex_stream_test.go`. --- .../deployment-operator-cd-agent-harness.yaml | 4 +- .../agent-harness/codex.Dockerfile | 42 +- .../internal/controller/agentrun_pod.go | 2 +- .../pkg/agentrun-harness/tool/acp/client.go | 11 +- .../agentrun-harness/tool/acp/client_test.go | 44 ++ .../pkg/agentrun-harness/tool/acp/engine.go | 20 +- .../agentrun-harness/tool/acp/engine_test.go | 20 + .../agentrun-harness/tool/acp/tool_call.go | 52 +- .../tool/acp/tool_call_test.go | 48 +- .../pkg/agentrun-harness/tool/acp/types.go | 2 + .../pkg/agentrun-harness/tool/acp/updates.go | 16 +- .../tool/codex/acp_environment.go | 59 +++ .../tool/codex/acp_environment_test.go | 52 ++ .../pkg/agentrun-harness/tool/codex/agent.go | 207 ++++++++ .../tool/codex/agent_config.go | 155 ++++++ .../tool/codex/agent_config_test.go | 96 ++++ .../agentrun-harness/tool/codex/agent_test.go | 82 +++ .../agentrun-harness/tool/codex/artifacts.go | 16 - .../pkg/agentrun-harness/tool/codex/codex.go | 412 --------------- .../tool/codex/codex_stream.go | 495 ------------------ .../tool/codex/codex_stream_test.go | 184 ------- .../tool/codex/codex_templates.go | 110 ---- .../tool/codex/codex_templates_test.go | 202 ------- .../tool/codex/codex_types.go | 278 ---------- .../pkg/agentrun-harness/tool/codex/model.go | 27 - .../tool/codex/runtime_config.go | 170 ++++++ .../tool/codex/runtime_config_test.go | 105 ++++ .../agentrun-harness/tool/codex/session.go | 92 ++++ .../tool/codex/session_test.go | 35 ++ .../agentrun-harness/tool/codex/templates.go | 73 +++ .../tool/codex/templates/config.toml.gotmpl | 108 ++++ .../tool/codex/templates_test.go | 252 +++++++++ .../agentrun-harness/tool/codex/transport.go | 108 ++++ .../tool/codex/transport_test.go | 136 +++++ .../pkg/agentrun-harness/tool/codex/types.go | 56 ++ .../tool/opencode/acp_environment.go | 32 ++ .../tool/opencode/acp_environment_test.go | 29 + .../agentrun-harness/tool/opencode/agent.go | 99 +--- .../opencode/{config.go => agent_config.go} | 75 ++- .../tool/opencode/agent_config_test.go | 70 +++ .../tool/opencode/agent_test.go | 210 -------- .../tool/opencode/provider_test.go | 29 - .../tool/opencode/runtime_config.go | 182 +++++++ .../tool/opencode/runtime_config_test.go | 254 +++++++++ .../agentrun-harness/tool/opencode/session.go | 41 ++ .../tool/opencode/session_test.go | 36 ++ .../tool/opencode/settings.go | 74 --- .../tool/opencode/settings_test.go | 107 ---- .../tool/opencode/templates.go | 4 - .../tool/opencode/templates_test.go | 23 - .../tool/opencode/transport.go | 67 +-- .../tool/opencode/transport_test.go | 41 +- .../pkg/agentrun-harness/tool/tool.go | 12 +- .../pkg/agentrun-harness/tool/tool_test.go | 27 + .../pkg/agentrun-harness/tool/v1/runtime.go | 3 - .../agentrun-harness/tool/v1/runtime_types.go | 14 +- 56 files changed, 2788 insertions(+), 2412 deletions(-) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/acp_environment.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/acp_environment_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/artifacts.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/codex.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_stream.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_stream_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_types.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/model.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/session.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/session_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/templates.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/templates/config.toml.gotmpl create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/templates_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/transport_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/types.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/acp_environment.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/acp_environment_test.go rename go/deployment-operator/pkg/agentrun-harness/tool/opencode/{config.go => agent_config.go} (51%) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_config_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/provider_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/runtime_config.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/runtime_config_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/session.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/session_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings_test.go diff --git a/.github/workflows/deployment-operator-cd-agent-harness.yaml b/.github/workflows/deployment-operator-cd-agent-harness.yaml index 7ddc8e48a5..401506d3df 100644 --- a/.github/workflows/deployment-operator-cd-agent-harness.yaml +++ b/.github/workflows/deployment-operator-cd-agent-harness.yaml @@ -34,7 +34,7 @@ jobs: CLAUDE_VERSION: 2.1.72 GEMINI_VERSION: 0.44.1 OPENCODE_VERSION: 1.18.23 - CODEX_VERSION: 0.104.0 + CODEX_VERSION: 1.9.0 PI_VERSION: 0.84.1 outputs: node: ${{ env.NODE_VERSION }} @@ -185,7 +185,7 @@ jobs: - name: opencode version: 1.18.23 - name: codex - version: 0.104.0 + version: 1.9.0 - name: pi version: 0.84.1 permissions: diff --git a/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile index 67cc52654f..6a15a2fb68 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile @@ -1,57 +1,37 @@ ARG NODE_IMAGE_TAG=24 ARG NODE_IMAGE=node:${NODE_IMAGE_TAG}-slim -ARG AGENT_VERSION=0.104.0 +ARG AGENT_VERSION=1.9.0 ARG AGENT_HARNESS_BASE_IMAGE_TAG=latest ARG AGENT_HARNESS_BASE_IMAGE_REPO=ghcr.io/pluralsh/agent-harness-base ARG AGENT_HARNESS_BASE_IMAGE=$AGENT_HARNESS_BASE_IMAGE_REPO:$AGENT_HARNESS_BASE_IMAGE_TAG -# Stage 1: Install Codex CLI from npm in the Node image and flatten dependencies +# Stage 1: Install the Codex ACP adapter and its compatible Codex dependency FROM $NODE_IMAGE AS node USER root -# Install codex CLI globally using npm -RUN npm install -g @openai/codex@0.104.0 - -# The codex script uses createRequire(import.meta.url) anchored at /usr/local/bin/codex. -# Node's module resolution walks up from /usr/local/bin/ and won't find node_modules -# until it reaches a NODE_PATH entry. The native package (@openai/codex-linux-x64) -# is installed nested inside @openai/codex/node_modules/ — hard-copy it up to the -# top-level @openai scope so NODE_PATH=/usr/local/lib/node_modules can find it. -RUN NESTED="/usr/local/lib/node_modules/@openai/codex/node_modules/@openai" && \ - if [ -d "$NESTED" ]; then \ - for pkg in "$NESTED"/codex-*; do \ - pkgname=$(basename "$pkg"); \ - cp -rL "$pkg" "/usr/local/lib/node_modules/@openai/$pkgname"; \ - done; \ - fi +ARG AGENT_VERSION +RUN npm install -g "@agentclientprotocol/codex-acp@$AGENT_VERSION" # Verify installation -RUN codex --version +RUN codex-acp --version -# Stage 2: Copy codex CLI into agent-harness base +# Stage 2: Copy the Codex ACP adapter into agent-harness base FROM $AGENT_HARNESS_BASE_IMAGE AS final -# Copy the codex CLI from the Node.js image -COPY --from=node /usr/local/bin/codex /usr/local/bin/codex -# Copy the entire @openai scope — now contains both @openai/codex (with its -# nested node_modules) and the promoted top-level @openai/codex-linux-x64 copy -COPY --from=node /usr/local/lib/node_modules/@openai /usr/local/lib/node_modules/@openai +COPY --from=node /usr/local/bin/codex-acp /usr/local/bin/codex-acp +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules -# Copy Node.js runtime (needed to run the CLI) +# Copy the Node.js runtime needed by the adapter. COPY --from=node /usr/local/bin/node /usr/local/bin/node -# NODE_PATH lets require.resolve() in the codex ESM script find -# /usr/local/lib/node_modules/@openai/codex-linux-x64 at runtime -ENV NODE_PATH=/usr/local/lib/node_modules - # Ensure proper ownership for nonroot user USER root -RUN chown -R 65532:65532 /usr/local/bin/codex /usr/local/lib/node_modules/@openai /usr/local/bin/node +RUN chown -R 65532:65532 /usr/local/bin/codex-acp /usr/local/lib/node_modules /usr/local/bin/node # Switch back to nonroot user USER 65532:65532 # The entrypoint remains the agent-harness binary -# The agent-harness will call the codex CLI as needed +# The agent-harness launches codex-acp directly. diff --git a/go/deployment-operator/internal/controller/agentrun_pod.go b/go/deployment-operator/internal/controller/agentrun_pod.go index f702b1635b..6d20efca08 100644 --- a/go/deployment-operator/internal/controller/agentrun_pod.go +++ b/go/deployment-operator/internal/controller/agentrun_pod.go @@ -124,7 +124,7 @@ var ( console.AgentRuntimeTypeClaude: "%s-claude-2.1.72", console.AgentRuntimeTypeGemini: "%s-gemini-0.44.1", console.AgentRuntimeTypeOpencode: "%s-opencode-1.18.23", - console.AgentRuntimeTypeCodex: "%s-codex-0.104.0", + console.AgentRuntimeTypeCodex: "%s-codex-1.9.0", console.AgentRuntimeTypePi: "%s-pi-0.84.1", } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go index 3b0641cc4f..4c826f854d 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go @@ -126,16 +126,25 @@ func (reader *contextReader) Read(buffer []byte) (int, error) { return reader.reader.Read(buffer) } -func (client *client) WriteTextFile(_ context.Context, request acpsdk.WriteTextFileRequest) (acpsdk.WriteTextFileResponse, error) { +func (client *client) WriteTextFile(ctx context.Context, request acpsdk.WriteTextFileRequest) (acpsdk.WriteTextFileResponse, error) { if err := client.validateSession(request.SessionId); err != nil { return acpsdk.WriteTextFileResponse{}, err } if !filepath.IsAbs(request.Path) { return acpsdk.WriteTextFileResponse{}, fmt.Errorf("acp filesystem path must be absolute: %q", request.Path) } + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return acpsdk.WriteTextFileResponse{}, err + } if err := os.MkdirAll(filepath.Dir(request.Path), 0o755); err != nil { return acpsdk.WriteTextFileResponse{}, fmt.Errorf("mkdir %s: %w", filepath.Dir(request.Path), err) } + if err := ctx.Err(); err != nil { + return acpsdk.WriteTextFileResponse{}, err + } if err := os.WriteFile(request.Path, []byte(request.Content), 0o644); err != nil { return acpsdk.WriteTextFileResponse{}, fmt.Errorf("write %s: %w", request.Path, err) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go index 8863762867..0084e9a858 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -75,3 +75,47 @@ func TestClientRejectsOversizedAndCanceledReads(t *testing.T) { t.Fatalf("canceled read error = %v", err) } } + +func TestClientRejectsCanceledWritesBeforeFilesystemSideEffects(t *testing.T) { + acpClient, directory := newTestClient(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + path := filepath.Join(directory, "nested", "file.txt") + + _, err := acpClient.WriteTextFile(ctx, acpsdk.WriteTextFileRequest{SessionId: "session-1", Path: path, Content: "content"}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled write error = %v", err) + } + if _, err := os.Stat(filepath.Dir(path)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("write parent directory error = %v, want not exist", err) + } +} + +type cancelAfterFirstCheckContext struct { + context.Context + checks int +} + +func (ctx *cancelAfterFirstCheckContext) Err() error { + ctx.checks++ + if ctx.checks > 1 { + return context.Canceled + } + return nil +} +func TestClientRejectsCanceledWritesBetweenFilesystemSideEffects(t *testing.T) { + acpClient, directory := newTestClient(t) + path := filepath.Join(directory, "nested", "file.txt") + + ctx := &cancelAfterFirstCheckContext{Context: context.Background()} + _, err := acpClient.WriteTextFile(ctx, acpsdk.WriteTextFileRequest{SessionId: "session-1", Path: path, Content: "content"}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled write error = %v", err) + } + if _, err := os.Stat(filepath.Dir(path)); err != nil { + t.Fatalf("write parent directory error = %v, want directory", err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("write file error = %v, want not exist", err) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go index 4470b75686..b322f69f6c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go @@ -28,15 +28,15 @@ type Engine struct { } func (engine *Engine) setSessionConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption, settings SessionSettings) error { - if err := engine.setModelConfig(ctx, connection, sessionID, options, settings.ModelID); err != nil { + if err := engine.setModelConfig(ctx, connection, sessionID, options, settings.ModelID, settings.Reasoning); err != nil { return err } return engine.setModeConfig(ctx, connection, sessionID, modes, options, settings.ModeID) } -func (engine *Engine) setModelConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, model string) error { +func (engine *Engine) setModelConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, model, reasoning string) error { if model == "" { - return nil + return engine.setReasoningConfig(ctx, connection, sessionID, options, reasoning) } found, err := engine.setConfigOption(ctx, connection, sessionID, options, "model", model) if err != nil { @@ -45,6 +45,20 @@ func (engine *Engine) setModelConfig(ctx context.Context, connection *acpsdk.Cli if !found { klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a model config option") } + return engine.setReasoningConfig(ctx, connection, sessionID, options, reasoning) +} + +func (engine *Engine) setReasoningConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, options []acpsdk.SessionConfigOption, reasoning string) error { + if reasoning == "" { + return nil + } + found, err := engine.setConfigOption(ctx, connection, sessionID, options, "reasoning_effort", reasoning) + if err != nil { + return err + } + if !found { + klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a reasoning effort config option") + } return nil } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go index 16accc2ad7..16eb6febd3 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -347,6 +347,26 @@ func TestEngineTurnAppliesModelAndModeConfig(t *testing.T) { } } +func TestEngineTurnAppliesModelAndReasoningEffort(t *testing.T) { + state := newTestState() + state.configOptions = []acpsdk.SessionConfigOption{ + {Select: &acpsdk.SessionConfigOptionSelect{Id: "model", CurrentValue: "default", Options: acpsdk.SessionConfigSelectOptions{Ungrouped: &acpsdk.SessionConfigSelectOptionsUngrouped{{Value: "default"}, {Value: "openai/gpt-5.4"}}}}}, + {Select: &acpsdk.SessionConfigOptionSelect{Id: "reasoning_effort", CurrentValue: "low", Options: acpsdk.SessionConfigSelectOptions{Ungrouped: &acpsdk.SessionConfigSelectOptionsUngrouped{{Value: "low"}, {Value: "medium"}}}}}, + } + _, process, _ := newTestAgentProcess(state, true) + _, err := NewEngine(Config{}).Turn(context.Background(), process, Request{ + Cwd: t.TempDir(), Prompt: "configure", Settings: SessionSettings{ModelID: "openai/gpt-5.4", Reasoning: "medium"}, + }, &testSink{}) + if err != nil { + t.Fatalf("configured turn: %v", err) + } + state.mu.Lock() + defer state.mu.Unlock() + if len(state.setConfig) != 2 || string(state.setConfig[0].ValueId.Value) != "openai/gpt-5.4" || string(state.setConfig[1].ValueId.Value) != "medium" { + t.Fatalf("config values = %#v", state.setConfig) + } +} + func TestEngineTurnStreamsMessagesToolsUsageAndOrdering(t *testing.T) { state := newTestState() cached, thought := 2, 3 diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go index 90b8e898bc..4b20da965b 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go @@ -20,6 +20,11 @@ type toolCall struct { state console.AgentMessageToolState } +type toolOutputValue struct { + text string + delta bool +} + func (call *toolCall) addOutput(output string) { if output == "" || output == call.output { return @@ -27,6 +32,12 @@ func (call *toolCall) addOutput(output string) { call.output = output } +func (call *toolCall) appendOutput(output string) { + if output != "" { + call.output += output + } +} + func (*toolCall) formatValue(value any) string { if value == nil { return "" @@ -58,12 +69,47 @@ func (call *toolCall) contentOutput(content []acpsdk.ToolCallContent) string { return builder.String() } -func (call *toolCall) toolOutput(content []acpsdk.ToolCallContent, rawOutput any) string { +func (call *toolCall) toolOutput(content []acpsdk.ToolCallContent, meta map[string]any, rawOutput any) toolOutputValue { + if output, delta, ok := terminalOutput(meta); ok { + return toolOutputValue{text: output, delta: delta} + } output := call.contentOutput(content) if output == "" && rawOutput != nil { - return call.formatValue(rawOutput) + if formatted, ok := formattedRawOutput(rawOutput); ok { + return toolOutputValue{text: formatted} + } + output = call.formatValue(rawOutput) + } + return toolOutputValue{text: output} +} + +func terminalOutput(meta map[string]any) (string, bool, bool) { + for _, candidate := range []struct { + name string + delta bool + }{ + {name: "terminal_output_delta", delta: true}, + {name: "terminal_output"}, + } { + envelope, ok := meta[candidate.name].(map[string]any) + if !ok { + continue + } + data, ok := envelope["data"].(string) + if ok { + return data, candidate.delta, true + } + } + return "", false, false +} + +func formattedRawOutput(rawOutput any) (string, bool) { + envelope, ok := rawOutput.(map[string]any) + if !ok { + return "", false } - return output + formatted, ok := envelope["formatted_output"].(string) + return formatted, ok } type toolUpdateEvents struct { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go index d2e4a8f1aa..7cc178684e 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go @@ -10,11 +10,11 @@ import ( func TestToolCallPrefersContentAndFormatsRawOutput(t *testing.T) { call := &toolCall{state: console.AgentMessageToolStateRunning} content := []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("displayed"))} - if got := call.toolOutput(content, map[string]string{"result": "raw"}); got != "displayed" { - t.Fatalf("content output = %q", got) + if got := call.toolOutput(content, nil, map[string]string{"result": "raw"}); got.text != "displayed" { + t.Fatalf("content output = %q", got.text) } - if got := call.toolOutput(nil, map[string]string{"result": "raw"}); got != `{"result":"raw"}` { - t.Fatalf("raw output = %q", got) + if got := call.toolOutput(nil, nil, map[string]string{"result": "raw"}); got.text != `{"result":"raw"}` { + t.Fatalf("raw output = %q", got.text) } call.setName("", acpsdk.ToolKindExecute) if call.name != string(acpsdk.ToolKindExecute) { @@ -22,6 +22,46 @@ func TestToolCallPrefersContentAndFormatsRawOutput(t *testing.T) { } } +func TestToolCallMapsAdapterTerminalOutput(t *testing.T) { + sink := &testSink{} + turn := &turnState{sink: sink, tools: map[string]*toolCall{"call-1": {id: "call-1"}}} + status := acpsdk.ToolCallStatusCompleted + updates := []struct { + meta map[string]any + rawOutput any + status *acpsdk.ToolCallStatus + wantOutput string + stream bool + }{ + {meta: adapterTerminalMeta("terminal_output_delta", "first\n"), wantOutput: "first\n", stream: true}, + {meta: adapterTerminalMeta("terminal_output_delta", "second\n"), wantOutput: "first\nsecond\n", stream: true}, + {meta: adapterTerminalMeta("terminal_output", "first\nsecond\n"), wantOutput: "first\nsecond\n"}, + {rawOutput: map[string]any{"formatted_output": "first\nsecond\n", "exit_code": float64(0)}, status: &status, wantOutput: "first\nsecond\n"}, + } + for _, update := range updates { + events, err := turn.applyToolUpdate(&acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", Meta: update.meta, RawOutput: update.rawOutput, Status: update.status, + }) + if err != nil { + t.Fatalf("applyToolUpdate() error = %v", err) + } + if events.output != update.wantOutput || events.streamOutput != update.stream { + t.Fatalf("tool output = %q, stream = %v", events.output, events.streamOutput) + } + turn.emitToolUpdate("call-1", events) + } + sink.mu.Lock() + outputs := append([]string(nil), sink.outputs...) + sink.mu.Unlock() + if len(outputs) != 2 || outputs[0] != "call-1:first\n" || outputs[1] != "call-1:first\nsecond\n" { + t.Fatalf("adapter output events = %v", outputs) + } +} + +func adapterTerminalMeta(name, data string) map[string]any { + return map[string]any{name: map[string]any{"data": data}} +} + func TestToolCallMessageUsesRunningOutputAndInput(t *testing.T) { call := &toolCall{name: "shell", input: `{"command":"ls"}`, state: console.AgentMessageToolStateRunning} message := call.message() diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go index 93cf141211..440fc80e96 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go @@ -11,6 +11,8 @@ import ( type SessionSettings struct { ModeID string ModelID string + // Reasoning is applied through the provider's reasoning_effort option. + Reasoning string } // Request contains the provider-neutral inputs for one ACP turn. diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go index 21f04746b2..eabeb928b5 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go @@ -167,7 +167,8 @@ func (turn *turnState) startTool(update *acpsdk.SessionUpdateToolCall) error { turn.mu.Unlock() return turn.fail(err.Error()) } - call.output = call.toolOutput(update.Content, update.RawOutput) + toolOutputValue := call.toolOutput(update.Content, update.Meta, update.RawOutput) + call.applyOutput(toolOutputValue) turn.tools[id] = call message := call.message() output := call.output @@ -199,8 +200,9 @@ func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (to } metadataChanged := call.updateMetadata(update) previousOutput := call.output - if output := call.toolOutput(update.Content, update.RawOutput); output != "" { - call.addOutput(output) + output := call.toolOutput(update.Content, update.Meta, update.RawOutput) + if output.text != "" { + call.applyOutput(output) } streamOutput := call.output != previousOutput && (previousOutput == "" || strings.HasPrefix(call.output, previousOutput)) terminal, statusChanged, err := call.updateStatus(update.Status) @@ -223,6 +225,14 @@ func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (to }, nil } +func (call *toolCall) applyOutput(output toolOutputValue) { + if output.delta { + call.appendOutput(output.text) + return + } + call.addOutput(output.text) +} + func (turn *turnState) emitToolUpdate(id acpsdk.ToolCallId, events toolUpdateEvents) { if events.streamOutput { turn.sink.ToolCallOutput(string(id), events.output) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/acp_environment.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/acp_environment.go new file mode 100644 index 0000000000..063038c320 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/acp_environment.go @@ -0,0 +1,59 @@ +package codex + +import ( + "encoding/json" + "fmt" + + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +// These names are the environment variables exchanged between the agent-run +// process and Codex's ACP adapter. +const ( + consoleTokenEnv = "PLRL_CONSOLE_TOKEN" + openAIAPIKeyEnv = "OPENAI_API_KEY" + codexAPIKeyEnv = "CODEX_API_KEY" + codexConfigEnv = "CODEX_CONFIG" + codexHomeEnv = "CODEX_HOME" + defaultAuthRequestEnv = "DEFAULT_AUTH_REQUEST" + modelProviderEnv = "MODEL_PROVIDER" + noBrowserEnv = "NO_BROWSER" +) + +// These fixed bootstrap values let Codex authenticate with an API key without +// opening a browser in the agent-run pod. +const ( + defaultAuthRequest = `{"methodId":"api-key"}` + noBrowserValue = "1" +) + +func (agent *Agent) env(config toolv1.Config, model, provider string) ([]string, error) { + codex := config.Run.Runtime.Config.Codex + credential := codex.ApiKey + if config.Run.IsProxyEnabled() { + credential = agent.consoleToken + } + + configJSON, err := json.Marshal(map[string]string{"model": model}) + if err != nil { + return nil, fmt.Errorf("marshal codex ACP config: %w", err) + } + + env := []string{ + fmt.Sprintf("%s=%s", consoleTokenEnv, agent.consoleToken), + fmt.Sprintf("%s=%s", codexHomeEnv, agent.codexHome(config)), + fmt.Sprintf("%s=%s", codexAPIKeyEnv, credential), + fmt.Sprintf("%s=%s", defaultAuthRequestEnv, defaultAuthRequest), + fmt.Sprintf("%s=%s", noBrowserEnv, noBrowserValue), + fmt.Sprintf("%s=%s", codexConfigEnv, configJSON), + } + + if !config.Run.IsProxyEnabled() { + env = append(env, fmt.Sprintf("%s=%s", openAIAPIKeyEnv, codex.ApiKey)) + } + if provider != "" { + env = append(env, fmt.Sprintf("%s=%s", modelProviderEnv, provider)) + } + + return env, nil +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/acp_environment_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/acp_environment_test.go new file mode 100644 index 0000000000..848470c38a --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/acp_environment_test.go @@ -0,0 +1,52 @@ +package codex + +import ( + "strings" + "testing" + + console "github.com/pluralsh/console/go/client" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestBuildACPEnvironmentUsesConsoleCredential(t *testing.T) { + for _, test := range []struct { + name string + streaming bool + }{ + {name: "proxy"}, + {name: "streaming proxy", streaming: true}, + } { + t.Run(test.name, func(t *testing.T) { + token := "console-token" + run := codexTestRun(console.AgentRunModeWrite, "gpt-5.4", true) + run.Runtime.StreamingProxy = test.streaming + run.PluralCreds = &console.PluralCredsFragment{Token: &token} + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: run} + env, err := NewAgent(config).env(config, "openai/gpt-5.4", pluralProvider) + if err != nil { + t.Fatalf("buildACPEnvironment() failed: %v", err) + } + values := testEnvValues(env) + if values[consoleTokenEnv] != token || values[codexAPIKeyEnv] != token { + t.Fatal("proxy environment did not use the Console credential") + } + if _, ok := values[openAIAPIKeyEnv]; ok { + t.Fatal("proxy environment included the direct API key variable") + } + if values[modelProviderEnv] != pluralProvider || values[codexConfigEnv] != `{"model":"openai/gpt-5.4"}` { + t.Fatal("proxy environment lost provider or model configuration") + } + }) + } +} + +func testEnvValues(env []string) map[string]string { + values := make(map[string]string, len(env)) + for _, item := range env { + key, value, ok := strings.Cut(item, "=") + if ok { + values[key] = value + } + } + return values +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go new file mode 100644 index 0000000000..2e70b27de1 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go @@ -0,0 +1,207 @@ +package codex + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/samber/lo" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +// These paths keep Codex's native state, prompt, and skills inside the +// workspace owned by this Agent. +const ( + codexHomeDir = ".codex" + codexSkillsDir = "skills" +) + +// Agent owns Codex settings, shared prompt and skills preparation, native +// configuration, and staging of Codex's native session data. +type Agent struct { + config toolv1.Config + consoleURL string + consoleToken string +} + +var _ toolv1.Agent = (*Agent)(nil) + +// NewAgent creates a Codex Agent for one agent run. +func NewAgent(config toolv1.Config) *Agent { + agent := &Agent{config: config} + if config.Run != nil && config.Run.PluralCreds != nil { + agent.consoleToken = lo.FromPtr(config.Run.PluralCreds.Token) + } + return agent +} + +// Type identifies the Console runtime implemented by Agent. +func (*Agent) Type() console.AgentRuntimeType { + return console.AgentRuntimeTypeCodex +} + +// Capabilities advertises the modes supported by Codex profiles. +func (*Agent) Capabilities() toolv1.AgentCapabilities { + return toolv1.AgentCapabilities{Modes: []console.AgentRunMode{ + console.AgentRunModeAnalyze, + console.AgentRunModeWrite, + console.AgentRunModeReview, + }} +} + +// Prepare writes Codex's shared system prompt and skills for a configuration +// phase. Native TOML configuration is written separately by Configure. +func (agent *Agent) Prepare(ctx context.Context, request toolv1.FileSystemRequest) error { + if err := agent.contextError(ctx); err != nil { + return err + } + config, err := agent.configForFilesystem(request) + if err != nil { + return err + } + + defaultTool := toolv1.DefaultTool{Config: config} + switch request.Phase { + case toolv1.ConfigurePhaseInitial: + err = defaultTool.ConfigureSystemPrompt(console.AgentRuntimeTypeCodex) + case toolv1.ConfigurePhaseBabysit: + err = defaultTool.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeCodex) + default: + return fmt.Errorf("unsupported codex configuration phase %q", request.Phase) + } + if err != nil { + return err + } + + if err := agent.contextError(ctx); err != nil { + return err + } + return defaultTool.ConfigureSkills(agent.skillsPath(config)) +} + +// Configure writes Codex's native TOML configuration for the initial phase. +// Babysit reuses that configuration while refreshing only prompt and skills. +func (agent *Agent) Configure(ctx context.Context, request toolv1.ConfigureRequest) error { + if err := agent.contextError(ctx); err != nil { + return err + } + if request.Phase != toolv1.ConfigurePhaseInitial && request.Phase != toolv1.ConfigurePhaseBabysit { + return fmt.Errorf("unsupported codex configuration phase %q", request.Phase) + } + if request.Phase == toolv1.ConfigurePhaseBabysit { + return nil + } + + config, err := agent.configWithCodex() + if err != nil { + return err + } + + agent.consoleURL = request.ConsoleURL + if request.ConsoleToken != "" { + agent.consoleToken = request.ConsoleToken + } + + model := agent.resolveModelForSettings(config, request.Settings) + + return agent.writeNativeConfig(config, model) +} + +// Export stages all native Codex sessions below OutputDir. Codex's session +// filenames and JSONL schema are provider-owned, so the complete sessions tree +// is copied into a disposable staging directory for artifact building. +func (agent *Agent) Export(ctx context.Context, request toolv1.ExportRequest) (toolv1.ExportResult, error) { + if err := agent.contextError(ctx); err != nil { + return toolv1.ExportResult{}, err + } + if request.SessionID == "" { + return toolv1.ExportResult{}, fmt.Errorf("codex session id is not set") + } + if request.OutputDir == "" { + return toolv1.ExportResult{}, fmt.Errorf("codex export output directory is not set") + } + + config, err := agent.configWithCodex() + if err != nil { + return toolv1.ExportResult{}, err + } + source := filepath.Join(agent.codexHome(config), codexSessionsDir) + if _, err := os.Stat(source); err != nil { + if errors.Is(err, os.ErrNotExist) { + return toolv1.ExportResult{}, nil + } + return toolv1.ExportResult{}, fmt.Errorf("stat codex sessions: %w", err) + } + + if err := agent.copySessionDirectory(ctx, source, request.OutputDir); err != nil { + return toolv1.ExportResult{}, err + } + return toolv1.ExportResult{SessionSource: artifacts.SessionSource{ + Path: request.OutputDir, + ArchivePath: codexSessionsDir, + }}, nil +} + +func (agent *Agent) configWithCodex() (toolv1.Config, error) { + if agent.config.WorkDir == "" { + return toolv1.Config{}, fmt.Errorf("work directory is not set") + } + if agent.config.RepositoryDir == "" { + return toolv1.Config{}, fmt.Errorf("repository directory is not set") + } + if _, err := agent.runConfig(agent.config.Run); err != nil { + return toolv1.Config{}, err + } + return agent.config, nil +} + +func (agent *Agent) configForFilesystem(request toolv1.FileSystemRequest) (toolv1.Config, error) { + if request.WorkDir == "" { + return toolv1.Config{}, fmt.Errorf("work directory is not set") + } + if request.RepositoryDir == "" { + return toolv1.Config{}, fmt.Errorf("repository directory is not set") + } + if agent.config.Run == nil { + return toolv1.Config{}, fmt.Errorf("agent run is not set") + } + config := agent.config + config.WorkDir = request.WorkDir + config.RepositoryDir = request.RepositoryDir + return config, nil +} + +func (*Agent) runConfig(run *agentrunv1.AgentRun) (*agentrunv1.CodexConfig, error) { + if run == nil { + return nil, fmt.Errorf("agent run is not set") + } + if run.Runtime == nil || run.Runtime.Config == nil || run.Runtime.Config.Codex == nil { + return nil, fmt.Errorf("codex runtime configuration is not set") + } + return run.Runtime.Config.Codex, nil +} + +func (agent *Agent) codexHome(config toolv1.Config) string { + return filepath.Join(config.WorkDir, codexHomeDir) +} + +func (agent *Agent) skillsPath(config toolv1.Config) string { + return filepath.Join(agent.codexHome(config), codexSkillsDir) +} + +func (agent *Agent) systemPromptPath(config toolv1.Config) (string, error) { + return filepath.Abs(filepath.Join(agent.codexHome(config), toolv1.SystemPromptFile)) +} + +func (*Agent) contextError(ctx context.Context) error { + if ctx == nil { + return nil + } + return ctx.Err() +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config.go new file mode 100644 index 0000000000..0965f5340a --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config.go @@ -0,0 +1,155 @@ +package codex + +import ( + "fmt" + "os" + "path/filepath" + + "k8s.io/klog/v2" + + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/dind" + mcpcfg "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/common" +) + +// These names are the shell environment variables allowed by native Codex +// configuration. +const ( + gitAccessTokenEnv = "GIT_ACCESS_TOKEN" + pathEnv = "PATH" + homeEnv = "HOME" + gitSigningKeyEnv = "GIT_SIGNING_KEY_PATH" +) + +// The mounted signing key is exposed to Codex only when this agent-run path +// exists and can be used by the shell environment policy. +const gitSigningKeyPath = common.GitSigningKeyMountPath + +// Codex trusts the agent-run pod for isolation and never waits for an +// interactive approval during a configured run. +const ( + sandboxModeHarness = "danger-full-access" + approvalPolicyNever = "never" +) + +// Built-in and user-configured MCP servers use these Codex transport and trust +// labels when native configuration is generated. +const ( + mcpHTTPTransport = "http" + mcpStdioTransport = "stdio" + trustPolicyAlways = "always" +) + +func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error { + external, err := mcpcfg.Load() + if err != nil { + return err + } + + profile, ok := agent.profileForMode(config.Run.Mode) + if !ok { + return fmt.Errorf("unsupported agent run mode %q for codex", config.Run.Mode) + } + modelInstructionsFile, err := agent.systemPromptPath(config) + if err != nil { + return err + } + + provider, baseURL, envKey, wireAPI := agent.resolveProviderSettings(config) + var providers []configTemplateProvider + if provider != "" { + providers = []configTemplateProvider{{ + Name: provider, + BaseURL: baseURL, + EnvKey: envKey, + WireAPI: wireAPI, + }} + } + + templateInput := &ConfigTemplateInput{ + RepositoryDir: config.RepositoryDir, + Profile: configTemplateProfile{ + Name: profile, + Model: model, + ModelProvider: provider, + SandboxMode: sandboxModeHarness, + ApprovalPolicy: approvalPolicyNever, + ModelReasoningEffort: defaultReasoning, + ShellEnvironmentPolicy: agent.shellEnvironmentPolicy(config.Run.DindEnabled), + EnableWebSearch: true, + EnableShellCache: true, + ModelInstructionsFile: modelInstructionsFile, + }, + Providers: providers, + MCPServers: agent.nativeMCPServers(external), + } + + configPath, err := agent.writeConfig(filepath.Join(agent.codexHome(config)), templateInput) + if err != nil { + return err + } + + klog.InfoS("Codex configured", "configPath", configPath) + return nil +} + +func (agent *Agent) nativeMCPServers(external []mcpcfg.Server) []configTemplateMCP { + result := []configTemplateMCP{{ + Name: pluralProvider, + Type: mcpHTTPTransport, + URL: common.AgentMCPServerURL, + TrustPolicy: trustPolicyAlways, + }, { + Name: common.CodebaseMemoryMCPServerName, + Type: mcpStdioTransport, + Command: common.CodebaseMemoryMCPCommand, + Env: agent.templateKeyValues(map[string]string{common.CodebaseMemoryCacheEnv: common.CodebaseMemoryCacheDir}), + TrustPolicy: trustPolicyAlways, + }} + indices := map[string]int{ + pluralProvider: 0, + common.CodebaseMemoryMCPServerName: 1, + } + + for _, server := range external { + input := configTemplateMCP{ + Name: server.Name, + URL: server.URL, + HTTPHeaders: agent.templateKeyValues(server.Headers), + TrustPolicy: trustPolicyAlways, + } + if server.HasAllowedTools() { + input.EnabledTools = server.AllowedTools + } + if index, ok := indices[server.Name]; ok { + result[index] = input + continue + } + indices[server.Name] = len(result) + result = append(result, input) + } + return result +} + +func (agent *Agent) shellEnvironmentVariables(dindEnabled bool) []string { + vars := []string{pathEnv, homeEnv, gitAccessTokenEnv} + if _, err := os.Stat(gitSigningKeyPath); err == nil { + vars = append(vars, gitSigningKeyEnv) + } + if dindEnabled { + vars = append(vars, dind.DockerHostEnv) + } + return vars +} + +func (agent *Agent) shellEnvironmentPolicy(dindEnabled bool) *configTemplateShellEnvironmentPolicy { + policy := &configTemplateShellEnvironmentPolicy{IncludeOnly: agent.shellEnvironmentVariables(dindEnabled)} + if !dindEnabled { + return policy + } + if value := os.Getenv(dind.DockerHostEnv); value != "" { + policy.Set = []configTemplateKeyValue{{Key: dind.DockerHostEnv, Value: value}} + } + return policy +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config_test.go new file mode 100644 index 0000000000..52937ffa04 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config_test.go @@ -0,0 +1,96 @@ +package codex + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestAgentPrepareAndConfigurePhases(t *testing.T) { + useCodexSystemTemplates(t) + workDir := t.TempDir() + repositoryDir := t.TempDir() + run := codexTestRun(console.AgentRunModeWrite, "gpt-5.1-codex", true) + run.Prompt = "initial prompt" + run.Skills = []agentrunv1.AgentSkill{{Name: "guide", Contents: "inspect changes"}} + config := toolv1.Config{WorkDir: workDir, RepositoryDir: repositoryDir, Run: run} + agent := NewAgent(config) + request := toolv1.FileSystemRequest{Phase: toolv1.ConfigurePhaseInitial, WorkDir: workDir, RepositoryDir: repositoryDir} + if err := agent.Prepare(context.Background(), request); err != nil { + t.Fatalf("Prepare(initial) error = %v", err) + } + promptPath := filepath.Join(workDir, codexHomeDir, toolv1.SystemPromptFile) + prompt, err := os.ReadFile(promptPath) + if err != nil { + t.Fatalf("read prompt: %v", err) + } + if !strings.Contains(string(prompt), "initial prompt") { + t.Fatalf("prompt does not contain run prompt: %s", prompt) + } + if _, err := os.Stat(filepath.Join(workDir, codexHomeDir, codexSkillsDir, "guide", "SKILL.md")); err != nil { + t.Fatalf("skill file was not prepared: %v", err) + } + settings, err := agent.ResolveSettings(run) + if err != nil { + t.Fatalf("ResolveSettings() error = %v", err) + } + if err := agent.Configure(context.Background(), toolv1.ConfigureRequest{ + Phase: toolv1.ConfigurePhaseInitial, + ConsoleURL: "https://console.example", + ConsoleToken: "console-token", + Settings: settings, + }); err != nil { + t.Fatalf("Configure(initial) error = %v", err) + } + nativePath := filepath.Join(workDir, codexHomeDir, "config.toml") + native, err := os.ReadFile(nativePath) + if err != nil { + t.Fatalf("read native config: %v", err) + } + if !strings.Contains(string(native), "https://console.example/ext/ai/v1") || + !strings.Contains(string(native), `model = "openai/gpt-5.1-codex"`) || + !strings.Contains(string(native), "web_search_request = true") || + !strings.Contains(string(native), "shell_snapshot = true") { + t.Fatalf("native config lost proxy settings: %s", native) + } + request.Phase = toolv1.ConfigurePhaseBabysit + if err := agent.Prepare(context.Background(), request); err != nil { + t.Fatalf("Prepare(babysit) error = %v", err) + } + if err := agent.Configure(context.Background(), toolv1.ConfigureRequest{Phase: toolv1.ConfigurePhaseBabysit}); err != nil { + t.Fatalf("Configure(babysit) error = %v", err) + } + after, err := os.ReadFile(nativePath) + if err != nil { + t.Fatalf("read native config after babysit: %v", err) + } + if string(native) != string(after) { + t.Fatal("babysit configuration unexpectedly rewrote native config") + } +} + +func TestResolveProviderSettingsPreservesProxyEndpointAndWirePolicy(t *testing.T) { + endpoint := "https://custom.example/v1" + method := console.OpenAiMethodChat + run := codexTestRun(console.AgentRunModeWrite, "gpt-5.4", true) + run.Runtime.Config.Codex.Endpoint = &endpoint + run.Runtime.Config.Codex.Method = string(method) + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: run} + agent := NewAgent(config) + agent.consoleURL = "https://console.example" + provider, baseURL, _, wireAPI := agent.resolveProviderSettings(config) + if provider != pluralProvider || baseURL != "https://console.example/ext/ai/v1" || wireAPI != chatWireAPI { + t.Fatalf("proxy provider settings = %q, %q, %q", provider, baseURL, wireAPI) + } + run.Runtime.AiProxy = false + provider, baseURL, _, wireAPI = agent.resolveProviderSettings(config) + if provider != customProvider || baseURL != endpoint || wireAPI != chatWireAPI { + t.Fatalf("custom provider settings = %q, %q, %q", provider, baseURL, wireAPI) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_test.go new file mode 100644 index 0000000000..3dc5cc3fd7 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_test.go @@ -0,0 +1,82 @@ +package codex + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestAgentExportStagesCodexSessions(t *testing.T) { + workDir := t.TempDir() + sessionDir := filepath.Join(workDir, codexHomeDir, codexSessionsDir, "2026", "09", "04") + if err := os.MkdirAll(sessionDir, 0755); err != nil { + t.Fatal(err) + } + sessionPath := filepath.Join(sessionDir, "rollout-thread-1.jsonl") + if err := os.WriteFile(sessionPath, []byte(`{"type":"thread.started","thread_id":"thread-1"}`), 0644); err != nil { + t.Fatal(err) + } + agent := NewAgent(toolv1.Config{WorkDir: workDir, RepositoryDir: t.TempDir(), Run: codexTestRun(console.AgentRunModeWrite, "", false)}) + outputDir := t.TempDir() + result, err := agent.Export(context.Background(), toolv1.ExportRequest{SessionID: "thread-1", OutputDir: outputDir}) + if err != nil { + t.Fatalf("Export() error = %v", err) + } + if result.SessionSource.Path != outputDir || result.SessionSource.ArchivePath != codexSessionsDir { + t.Fatalf("session source = %#v", result.SessionSource) + } + staged, err := os.ReadFile(filepath.Join(outputDir, "2026", "09", "04", "rollout-thread-1.jsonl")) + if err != nil { + t.Fatalf("read staged session: %v", err) + } + if string(staged) != `{"type":"thread.started","thread_id":"thread-1"}` { + t.Fatalf("staged session = %q", staged) + } + if _, err := os.Stat(sessionPath); err != nil { + t.Fatalf("live session was removed: %v", err) + } +} + +func TestAgentExportWithoutSessionsReturnsNoSource(t *testing.T) { + agent := NewAgent(toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: codexTestRun(console.AgentRunModeWrite, "", false)}) + result, err := agent.Export(context.Background(), toolv1.ExportRequest{SessionID: "thread-1", OutputDir: t.TempDir()}) + if err != nil { + t.Fatalf("Export() error = %v", err) + } + if result.SessionSource.Path != "" || result.SessionSource.ArchivePath != "" { + t.Fatalf("session source = %#v, want empty", result.SessionSource) + } +} + +func codexTestRun(mode console.AgentRunMode, model string, proxy bool) *agentrunv1.AgentRun { + return &agentrunv1.AgentRun{ + ID: "run-1", Mode: mode, + Runtime: &agentrunv1.AgentRuntime{ + AiProxy: proxy, + Config: &agentrunv1.AgentRuntimeConfig{Codex: &agentrunv1.CodexConfig{ + Model: model, ApiKey: "api-key", Timeout: 9 * time.Minute, + }}, + }, + } +} + +func useCodexSystemTemplates(t *testing.T) { + t.Helper() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "system"), 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"analyze", "write", "review", "babysit"} { + path := filepath.Join(root, "system", name+".md.tmpl") + if err := os.WriteFile(path, []byte(name+" {{.Prompt}}"), 0644); err != nil { + t.Fatal(err) + } + } + t.Chdir(root) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/artifacts.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/artifacts.go deleted file mode 100644 index 8992704fb9..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/artifacts.go +++ /dev/null @@ -1,16 +0,0 @@ -package codex - -import ( - "context" - "path/filepath" - - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" -) - -func (in *Codex) UploadArtifacts(ctx context.Context) (*artifacts.UploadArtifacts, error) { - return in.BuildUploadArtifacts(ctx, artifacts.BuildArtifactsOptions{ - Provider: "codex", - Source: artifacts.SessionSource{Path: filepath.Join(in.codexHome(), "sessions"), ArchivePath: "sessions"}, - SessionID: in.threadID, - }) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex.go deleted file mode 100644 index 862cfa3bdc..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex.go +++ /dev/null @@ -1,412 +0,0 @@ -package codex - -import ( - "context" - "fmt" - "os" - "path" - "path/filepath" - - "github.com/samber/lo" - "k8s.io/klog/v2" - - console "github.com/pluralsh/console/go/client" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/dind" - mcpcfg "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" - proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" - "github.com/pluralsh/console/go/deployment-operator/pkg/common" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" - - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" -) - -const ( - consoleTokenEnv = "PLRL_CONSOLE_TOKEN" - gitAccessTokenEnv = "GIT_ACCESS_TOKEN" - openAIAPIKeyEnv = "OPENAI_API_KEY" - gitAskpassPath = "/plural/.git-askpass" - gitSigningKeyPath = common.GitSigningKeyMountPath - autonomousProfile = "autonomous" - reviewProfile = "review" - openAIProvider = "openai-api" - openAIBaseURL = "https://api.openai.com/v1" - // sandboxModeHarness disables Codex OS sandboxing; the agent-run pod is the isolation boundary. - sandboxModeHarness = "danger-full-access" -) - -func profileForMode(mode console.AgentRunMode) (string, bool) { - switch mode { - case console.AgentRunModeAnalyze: - return "analysis", true - case console.AgentRunModeWrite: - return autonomousProfile, true - case console.AgentRunModeReview: - return reviewProfile, true - default: - return "", false - } -} - -func New(config v1.Config) v1.Tool { - result := &Codex{ - DefaultTool: v1.DefaultTool{Config: config}, - apiKey: config.Run.Runtime.Config.Codex.ApiKey, - model: EnsureModel(config.Run.Runtime.Config.Codex.Model), - proxy: config.Run.IsProxyEnabled(), - toolItems: make(map[string]*StreamItem), - } - - if config.Run.PluralCreds != nil { - result.consoleToken = lo.FromPtr(config.Run.PluralCreds.Token) - } - - if err := result.ensure(); err != nil { - klog.Fatalf("failed to initialize codex tool: %v", err) - } - - return result -} - -func (in *Codex) ensure() error { - if len(in.Config.WorkDir) == 0 { - return fmt.Errorf("work directory is not set") - } - - if len(in.Config.RepositoryDir) == 0 { - return fmt.Errorf("repository directory is not set") - } - - return nil -} - -func (in *Codex) Run(ctx context.Context, options ...exec.Option) { - go in.start(ctx, options...) -} - -func (in *Codex) ConfigureBabysitRun() error { - klog.Info("configuring codex babysit run") - // model_instructions_file points at AGENTS.md; re-rendering the file is enough. - if err := in.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeCodex); err != nil { - return err - } - - return in.ConfigureSkills(in.skillsPath()) -} - -func (in *Codex) Configure(consoleURL, consoleToken string) error { - in.consoleURL = consoleURL - if consoleToken != "" { - in.consoleToken = consoleToken - } - - if err := in.ConfigureSystemPrompt(console.AgentRuntimeTypeCodex); err != nil { - return err - } - if err := in.ConfigureSkills(in.skillsPath()); err != nil { - return err - } - - return in.writeCodexConfig() -} - -func (in *Codex) writeCodexConfig() error { - allowedEnvVars := codexAllowedEnvVars(in.Config.Run.DindEnabled) - modelInstructionsFile, err := in.systemPromptPath() - if err != nil { - return err - } - dindEnabled := in.Config.Run.DindEnabled - - model := string(in.model) - if in.proxy { - model = proxymodel.ProxyModel(console.AgentRuntimeTypeCodex, model) - } - - baseAgent := AgentInput{ - Model: model, - ApprovalPolicy: "never", - ModelReasoningEffort: "medium", - AllowedEnvVars: allowedEnvVars, - EnableWebSearch: true, - EnableShellCache: true, - } - - var ( - agents []AgentInput - mcps []MCPInput - providers []ModelProviderInput - ) - - modelProvider := "" - wireAPI := codexWireAPI(in.Config.Run.Runtime.Config.Codex.Method) - switch { - case in.proxy: - modelProvider = "plural" - baseURL := fmt.Sprintf("%s/ext/ai/v1", in.consoleURL) - if in.Config.Run.IsStreamingProxyEnabled() { - baseURL = common.AgentOpenAIBaseURL - } - providers = []ModelProviderInput{{ - Name: "plural", - BaseURL: baseURL, - EnvKey: consoleTokenEnv, - WireAPI: wireAPI, - }} - case in.Config.Run.Runtime.Config.Codex.Endpoint != nil: - modelProvider = "custom" - providers = []ModelProviderInput{{ - Name: "custom", - BaseURL: *in.Config.Run.Runtime.Config.Codex.Endpoint, - EnvKey: openAIAPIKeyEnv, - WireAPI: wireAPI, - }} - case wireAPI != "": - modelProvider = openAIProvider - providers = []ModelProviderInput{{ - Name: openAIProvider, - BaseURL: openAIBaseURL, - EnvKey: openAIAPIKeyEnv, - WireAPI: wireAPI, - }} - } - - mcps = []MCPInput{{ - Name: "plural", - Type: "http", - URL: common.AgentMCPServerURL, - TrustPolicy: "always", - }, { - Name: common.CodebaseMemoryMCPServerName, - Type: "stdio", - Command: common.CodebaseMemoryMCPCommand, - Env: map[string]string{common.CodebaseMemoryCacheEnv: common.CodebaseMemoryCacheDir}, - TrustPolicy: "always", - }} - - external, err := mcpcfg.Load() - if err != nil { - return err - } - for _, server := range external { - input := MCPInput{ - Name: server.Name, - URL: server.URL, - HTTPHeaders: server.Headers, - TrustPolicy: "always", - } - if server.HasAllowedTools() { - input.EnabledTools = server.AllowedTools - } - mcps = append(mcps, input) - } - - profile, ok := profileForMode(in.Config.Run.Mode) - if !ok { - return fmt.Errorf("unsupported agent run mode %q for codex", in.Config.Run.Mode) - } - agents = []AgentInput{{ - Name: profile, - SandboxMode: sandboxModeHarness, - Model: baseAgent.Model, - ApprovalPolicy: baseAgent.ApprovalPolicy, - ModelReasoningEffort: baseAgent.ModelReasoningEffort, - AllowedEnvVars: baseAgent.AllowedEnvVars, - ModelProvider: modelProvider, - ModelInstructionsFile: modelInstructionsFile, - DindEnabled: dindEnabled, - }} - - cfg, err := BuildCodexConfig(in.Config.RepositoryDir, agents, mcps, providers) - if err != nil { - return err - } - - config, err := WriteCodexConfig(path.Join(in.Config.WorkDir, ".codex"), cfg) - if err != nil { - return err - } - - klog.InfoS("Codex configured", "configPath", config) - - return nil -} - -func (in *Codex) OnMessage(f v1.MessageCallback) { - in.onMessage = f -} - -func (in *Codex) BabysitRun(ctx context.Context, bCtx *v1.BabysitContext) bool { - if bCtx == nil { - return false - } - - args := codexExecArgs(in.Config.RepositoryDir, autonomousProfile, bCtx.Prompt, in.threadID) - - in.executable = exec.NewExecutable( - "codex", - append(in.codexExecOptions(), exec.WithArgs(args))..., - ) - - klog.V(log.LogLevelInfo).InfoS("codex executable configured", "timeout", in.Config.Run.Runtime.Config.Codex.Timeout) - - // Send the initial prompt as a message too - if in.onMessage != nil { - in.onMessage(&console.AgentMessageAttributes{Message: bCtx.Prompt, Role: console.AiRoleUser}, "") - } - - in.resetToolItems() - err := in.executable.RunStream(ctx, in.handleStreamLine) - if err != nil { - klog.ErrorS(err, "codex execution failed") - in.Config.ErrorChan <- err - return false - } - - klog.V(log.LogLevelExtended).InfoS("codex babysit run finished") - return false -} - -// FollowUpRun re-runs Codex with followUpPrompt. Errors are returned to the -// caller and must not be sent on ErrorChan. -func (in *Codex) FollowUpRun(ctx context.Context, followUpPrompt string) error { - klog.V(log.LogLevelInfo).InfoS( - "follow-up: reprompting codex", - "prompt_len", len(followUpPrompt), - "resumeSession", in.threadID != "", - "sessionID", in.threadID, - ) - - profile, _ := profileForMode(in.Config.Run.Mode) - args := codexExecArgs(in.Config.RepositoryDir, profile, followUpPrompt, in.threadID) - - in.executable = exec.NewExecutable( - "codex", - append(in.codexExecOptions(), exec.WithArgs(args))..., - ) - - in.resetToolItems() - err := in.executable.RunStream(ctx, in.handleStreamLine) - if err != nil { - return fmt.Errorf("codex follow-up execution failed: %w", err) - } - klog.V(log.LogLevelExtended).InfoS("codex follow-up execution finished") - return nil -} - -func (in *Codex) start(ctx context.Context, options ...exec.Option) { - // In proxy mode the plural provider handles auth via PLRL_CONSOLE_TOKEN; - // codex login is only needed for direct OpenAI usage. - if !in.proxy && in.Config.Run.Runtime.Config.Codex.Endpoint == nil { - loginArgs := []string{"-c", fmt.Sprintf("printenv %s | codex login --with-api-key", openAIAPIKeyEnv)} - in.executable = exec.NewExecutable( - "bash", - exec.WithArgs(loginArgs), - exec.WithDir(in.Config.WorkDir), - exec.WithEnv([]string{ - fmt.Sprintf("%s=%s", openAIAPIKeyEnv, in.apiKey), - fmt.Sprintf("CODEX_HOME=%s", in.codexHome()), - }), - exec.WithTimeout(in.Config.Run.Runtime.Config.Codex.Timeout), - ) - if err := in.executable.Run(ctx); err != nil { - klog.ErrorS(err, "codex login failed") - in.Config.ErrorChan <- err - return - } - } - - agent, _ := profileForMode(in.Config.Run.Mode) - - args := codexExecArgs(in.Config.RepositoryDir, agent, in.Config.Run.Prompt, "") - - in.executable = exec.NewExecutable( - "codex", - append( - options, - append(in.codexExecOptions(), exec.WithArgs(args))..., - )..., - ) - - klog.V(log.LogLevelInfo).InfoS("codex executable configured", "timeout", in.Config.Run.Runtime.Config.Codex.Timeout) - - // Send the initial prompt as a message too - if in.onMessage != nil { - in.onMessage(&console.AgentMessageAttributes{Message: in.Config.Run.Prompt, Role: console.AiRoleUser}, "") - } - - in.resetToolItems() - err := in.executable.RunStream(ctx, in.handleStreamLine) - if err != nil { - klog.ErrorS(err, "codex execution failed") - in.Config.ErrorChan <- err - return - } - klog.V(log.LogLevelExtended).InfoS("codex execution finished") - // FinishedChan is closed by the controller after the babysit loop exits. -} - -func (in *Codex) codexExecOptions() []exec.Option { - env := []string{ - fmt.Sprintf("PLRL_CONSOLE_TOKEN=%s", in.consoleToken), - fmt.Sprintf("CODEX_HOME=%s", in.codexHome()), - } - if !in.proxy && in.apiKey != "" { - env = append(env, fmt.Sprintf("%s=%s", openAIAPIKeyEnv, in.apiKey)) - } - - return []exec.Option{ - exec.WithDir(in.Config.RepositoryDir), - exec.WithEnv(env), - exec.WithTimeout(in.Config.Run.Runtime.Config.Codex.Timeout), - } -} - -func (in *Codex) codexHome() string { - return path.Join(in.Config.WorkDir, ".codex") -} - -func (in *Codex) skillsPath() string { - return path.Join(in.codexHome(), "skills") -} - -func (in *Codex) systemPromptPath() (string, error) { - p := path.Join(in.codexHome(), v1.SystemPromptFile) - return filepath.Abs(p) -} - -func codexExecArgs(repositoryDir, profile, prompt, resumeSessionID string) []string { - args := []string{ - "exec", - "--sandbox", sandboxModeHarness, - "--cd", repositoryDir, - "--profile", profile, - "--json", - } - if resumeSessionID != "" { - return append(args, "resume", resumeSessionID, prompt) - } - return append(args, prompt) -} - -func codexWireAPI(method string) string { - switch console.OpenAiMethod(method) { - case console.OpenAiMethodChat: - return "chat" - case console.OpenAiMethodResponses: - return "responses" - default: - return "" - } -} - -func codexAllowedEnvVars(dindEnabled bool) []string { - vars := []string{"PATH", "HOME", gitAccessTokenEnv} - if _, err := os.Stat(gitSigningKeyPath); err == nil { - vars = append(vars, "GIT_SIGNING_KEY_PATH") - } - if dindEnabled { - vars = append(vars, dind.DockerHostEnv) - } - return vars -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_stream.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_stream.go deleted file mode 100644 index b9fe2056c2..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_stream.go +++ /dev/null @@ -1,495 +0,0 @@ -package codex - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/samber/lo" - "k8s.io/klog/v2" - - console "github.com/pluralsh/console/go/client" - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" -) - -func (in *Codex) resetToolItems() { - in.toolItems = make(map[string]*StreamItem) -} - -func (in *Codex) handleStreamLine(line []byte) { - event := &StreamEvent{} - if err := json.Unmarshal(line, event); err != nil { - klog.V(log.LogLevelExtended).InfoS("failed to unmarshal codex stream event", "line", string(line)) - return - } - - if event.Type == streamEventTypeThreadStarted && event.ThreadID != "" { - in.threadID = event.ThreadID - klog.V(log.LogLevelDebug).InfoS("codex thread started", "thread_id", in.threadID) - } - - msg, callID := in.mapStreamEvent(event) - if in.onMessage != nil && msg != nil { - in.onMessage(msg, callID) - } -} - -// mapStreamEvent converts a Codex CLI JSON stream event (codex exec --json) into -// AgentMessageAttributes. See https://takopi.dev/reference/runners/codex/exec-json-cheatsheet/ -// The second return value is a tool call ID used to correlate start/complete updates. -func (in *Codex) mapStreamEvent(event *StreamEvent) (*console.AgentMessageAttributes, string) { - switch event.Type { - case streamEventTypeItemStarted: - in.cacheToolItem(event.Item) - return mapStartedStreamItem(event.Item, in.threadID) - case streamEventTypeItemCompleted: - if event.Item == nil { - return nil, "" - } - item := in.mergeToolItem(event.Item) - return mapCompletedStreamItem(item, in.threadID) - case streamEventTypeTurnCompleted: - if event.Usage == nil { - return nil, "" - } - in.Config.Usage.RecordUsage(usage.Record{ - InputTokens: event.Usage.InputTokens, - OutputTokens: event.Usage.OutputTokens, - CachedTokens: event.Usage.CachedInputTokens, - ReasoningTokens: event.Usage.ReasoningOutputTokens, - }) - totalTokens := float64(event.Usage.InputTokens + event.Usage.OutputTokens) - return &console.AgentMessageAttributes{ - Role: console.AiRoleAssistant, - Message: ignoredAgentMessage, - Cost: &console.AgentMessageCostAttributes{ - Total: totalTokens, - Tokens: &console.AgentMessageTokensAttributes{ - Input: lo.ToPtr(float64(event.Usage.InputTokens)), - Output: lo.ToPtr(float64(event.Usage.OutputTokens)), - }, - }, - }, "" - case streamEventTypeTurnFailed: - msg := "" - if event.Error != nil { - msg = event.Error.Message - } - if msg == "" { - return nil, "" - } - return &console.AgentMessageAttributes{ - Role: console.AiRoleAssistant, - Message: msg, - }, "" - case "error": - return &console.AgentMessageAttributes{ - Role: console.AiRoleAssistant, - Message: event.Message, - }, "" - } - return nil, "" -} - -func (in *Codex) cacheToolItem(item *StreamItem) { - if item == nil || item.ID == "" { - return - } - switch item.Type { - case streamItemTypeMCPToolCall, streamItemTypeCommandExecution, streamItemTypeDynamicToolCall: - in.toolItems[item.ID] = item - } -} - -func (in *Codex) mergeToolItem(item *StreamItem) *StreamItem { - if item == nil || item.ID == "" { - return item - } - cached, ok := in.toolItems[item.ID] - delete(in.toolItems, item.ID) - if !ok { - return item - } - - merged := *item - if isJSONNull(merged.Arguments) { - merged.Arguments = cached.Arguments - } - if merged.Command == "" { - merged.Command = cached.Command - } - if merged.Server == "" { - merged.Server = cached.Server - } - if merged.Tool == "" { - merged.Tool = cached.Tool - } - if merged.Namespace == "" { - merged.Namespace = cached.Namespace - } - return &merged -} - -func mapStartedStreamItem(item *StreamItem, threadID string) (*console.AgentMessageAttributes, string) { - if item == nil || item.ID == "" { - return nil, "" - } - switch item.Type { - case streamItemTypeCommandExecution: - klog.V(log.LogLevelDebug).InfoS("codex command execution started", "command", item.Command, "thread_id", threadID) - return toolCallMessage( - streamItemTypeCommandExecution, - console.AgentMessageToolStateRunning, - formatCommandInput(item.Command), - v1.RunningToolOutput, - ), item.ID - case streamItemTypeDynamicToolCall: - toolName := resolveDynamicToolName(item) - klog.V(log.LogLevelDebug).InfoS( - "codex dynamic tool call started", - "tool", toolName, - "namespace", item.Namespace, - "thread_id", threadID, - ) - return toolCallMessage( - toolName, - console.AgentMessageToolStateRunning, - formatDynamicToolInput(item), - v1.RunningToolOutput, - ), item.ID - case streamItemTypeMCPToolCall: - klog.V(log.LogLevelDebug).InfoS( - "codex mcp tool call started", - "server", item.Server, - "tool", item.Tool, - "thread_id", threadID, - ) - return toolCallMessage( - streamItemTypeMCPToolCall, - console.AgentMessageToolStateRunning, - formatMCPInput(item.Server, item.Tool, item.Arguments), - v1.RunningToolOutput, - ), item.ID - case streamItemTypeFileChange: - input, _ := json.Marshal(item.Changes) - klog.V(log.LogLevelDebug).InfoS("codex file change started", "thread_id", threadID) - return toolCallMessage( - streamItemTypeFileChange, - console.AgentMessageToolStateRunning, - string(input), - v1.RunningToolOutput, - ), item.ID - } - return nil, "" -} - -func mapCompletedStreamItem(item *StreamItem, threadID string) (*console.AgentMessageAttributes, string) { - switch item.Type { - case "error": - msg := lo.Ternary(len(item.Message) == 0, item.Text, item.Message) - if len(msg) == 0 { - return nil, "" - } - - klog.V(log.LogLevelDebug).InfoS("codex item error", "message", msg, "thread_id", threadID) - return &console.AgentMessageAttributes{ - Role: console.AiRoleAssistant, - Message: msg, - }, "" - case "reasoning": - // Reasoning summaries are not forwarded to the console API. - return nil, "" - - case "agent_message": - if item.Text == "" { - return nil, "" - } - klog.V(log.LogLevelDebug).InfoS("codex agent message", "text", item.Text, "thread_id", threadID) - return &console.AgentMessageAttributes{ - Role: console.AiRoleAssistant, - Message: item.Text, - }, "" - - case streamItemTypeCommandExecution: - return mapCommandExecutionItem(item, threadID) - - case streamItemTypeDynamicToolCall: - return mapDynamicToolCallItem(item, threadID) - - case streamItemTypeMCPToolCall: - return mapMCPToolCallItem(item, threadID) - - case streamItemTypeFileChange: - return mapFileChangeItem(item, threadID) - - case streamItemTypeWebSearch: - return mapWebSearchItem(item, threadID) - } - - return nil, "" -} - -func mapCommandExecutionItem(item *StreamItem, threadID string) (*console.AgentMessageAttributes, string) { - if item.Status != statusCompleted && item.Status != statusFailed { - return nil, "" - } - exitCode := 0 - if item.ExitCode != nil { - exitCode = *item.ExitCode - } - state := console.AgentMessageToolStateCompleted - if item.Status == statusFailed || exitCode != 0 { - state = console.AgentMessageToolStateError - } - klog.V(log.LogLevelDebug).InfoS("codex command execution", "command", item.Command, "exit_code", exitCode, "thread_id", threadID) - return toolCallMessage( - streamItemTypeCommandExecution, - state, - formatCommandInput(item.Command), - item.AggregatedOutput, - ), item.ID -} - -func mapDynamicToolCallItem(item *StreamItem, threadID string) (*console.AgentMessageAttributes, string) { - state, ok := dynamicToolState(item) - if !ok { - return nil, "" - } - toolName := resolveDynamicToolName(item) - output := formatDynamicToolOutput(item) - klog.V(log.LogLevelDebug).InfoS( - "codex dynamic tool call", - "tool", toolName, - "namespace", item.Namespace, - "status", item.Status, - "thread_id", threadID, - ) - return toolCallMessage( - toolName, - state, - formatDynamicToolInput(item), - output, - ), item.ID -} - -func mapMCPToolCallItem(item *StreamItem, threadID string) (*console.AgentMessageAttributes, string) { - if item.Status != statusCompleted && item.Status != statusFailed { - return nil, "" - } - state := console.AgentMessageToolStateCompleted - if item.Status == statusFailed { - state = console.AgentMessageToolStateError - } - output := formatMCPOutput(item) - klog.V(log.LogLevelDebug).InfoS( - "codex mcp tool call", - "server", item.Server, - "tool", item.Tool, - "status", item.Status, - "thread_id", threadID, - ) - return toolCallMessage( - streamItemTypeMCPToolCall, - state, - formatMCPInput(item.Server, item.Tool, item.Arguments), - output, - ), item.ID -} - -func mapFileChangeItem(item *StreamItem, threadID string) (*console.AgentMessageAttributes, string) { - if item.Status != statusCompleted && item.Status != statusFailed { - return nil, "" - } - state := console.AgentMessageToolStateCompleted - if item.Status == statusFailed { - state = console.AgentMessageToolStateError - } - paths := make([]string, 0, len(item.Changes)) - for _, c := range item.Changes { - paths = append(paths, fmt.Sprintf("%s:%s", c.Kind, c.Path)) - } - output := strings.Join(paths, ", ") - input, _ := json.Marshal(item.Changes) - klog.V(log.LogLevelDebug).InfoS("codex file change", "changes", output, "thread_id", threadID) - return toolCallMessage(streamItemTypeFileChange, state, string(input), output), item.ID -} - -func mapWebSearchItem(item *StreamItem, threadID string) (*console.AgentMessageAttributes, string) { - if item.Query == "" { - return nil, "" - } - klog.V(log.LogLevelDebug).InfoS("codex web search", "query", item.Query, "thread_id", threadID) - input, _ := json.Marshal(map[string]string{"query": item.Query}) - return toolCallMessage(streamItemTypeWebSearch, console.AgentMessageToolStateCompleted, string(input), ""), item.ID -} - -func toolCallMessage(name string, state console.AgentMessageToolState, input, output string) *console.AgentMessageAttributes { - tool := &console.AgentMessageToolAttributes{ - Name: lo.ToPtr(name), - State: lo.ToPtr(state), - Output: lo.ToPtr(output), // Always set output so empty stdout clears the "running..." placeholder on update. - } - if input != "" { - tool.Input = lo.ToPtr(input) - } - return &console.AgentMessageAttributes{ - Role: console.AiRoleAssistant, - Message: "Called tool", - Metadata: &console.AgentMessageMetadataAttributes{ - Tool: tool, - }, - } -} - -func formatMCPInput(server, tool string, arguments json.RawMessage) string { - payload := map[string]any{ - "server": server, - "tool": tool, - } - if hasJSONContent(arguments) { - var args map[string]any - if err := json.Unmarshal(arguments, &args); err == nil { - for k, v := range args { - payload[k] = v - } - } - } - encoded, err := json.Marshal(payload) - if err != nil { - return formatToolArguments(arguments) - } - return string(encoded) -} - -func dynamicToolState(item *StreamItem) (console.AgentMessageToolState, bool) { - switch item.Status { - case statusCompleted: - if item.Success != nil && !*item.Success { - return console.AgentMessageToolStateError, true - } - return console.AgentMessageToolStateCompleted, true - case statusFailed: - return console.AgentMessageToolStateError, true - default: - return "", false - } -} - -func resolveDynamicToolName(item *StreamItem) string { - if item.Tool != "" { - return item.Tool - } - return streamItemTypeDynamicToolCall -} - -func formatDynamicToolInput(item *StreamItem) string { - payload := map[string]any{} - if item.Namespace != "" { - payload["namespace"] = item.Namespace - } - if item.Tool != "" { - payload["tool"] = item.Tool - } - if hasJSONContent(item.Arguments) { - var args map[string]any - if err := json.Unmarshal(item.Arguments, &args); err == nil { - for k, v := range args { - payload[k] = v - } - } - } - if len(payload) == 0 { - return formatToolArguments(item.Arguments) - } - encoded, err := json.Marshal(payload) - if err != nil { - return formatToolArguments(item.Arguments) - } - return string(encoded) -} - -func formatDynamicToolOutput(item *StreamItem) string { - if item.Error != nil && item.Error.Message != "" { - return item.Error.Message - } - var parts []string - for _, block := range item.ContentItems { - switch block.Type { - case "input_text", "text": - if block.Text != "" { - parts = append(parts, block.Text) - } - default: - if encoded, err := json.Marshal(block); err == nil { - parts = append(parts, string(encoded)) - } - } - } - if len(parts) > 0 { - return strings.Join(parts, "\n") - } - return formatMCPOutput(item) -} - -func formatCommandInput(command string) string { - if command == "" { - return "" - } - input, err := json.Marshal(map[string]string{"command": command}) - if err != nil { - return command - } - return string(input) -} - -func isJSONNull(raw json.RawMessage) bool { - return len(raw) == 0 || string(raw) == jsonNullLiteral -} - -func hasJSONContent(raw json.RawMessage) bool { - return len(raw) > 0 && string(raw) != jsonNullLiteral -} - -func formatToolArguments(arguments json.RawMessage) string { - if isJSONNull(arguments) { - return "" - } - if !json.Valid(arguments) { - return string(arguments) - } - return string(arguments) -} - -func formatMCPOutput(item *StreamItem) string { - if item.Error != nil && item.Error.Message != "" { - return item.Error.Message - } - if item.Result == nil { - return "" - } - if hasJSONContent(item.Result.StructuredContent) { - return formatToolArguments(item.Result.StructuredContent) - } - var parts []string - for _, block := range item.Result.Content { - switch block.Type { - case "text": - if block.Text != "" { - parts = append(parts, block.Text) - } - default: - if encoded, err := json.Marshal(block); err == nil { - parts = append(parts, string(encoded)) - } - } - } - if len(parts) > 0 { - return strings.Join(parts, "\n") - } - if encoded, err := json.Marshal(item.Result); err == nil { - return string(encoded) - } - return "" -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_stream_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_stream_test.go deleted file mode 100644 index fdbcacc892..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_stream_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package codex - -import ( - "encoding/json" - "testing" - - console "github.com/pluralsh/console/go/client" - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - harnessusage "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" - "github.com/stretchr/testify/require" -) - -func TestMapDynamicToolCallReadFile(t *testing.T) { - line := `{"type":"item.completed","item":{"id":"item_8","type":"dynamic_tool_call","tool":"read_file","arguments":{"path":"README.md"},"content_items":[{"type":"input_text","text":"# Hello"}],"success":true,"status":"completed"}}` - - c := &Codex{toolItems: make(map[string]*StreamItem)} - event := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(line), event)) - - msg, callID := c.mapStreamEvent(event) - require.NotNil(t, msg) - require.Equal(t, "item_8", callID) - require.Equal(t, "read_file", *msg.Metadata.Tool.Name) - require.JSONEq(t, `{"tool":"read_file","path":"README.md"}`, *msg.Metadata.Tool.Input) - require.Equal(t, "# Hello", *msg.Metadata.Tool.Output) -} - -func TestMapDynamicToolCallMergesArgumentsFromStarted(t *testing.T) { - c := &Codex{toolItems: make(map[string]*StreamItem)} - - started := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(`{"type":"item.started","item":{"id":"item_8","type":"dynamic_tool_call","tool":"read_file","arguments":{"path":"README.md"},"status":"in_progress"}}`), started)) - msg, callID := c.mapStreamEvent(started) - require.NotNil(t, msg) - require.Equal(t, "item_8", callID) - require.Equal(t, console.AgentMessageToolStateRunning, *msg.Metadata.Tool.State) - require.Equal(t, v1.RunningToolOutput, *msg.Metadata.Tool.Output) - require.JSONEq(t, `{"tool":"read_file","path":"README.md"}`, *msg.Metadata.Tool.Input) - - completed := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(`{"type":"item.completed","item":{"id":"item_8","type":"dynamic_tool_call","tool":"read_file","content_items":[{"type":"input_text","text":"ok"}],"success":true,"status":"completed"}}`), completed)) - - msg, callID = c.mapStreamEvent(completed) - require.NotNil(t, msg) - require.Equal(t, "item_8", callID) - require.Equal(t, "read_file", *msg.Metadata.Tool.Name) - require.JSONEq(t, `{"tool":"read_file","path":"README.md"}`, *msg.Metadata.Tool.Input) -} - -func TestMapMCPToolCallIncludesArgumentsAndResult(t *testing.T) { - line := `{"type":"item.completed","item":{"id":"item_5","type":"mcp_tool_call","server":"docs","tool":"search","arguments":{"q":"exec --json"},"result":{"content":[{"type":"text","text":"Found 3 matches."}],"structured_content":{"matches":3}},"error":null,"status":"completed"}}` - - c := &Codex{toolItems: make(map[string]*StreamItem)} - event := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(line), event)) - - msg, callID := c.mapStreamEvent(event) - require.NotNil(t, msg) - require.Equal(t, "item_5", callID) - require.NotNil(t, msg.Metadata) - require.NotNil(t, msg.Metadata.Tool) - - require.Equal(t, "mcp_tool_call", *msg.Metadata.Tool.Name) - require.Equal(t, console.AgentMessageToolStateCompleted, *msg.Metadata.Tool.State) - require.JSONEq(t, `{"server":"docs","tool":"search","q":"exec --json"}`, *msg.Metadata.Tool.Input) - require.Equal(t, `{"matches":3}`, *msg.Metadata.Tool.Output) -} - -func TestMapMCPToolCallMergesArgumentsFromStarted(t *testing.T) { - c := &Codex{toolItems: make(map[string]*StreamItem)} - - started := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(`{"type":"item.started","item":{"id":"item_5","type":"mcp_tool_call","server":"docs","tool":"search","arguments":{"q":"exec --json"},"status":"in_progress"}}`), started)) - msg, callID := c.mapStreamEvent(started) - require.NotNil(t, msg) - require.Equal(t, "item_5", callID) - require.Equal(t, console.AgentMessageToolStateRunning, *msg.Metadata.Tool.State) - - completed := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(`{"type":"item.completed","item":{"id":"item_5","type":"mcp_tool_call","server":"docs","tool":"search","result":{"content":[{"type":"text","text":"ok"}]},"status":"completed"}}`), completed)) - - msg, callID = c.mapStreamEvent(completed) - require.NotNil(t, msg) - require.Equal(t, "item_5", callID) - require.JSONEq(t, `{"server":"docs","tool":"search","q":"exec --json"}`, *msg.Metadata.Tool.Input) - require.Equal(t, "ok", *msg.Metadata.Tool.Output) -} - -func TestMapMCPToolCallFailureUsesErrorMessage(t *testing.T) { - line := `{"type":"item.completed","item":{"id":"item_6","type":"mcp_tool_call","server":"docs","tool":"search","arguments":{"q":"exec --json"},"result":null,"error":{"message":"tool timeout"},"status":"failed"}}` - - c := &Codex{toolItems: make(map[string]*StreamItem)} - event := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(line), event)) - - msg, callID := c.mapStreamEvent(event) - require.NotNil(t, msg) - require.Equal(t, "item_6", callID) - require.Equal(t, console.AgentMessageToolStateError, *msg.Metadata.Tool.State) - require.Equal(t, "tool timeout", *msg.Metadata.Tool.Output) - require.JSONEq(t, `{"server":"docs","tool":"search","q":"exec --json"}`, *msg.Metadata.Tool.Input) -} - -func TestMapCommandExecutionTwoTurn(t *testing.T) { - c := &Codex{toolItems: make(map[string]*StreamItem)} - - started := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(`{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"in_progress"}}`), started)) - msg, callID := c.mapStreamEvent(started) - require.NotNil(t, msg) - require.Equal(t, "item_1", callID) - require.Equal(t, "command_execution", *msg.Metadata.Tool.Name) - require.Equal(t, console.AgentMessageToolStateRunning, *msg.Metadata.Tool.State) - require.Equal(t, v1.RunningToolOutput, *msg.Metadata.Tool.Output) - require.JSONEq(t, `{"command":"bash -lc ls"}`, *msg.Metadata.Tool.Input) - - completed := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(`{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"docs\n","exit_code":0,"status":"completed"}}`), completed)) - msg, callID = c.mapStreamEvent(completed) - require.NotNil(t, msg) - require.Equal(t, "item_1", callID) - require.Equal(t, console.AgentMessageToolStateCompleted, *msg.Metadata.Tool.State) - require.Equal(t, "docs\n", *msg.Metadata.Tool.Output) -} - -func TestMapCommandExecutionEmptyOutputClearsRunningPlaceholder(t *testing.T) { - c := &Codex{toolItems: make(map[string]*StreamItem)} - - started := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(`{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc 'sleep 100'","status":"in_progress"}}`), started)) - msg, callID := c.mapStreamEvent(started) - require.NotNil(t, msg) - require.Equal(t, "item_1", callID) - require.Equal(t, v1.RunningToolOutput, *msg.Metadata.Tool.Output) - - completed := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(`{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc 'sleep 100'","aggregated_output":"","exit_code":0,"status":"completed"}}`), completed)) - msg, callID = c.mapStreamEvent(completed) - require.NotNil(t, msg) - require.Equal(t, "item_1", callID) - require.Equal(t, console.AgentMessageToolStateCompleted, *msg.Metadata.Tool.State) - require.NotNil(t, msg.Metadata.Tool.Output) - require.Equal(t, "", *msg.Metadata.Tool.Output) -} - -func TestMapTurnCompletedPersistsCostWithoutChatContent(t *testing.T) { - line := `{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":20,"output_tokens":50,"reasoning_output_tokens":12}}` - - c := &Codex{toolItems: make(map[string]*StreamItem)} - c.Config.Usage = harnessusage.New(nil) - event := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(line), event)) - - msg, callID := c.mapStreamEvent(event) - require.NotNil(t, msg) - require.Empty(t, callID) - require.Equal(t, ignoredAgentMessage, msg.Message) - require.NotNil(t, msg.Cost) - require.Equal(t, float64(150), msg.Cost.Total) - require.Equal(t, float64(100), *msg.Cost.Tokens.Input) - require.Equal(t, float64(50), *msg.Cost.Tokens.Output) - - attrs := c.Config.Usage.Attributes() - require.NotNil(t, attrs) - require.Equal(t, int64(100), *attrs.InputTokens) - require.Equal(t, int64(50), *attrs.OutputTokens) - require.Equal(t, int64(150), *attrs.TotalTokens) - require.Equal(t, int64(20), *attrs.CachedTokens) - require.Equal(t, int64(12), *attrs.ReasoningTokens) -} - -func TestMapWebSearchIncludesQueryAsInput(t *testing.T) { - line := `{"type":"item.completed","item":{"id":"item_7","type":"web_search","query":"codex exec --json schema"}}` - - c := &Codex{toolItems: make(map[string]*StreamItem)} - event := &StreamEvent{} - require.NoError(t, json.Unmarshal([]byte(line), event)) - - msg, callID := c.mapStreamEvent(event) - require.NotNil(t, msg) - require.Equal(t, "item_7", callID) - require.Equal(t, "web_search", *msg.Metadata.Tool.Name) - require.JSONEq(t, `{"query":"codex exec --json schema"}`, *msg.Metadata.Tool.Input) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates.go deleted file mode 100644 index 3dfc7f70b2..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates.go +++ /dev/null @@ -1,110 +0,0 @@ -package codex - -import ( - "os" - "path/filepath" - - "github.com/pelletier/go-toml/v2" - - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/dind" -) - -func BuildCodexConfig(repositoryDir string, agents []AgentInput, mcps []MCPInput, providers []ModelProviderInput) (*CodexConfig, error) { - cfg := &CodexConfig{ - Features: &Features{ - Skills: true, - }, - Profiles: make(map[string]*Profile), - MCPServers: make(map[string]*MCPServer), - } - cfg.Projects = map[string]*Project{ - repositoryDir: { - TrustLevel: "trusted", - }, - } - - // Add custom model providers - if len(providers) > 0 { - cfg.ModelProviders = make(map[string]*ModelProviderConfig, len(providers)) - for _, p := range providers { - cfg.ModelProviders[p.Name] = &ModelProviderConfig{ - Name: p.Name, - BaseURL: p.BaseURL, - EnvKey: p.EnvKey, - WireAPI: p.WireAPI, - } - } - } - - // Add profiles - for _, a := range agents { - profile := &Profile{ - Model: a.Model, - ModelProvider: a.ModelProvider, - SandboxMode: a.SandboxMode, - ApprovalPolicy: a.ApprovalPolicy, - ModelReasoningEffort: a.ModelReasoningEffort, - ShellEnvironmentPolicy: shellEnvPolicy(a.DindEnabled), - Features: &Features{ - WebSearchRequest: a.EnableWebSearch, - ShellSnapshot: a.EnableShellCache, - }, - ModelInstructionsFile: a.ModelInstructionsFile, - } - cfg.Profiles[a.Name] = profile - } - - // Add MCP servers - for _, m := range mcps { - cfg.MCPServers[m.Name] = &MCPServer{ - Type: m.Type, - URL: m.URL, - Command: m.Command, - Args: m.Args, - Env: m.Env, - Headers: m.Headers, - HTTPHeaders: m.HTTPHeaders, - EnvHTTPHeaders: m.EnvHTTPHeaders, - EnabledTools: m.EnabledTools, - DisabledTools: m.DisabledTools, - TrustPolicy: m.TrustPolicy, - } - } - - return cfg, nil -} - -func WriteCodexConfig(basePath string, cfg *CodexConfig) (string, error) { - if err := os.MkdirAll(basePath, 0755); err != nil { - return "", err - } - - filePath := filepath.Join(basePath, "config.toml") - data, err := toml.Marshal(cfg) - if err != nil { - return "", err - } - - if err := os.WriteFile(filePath, data, 0644); err != nil { - return "", err - } - - return filePath, nil -} - -func shellEnvPolicy(dindEnabled bool) *ShellEnvPolicy { - policy := &ShellEnvPolicy{ - IncludeOnly: codexAllowedEnvVars(dindEnabled), - } - if !dindEnabled { - return policy - } - - policy.Set = map[string]string{} - for _, key := range []string{dind.DockerHostEnv} { - if val := os.Getenv(key); val != "" { - policy.Set[key] = val - } - } - return policy -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates_test.go deleted file mode 100644 index 48ee1d5382..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package codex - -import ( - "fmt" - "testing" - - console "github.com/pluralsh/console/go/client" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/dind" - proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" - "github.com/pluralsh/console/go/deployment-operator/pkg/common" -) - -func TestBuildCodexConfig_ProxyProvider(t *testing.T) { - consoleURL := "https://console.plural.sh" - model := proxymodel.ProxyModel(console.AgentRuntimeTypeCodex, "gpt-5.4") - - cfg, err := BuildCodexConfig("/repo", []AgentInput{{ - Name: autonomousProfile, - SandboxMode: sandboxModeHarness, - Model: model, - ModelProvider: "plural", - }}, nil, []ModelProviderInput{{ - Name: "plural", - BaseURL: fmt.Sprintf("%s/ext/ai/v1", consoleURL), - EnvKey: consoleTokenEnv, - WireAPI: "chat", - }}) - if err != nil { - t.Fatalf("BuildCodexConfig() failed: %v", err) - } - if cfg.Features == nil || !cfg.Features.Skills { - t.Fatal("expected Codex skills feature to be enabled") - } - - provider := cfg.ModelProviders["plural"] - if provider == nil { - t.Fatal("expected plural model provider") - } - if provider.BaseURL != "https://console.plural.sh/ext/ai/v1" { - t.Fatalf("base_url = %q, want https://console.plural.sh/ext/ai/v1", provider.BaseURL) - } - if provider.EnvKey != consoleTokenEnv { - t.Fatalf("env_key = %q, want %q", provider.EnvKey, consoleTokenEnv) - } - if provider.WireAPI != "chat" { - t.Fatalf("wire_api = %q, want chat", provider.WireAPI) - } -} - -func TestProfileForMode(t *testing.T) { - for _, tc := range []struct { - mode console.AgentRunMode - profile string - }{ - {console.AgentRunModeAnalyze, "analysis"}, - {console.AgentRunModeWrite, autonomousProfile}, - {console.AgentRunModeReview, reviewProfile}, - } { - profile, ok := profileForMode(tc.mode) - if !ok || profile != tc.profile { - t.Fatalf("profileForMode(%s) = %q, %v; want %q, true", tc.mode, profile, ok, tc.profile) - } - } -} - -func TestBuildCodexConfig_CodebaseMemoryMCPServer(t *testing.T) { - cfg, err := BuildCodexConfig("/repo", []AgentInput{{ - Name: autonomousProfile, - SandboxMode: sandboxModeHarness, - Model: string(ModelGPT54), - }}, []MCPInput{{ - Name: common.CodebaseMemoryMCPServerName, - Type: "stdio", - Command: common.CodebaseMemoryMCPCommand, - Env: map[string]string{common.CodebaseMemoryCacheEnv: common.CodebaseMemoryCacheDir}, - }}, nil) - if err != nil { - t.Fatalf("BuildCodexConfig() failed: %v", err) - } - - server := cfg.MCPServers[common.CodebaseMemoryMCPServerName] - if server == nil { - t.Fatal("expected codebase memory MCP server") - } - if server.Type != "stdio" { - t.Fatalf("type = %q, want stdio", server.Type) - } - if server.Command != common.CodebaseMemoryMCPCommand { - t.Fatalf("command = %q, want %q", server.Command, common.CodebaseMemoryMCPCommand) - } - if server.Env[common.CodebaseMemoryCacheEnv] != common.CodebaseMemoryCacheDir { - t.Fatalf("env[%s] = %q, want %q", common.CodebaseMemoryCacheEnv, server.Env[common.CodebaseMemoryCacheEnv], common.CodebaseMemoryCacheDir) - } -} - -func TestBuildCodexConfig_ExternalHTTPServer(t *testing.T) { - cfg, err := BuildCodexConfig("/repo", []AgentInput{{ - Name: autonomousProfile, - SandboxMode: sandboxModeHarness, - Model: string(ModelGPT54), - }}, []MCPInput{{ - Name: "linear", - URL: "https://mcp.linear.app/mcp", - HTTPHeaders: map[string]string{ - "Authorization": "Bearer token", - }, - EnabledTools: []string{"list_issues"}, - TrustPolicy: "always", - }}, nil) - if err != nil { - t.Fatalf("BuildCodexConfig() failed: %v", err) - } - - server := cfg.MCPServers["linear"] - if server == nil { - t.Fatal("expected linear MCP server") - } - if server.URL != "https://mcp.linear.app/mcp" { - t.Fatalf("url = %q", server.URL) - } - if server.HTTPHeaders["Authorization"] != "Bearer token" { - t.Fatalf("http_headers = %#v", server.HTTPHeaders) - } - if len(server.EnabledTools) != 1 || server.EnabledTools[0] != "list_issues" { - t.Fatalf("enabled_tools = %#v", server.EnabledTools) - } - if server.TrustPolicy != "always" { - t.Fatalf("trust_policy = %q", server.TrustPolicy) - } -} - -func TestCodexExecArgs(t *testing.T) { - repositoryDir := dind.RepositoryDir() - args := codexExecArgs(repositoryDir, autonomousProfile, "run tests", "") - want := []string{ - "exec", - "--sandbox", sandboxModeHarness, - "--cd", repositoryDir, - "--profile", autonomousProfile, - "--json", "run tests", - } - if len(args) != len(want) { - t.Fatalf("expected %d args, got %d: %v", len(want), len(args), args) - } - for i := range want { - if args[i] != want[i] { - t.Fatalf("arg[%d]: expected %q, got %q (full: %v)", i, want[i], args[i], args) - } - } -} - -func TestCodexExecArgsResume(t *testing.T) { - repositoryDir := dind.RepositoryDir() - sessionID := "thr_abc123" - args := codexExecArgs(repositoryDir, autonomousProfile, "run tests", sessionID) - want := []string{ - "exec", - "--sandbox", sandboxModeHarness, - "--cd", repositoryDir, - "--profile", autonomousProfile, - "--json", - "resume", sessionID, "run tests", - } - if len(args) != len(want) { - t.Fatalf("expected %d args, got %d: %v", len(want), len(args), args) - } - for i := range want { - if args[i] != want[i] { - t.Fatalf("arg[%d]: expected %q, got %q (full: %v)", i, want[i], args[i], args) - } - } -} - -func TestCodexWireAPI(t *testing.T) { - tests := []struct { - name string - method string - want string - }{ - {name: "chat", method: string(console.OpenAiMethodChat), want: "chat"}, - {name: "responses", method: string(console.OpenAiMethodResponses), want: "responses"}, - {name: "auto", method: string(console.OpenAiMethodAuto), want: ""}, - {name: "empty", method: "", want: ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := codexWireAPI(tt.method); got != tt.want { - t.Fatalf("codexWireAPI(%q) = %q, want %q", tt.method, got, tt.want) - } - }) - } -} - -func containsString(values []string, target string) bool { - for _, value := range values { - if value == target { - return true - } - } - return false -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_types.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_types.go deleted file mode 100644 index 78074172d9..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_types.go +++ /dev/null @@ -1,278 +0,0 @@ -package codex - -import ( - "encoding/json" - - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" -) - -const ( - statusCompleted = "completed" - statusFailed = "failed" - jsonNullLiteral = "null" - - // streamEventTypeThreadStarted is the Codex JSON stream event type that carries thread_id. - streamEventTypeThreadStarted = "thread.started" - - streamEventTypeItemStarted = "item.started" - streamEventTypeItemCompleted = "item.completed" - streamEventTypeTurnCompleted = "turn.completed" - streamEventTypeTurnFailed = "turn.failed" - - streamItemTypeCommandExecution = "command_execution" - streamItemTypeDynamicToolCall = "dynamic_tool_call" - streamItemTypeMCPToolCall = "mcp_tool_call" - streamItemTypeFileChange = "file_change" - streamItemTypeWebSearch = "web_search" - - // ignoredAgentMessage marks messages persisted for metadata (e.g. cost) but hidden in chat. - ignoredAgentMessage = "__plrl_ignore__" -) - -type Codex struct { - v1.DefaultTool - - // onMessage is a callback called when a new message is received. - onMessage v1.MessageCallback - - // executable is the Codex executable used to call CLI. - executable exec.Executable - - // apiKey used to authenticate with the API. - apiKey string - - // model used to generate code. - model Model - - // threadID is captured from the "thread.started" event and forwarded to the API - // as the session identifier (analogous to session_id in Claude). - threadID string - - // toolItems caches in-progress stream items (keyed by item.id) so tool arguments - // from item.started are available when item.completed is emitted. - toolItems map[string]*StreamItem - - proxy bool - - consoleURL string - consoleToken string -} - -// StreamEvent is the top-level envelope for every JSON line emitted by `codex exec --json`. -type StreamEvent struct { - // Type identifies the event kind, e.g. "thread.started", "turn.started", - // "item.started", "item.completed", "turn.completed". - Type string `json:"type"` - - // Message is populated with "error" events. - Message string `json:"message"` - - // ThreadID is set on "thread.started" events and carries the session - // identifier that must be forwarded to the API (analogous to session_id in Claude). - ThreadID string `json:"thread_id,omitempty"` - - // Item is populated on "item.started" and "item.completed" events. - Item *StreamItem `json:"item,omitempty"` - - // Usage is populated on "turn.completed" events and contains token usage statistics. - Usage *TurnUsage `json:"usage,omitempty"` - - // Error is populated on "turn.failed" events. - Error *TurnError `json:"error,omitempty"` -} - -// TurnError holds the error payload for a failed turn. -type TurnError struct { - Message string `json:"message,omitempty"` -} - -// TurnUsage holds token usage statistics emitted in the "turn.completed" event. -type TurnUsage struct { - InputTokens int64 `json:"input_tokens"` - CachedInputTokens int64 `json:"cached_input_tokens"` - OutputTokens int64 `json:"output_tokens"` - ReasoningOutputTokens int64 `json:"reasoning_output_tokens"` -} - -// StreamItem is the payload carried inside "item.started" / "item.completed" events. -type StreamItem struct { - // ID is the stable identifier for this item across started/completed pairs. - ID string `json:"id"` - - // Type describes what kind of item this is: "reasoning", "agent_message", "todo_list", - // "command_execution", "dynamic_tool_call", "mcp_tool_call", "file_change", etc. - Type string `json:"type"` - - // Text is populated for "reasoning" and "agent_message" items. - Text string `json:"text,omitempty"` - - // Message is populated for Type "error" items. - Message string `json:"message,omitempty"` - - // Command and output fields are populated for "command_execution" items. - Command string `json:"command,omitempty"` - AggregatedOutput string `json:"aggregated_output,omitempty"` - ExitCode *int `json:"exit_code,omitempty"` - Status string `json:"status,omitempty"` - - // Items is populated for "todo_list" items. - Items []TodoItem `json:"items,omitempty"` - - // Tool fields are populated for "mcp_tool_call" and "dynamic_tool_call" items. - Server string `json:"server,omitempty"` - Namespace string `json:"namespace,omitempty"` - Tool string `json:"tool,omitempty"` - Arguments json.RawMessage `json:"arguments,omitempty"` - Result *MCPToolResult `json:"result,omitempty"` - Error *MCPToolError `json:"error,omitempty"` - - // ContentItems is populated for "dynamic_tool_call" items. - ContentItems []DynamicToolContentItem `json:"content_items,omitempty"` - Success *bool `json:"success,omitempty"` - - // Query is populated for "web_search" items. - Query string `json:"query,omitempty"` - - // Changes is populated for "file_change" items. - Changes []FileChange `json:"changes,omitempty"` -} - -// DynamicToolContentItem is a single output block for a "dynamic_tool_call" item. -type DynamicToolContentItem struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` -} - -// MCPToolResult is the result payload for a completed "mcp_tool_call" item. -// See https://takopi.dev/reference/runners/codex/exec-json-cheatsheet/ -type MCPToolResult struct { - Content []MCPContentBlock `json:"content,omitempty"` - StructuredContent json.RawMessage `json:"structured_content,omitempty"` -} - -// MCPContentBlock is a single MCP content block inside MCPToolResult.Content. -type MCPContentBlock struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` -} - -// MCPToolError holds the error payload for a failed "mcp_tool_call" item. -type MCPToolError struct { - Message string `json:"message,omitempty"` -} - -// FileChange describes a single file modification inside a "file_change" item. -type FileChange struct { - Path string `json:"path,omitempty"` - Kind string `json:"kind,omitempty"` // e.g. "add", "modify", "delete" -} - -// TodoItem is a single entry inside a "todo_list" StreamItem. -type TodoItem struct { - Text string `json:"text"` - Completed bool `json:"completed"` -} - -type AgentInput struct { - Name string - Model string - ModelProvider string - SandboxMode string - ApprovalPolicy string - ModelReasoningEffort string - AllowedEnvVars []string - EnableWebSearch bool - EnableShellCache bool - ModelInstructionsFile string - DindEnabled bool -} - -type Project struct { - TrustLevel string `toml:"trust_level,omitempty"` -} - -type MCPInput struct { - Name string - Type string // Transport type: "stdio", "sse" or "http" - URL string - Command string - Args []string - Env map[string]string - Headers map[string]string // HTTP request headers, used for "http" transport - HTTPHeaders map[string]string // Codex streamable HTTP headers (`http_headers`) - EnvHTTPHeaders map[string]string // Header name -> env var name (`env_http_headers`) - EnabledTools []string - DisabledTools []string - TrustPolicy string // e.g. "always" to auto-approve tool calls in exec mode -} - -// ModelProviderInput is the user-facing input for registering a custom model provider. -type ModelProviderInput struct { - // Name is the key used to reference this provider from a Profile's ModelProvider field. - Name string - // BaseURL is the OpenAI-compatible API endpoint, e.g. "https://api.example.com/v1". - BaseURL string - // EnvKey is the name of the environment variable that holds the API key. - EnvKey string - // WireAPI chooses the OpenAI API shape Codex uses for this provider: "chat" or "responses". - WireAPI string -} - -// ModelProviderConfig is serialized into [model_providers.] in config.toml. -type ModelProviderConfig struct { - Name string `toml:"name,omitempty"` - BaseURL string `toml:"base_url,omitempty"` - EnvKey string `toml:"env_key,omitempty"` - WireAPI string `toml:"wire_api,omitempty"` -} - -type ShellEnvPolicy struct { - IncludeOnly []string `toml:"include_only,omitempty"` - Set map[string]string `toml:"set,omitempty"` -} - -type SandboxWorkspaceWrite struct { - NetworkAccess bool `toml:"network_access,omitempty"` - WritableRoots []string `toml:"writable_roots,omitempty"` -} - -type Features struct { - WebSearchRequest bool `toml:"web_search_request,omitempty"` - ShellSnapshot bool `toml:"shell_snapshot,omitempty"` - Skills bool `toml:"skills,omitempty"` -} - -type Profile struct { - Model string `toml:"model"` - ModelProvider string `toml:"model_provider,omitempty"` - SandboxMode string `toml:"sandbox_mode"` - ApprovalPolicy string `toml:"approval_policy"` - ModelReasoningEffort string `toml:"model_reasoning_effort"` - ShellEnvironmentPolicy *ShellEnvPolicy `toml:"shell_environment_policy,omitempty"` - Features *Features `toml:"features,omitempty"` - ModelInstructionsFile string `toml:"model_instructions_file,omitempty"` - SandboxWorkspaceWrite *SandboxWorkspaceWrite `toml:"sandbox_workspace_write,omitempty"` -} - -type MCPServer struct { - Type string `toml:"type,omitempty"` // Transport type: "stdio", "sse" or "http" - URL string `toml:"url,omitempty"` // For remote MCP (sse/http) - Command string `toml:"command,omitempty"` // For local MCP (stdio) - Args []string `toml:"args,omitempty"` - Env map[string]string `toml:"env,omitempty"` - Headers map[string]string `toml:"headers,omitempty"` // HTTP request headers for "http" transport - HTTPHeaders map[string]string `toml:"http_headers,omitempty"` // Codex streamable HTTP headers - EnvHTTPHeaders map[string]string `toml:"env_http_headers,omitempty"` // Header values read from the process environment - EnabledTools []string `toml:"enabled_tools,omitempty"` - DisabledTools []string `toml:"disabled_tools,omitempty"` - TrustPolicy string `toml:"trust_policy,omitempty"` // e.g. "always" to auto-approve tool calls in exec mode -} - -type CodexConfig struct { - Features *Features `toml:"features,omitempty"` - Projects map[string]*Project `toml:"projects,omitempty"` - ModelProviders map[string]*ModelProviderConfig `toml:"model_providers,omitempty"` - Profiles map[string]*Profile `toml:"profiles"` - MCPServers map[string]*MCPServer `toml:"mcp_servers"` -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/model.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/model.go deleted file mode 100644 index 482385ee16..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/model.go +++ /dev/null @@ -1,27 +0,0 @@ -package codex - -type Model string - -const ( - ModelGPT5 Model = "gpt-5" - - // Primary Codex models - ModelGPT51Codex Model = "gpt-5.1-codex" - ModelGPT51CodexMini Model = "gpt-5.1-codex-mini" - ModelGPT54 Model = "gpt-5.4" - ModelCodexMini Model = "codex-mini-latest" - - // Optional powerful Codex options - ModelGPT52Codex Model = "gpt-5.2-codex" - - defaultModel = ModelGPT54 -) - -// EnsureModel returns a sensible default -func EnsureModel(model string) Model { - if len(model) == 0 { - return defaultModel - } - - return Model(model) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config.go new file mode 100644 index 0000000000..1820c39924 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config.go @@ -0,0 +1,170 @@ +package codex + +import ( + "fmt" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/common" +) + +// These fallbacks keep Codex runs deterministic when Console omits model or +// ACP reasoning settings. +const ( + defaultModel = "gpt-5.6-luna" + defaultReasoning = "medium" +) + +// Console run modes map to these profile names in Codex's native config. +const ( + analysisProfile = "analysis" + autonomousProfile = "autonomous" + reviewProfile = "review" +) + +// These provider keys, endpoint, and wire labels are serialized into Codex's +// model provider configuration for direct, custom, and proxied requests. +const ( + pluralProvider = "plural" + customProvider = "custom" + openAIProvider = "openai-api" + openAIBaseURL = "https://api.openai.com/v1" + chatWireAPI = "chat" + responsesWireAPI = "responses" +) + +// All supported Console run modes use Codex's full-access ACP mode; the +// agent-run pod remains the isolation boundary. +const acpModeID = "agent-full-access" + +func (*Agent) resolveModel(model string) string { + if model == "" { + return defaultModel + } + return model +} + +// ResolveSettings resolves Codex's model and timeout without exposing +// credentials to the provider-neutral runtime settings. +func (agent *Agent) ResolveSettings(run *agentrunv1.AgentRun) (toolv1.Settings, error) { + codex, err := agent.runConfig(run) + if err != nil { + return toolv1.Settings{}, err + } + + config := toolv1.Config{Run: run} + model := agent.resolveModelForSettings(config, toolv1.Settings{}) + provider := console.AiProviderOpenai + + return toolv1.Settings{ + Mode: run.Mode, + Model: toolv1.ModelSelection{ + Provider: &provider, + Name: model, + Reasoning: agent.resolveReasoning(toolv1.Settings{}), + }, + Timeout: codex.Timeout, + Proxy: run.IsProxyEnabled(), + }, nil +} + +func (agent *Agent) resolveModelForSettings(config toolv1.Config, settings toolv1.Settings) string { + codex := config.Run.Runtime.Config.Codex + + model := settings.Model.Name + if model == "" { + model = codex.Model + } + model = agent.resolveModel(model) + + if config.Run.IsProxyEnabled() { + model = proxymodel.ProxyModel(console.AgentRuntimeTypeCodex, model) + } + return model +} + +func (*Agent) resolveReasoning(settings toolv1.Settings) string { + reasoning := settings.Model.Reasoning + if reasoning == "" { + reasoning = defaultReasoning + } + return reasoning +} + +func (agent *Agent) resolveACPSettings(settings toolv1.Settings) (string, string, string, error) { + modeID, err := agent.resolveACPMode(settings.Mode, "") + if err != nil { + return "", "", "", err + } + + return settings.Model.Name, agent.resolveReasoning(settings), modeID, nil +} + +func (agent *Agent) resolveACPProvider(config toolv1.Config) string { + if config.Run.IsProxyEnabled() { + return pluralProvider + } + if config.Run.Runtime.Config.Codex.Endpoint != nil { + return customProvider + } + if agent.wireAPI(config.Run.Runtime.Config.Codex.Method) == "" { + return "" + } + return openAIProvider +} + +func (agent *Agent) resolveProviderSettings(config toolv1.Config) (string, string, string, string) { + wireAPI := agent.wireAPI(config.Run.Runtime.Config.Codex.Method) + if config.Run.IsProxyEnabled() { + baseURL := fmt.Sprintf("%s/ext/ai/v1", agent.consoleURL) + if config.Run.IsStreamingProxyEnabled() { + baseURL = common.AgentOpenAIBaseURL + } + return pluralProvider, baseURL, consoleTokenEnv, wireAPI + } + if endpoint := config.Run.Runtime.Config.Codex.Endpoint; endpoint != nil { + return customProvider, *endpoint, openAIAPIKeyEnv, wireAPI + } + if wireAPI == "" { + return "", "", "", "" + } + return openAIProvider, openAIBaseURL, openAIAPIKeyEnv, wireAPI +} + +func (*Agent) profileForMode(mode console.AgentRunMode) (string, bool) { + switch mode { + case console.AgentRunModeAnalyze: + return analysisProfile, true + case console.AgentRunModeWrite: + return autonomousProfile, true + case console.AgentRunModeReview: + return reviewProfile, true + default: + return "", false + } +} + +func (*Agent) wireAPI(method string) string { + switch console.OpenAiMethod(method) { + case console.OpenAiMethodChat: + return chatWireAPI + case console.OpenAiMethodResponses: + return responsesWireAPI + default: + return "" + } +} + +func (*Agent) resolveACPMode(mode, fallback console.AgentRunMode) (string, error) { + if mode == "" { + mode = fallback + } + switch mode { + case console.AgentRunModeAnalyze, console.AgentRunModeWrite, console.AgentRunModeReview: + return acpModeID, nil + default: + return "", fmt.Errorf("unsupported codex ACP mode %q", mode) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config_test.go new file mode 100644 index 0000000000..36f52ca65c --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config_test.go @@ -0,0 +1,105 @@ +package codex + +import ( + "testing" + "time" + + console "github.com/pluralsh/console/go/client" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestResolveSettingsUsesDefaultAndProxyModel(t *testing.T) { + run := codexTestRun(console.AgentRunModeReview, "", false) + run.Runtime.Config.Codex.Timeout = 7 * time.Minute + settings, err := NewAgent(toolv1.Config{Run: run}).ResolveSettings(run) + if err != nil { + t.Fatalf("ResolveSettings() error = %v", err) + } + if settings.Model.Provider == nil || *settings.Model.Provider != console.AiProviderOpenai { + t.Fatalf("provider = %v, want openai", settings.Model.Provider) + } + if settings.Model.Name != defaultModel { + t.Fatalf("model = %q, want %q", settings.Model.Name, defaultModel) + } + if settings.Model.Reasoning != defaultReasoning { + t.Fatalf("reasoning = %q, want %q", settings.Model.Reasoning, defaultReasoning) + } + if settings.Timeout != 7*time.Minute || settings.Proxy { + t.Fatalf("settings = %#v", settings) + } + capabilities := NewAgent(toolv1.Config{}).Capabilities() + for _, mode := range []console.AgentRunMode{console.AgentRunModeAnalyze, console.AgentRunModeWrite, console.AgentRunModeReview} { + if !capabilities.Supports(mode) { + t.Fatalf("Codex capabilities do not include %s", mode) + } + } + + proxyRun := codexTestRun(console.AgentRunModeWrite, "gpt-5.4", true) + proxySettings, err := NewAgent(toolv1.Config{Run: proxyRun}).ResolveSettings(proxyRun) + if err != nil { + t.Fatal(err) + } + if proxySettings.Model.Name != "openai/gpt-5.4" || !proxySettings.Proxy { + t.Fatalf("proxy settings = %#v", proxySettings) + } +} + +func TestResolveSettingsPreservesExplicitModel(t *testing.T) { + // This provider-qualified fixture verifies explicit model IDs pass through unchanged. + const explicitModel = "vendor/gpt-5.6-luna-custom" + run := codexTestRun(console.AgentRunModeWrite, explicitModel, false) + settings, err := NewAgent(toolv1.Config{Run: run}).ResolveSettings(run) + if err != nil { + t.Fatalf("ResolveSettings() error = %v", err) + } + if settings.Model.Name != explicitModel { + t.Fatalf("model = %q, want %q", settings.Model.Name, explicitModel) + } +} + +func TestCodexProfilesAndACPSettings(t *testing.T) { + agent := NewAgent(toolv1.Config{Run: codexTestRun(console.AgentRunModeWrite, "gpt-5.4", false)}) + for _, test := range []struct { + mode console.AgentRunMode + profile string + }{ + {console.AgentRunModeAnalyze, analysisProfile}, + {console.AgentRunModeWrite, autonomousProfile}, + {console.AgentRunModeReview, reviewProfile}, + } { + profile, ok := agent.profileForMode(test.mode) + if !ok || profile != test.profile { + t.Fatalf("profileForMode(%s) = %q, %v", test.mode, profile, ok) + } + } + model, reasoning, modeID, err := agent.resolveACPSettings(toolv1.Settings{Mode: console.AgentRunModeReview, Model: toolv1.ModelSelection{Name: "gpt-5.4"}}) + if err != nil { + t.Fatal(err) + } + if want := "gpt-5.4"; model != want { + t.Fatalf("ACP model = %q, want %q", model, want) + } + if want := defaultReasoning; reasoning != want { + t.Fatalf("ACP reasoning = %q, want %q", reasoning, want) + } + if want := acpModeID; modeID != want { + t.Fatalf("ACP mode = %q, want %q", modeID, want) + } +} + +func TestCodexWireAPI(t *testing.T) { + agent := NewAgent(toolv1.Config{}) + for _, test := range []struct { + method string + want string + }{ + {method: string(console.OpenAiMethodChat), want: chatWireAPI}, + {method: string(console.OpenAiMethodResponses), want: responsesWireAPI}, + {method: string(console.OpenAiMethodAuto), want: ""}, + {method: "", want: ""}, + } { + if got := agent.wireAPI(test.method); got != test.want { + t.Fatalf("wireAPI(%q) = %q, want %q", test.method, got, test.want) + } + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/session.go new file mode 100644 index 0000000000..26c43ed135 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/session.go @@ -0,0 +1,92 @@ +package codex + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" +) + +// Export preserves Codex's provider-owned session subtree under this relative +// path so resumed runs retain their native session files. +const codexSessionsDir = "sessions" + +func (agent *Agent) copySessionDirectory(ctx context.Context, source, destination string) error { + info, err := os.Stat(source) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("stat codex sessions: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("codex sessions path %q is not a directory", source) + } + + if err := os.MkdirAll(destination, 0755); err != nil { + return fmt.Errorf("create codex session export: %w", err) + } + + return filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if err := agent.contextError(ctx); err != nil { + return err + } + + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(destination, rel) + + if entry.IsDir() { + return os.MkdirAll(target, 0755) + } + if entry.Type()&os.ModeSymlink != 0 { + link, err := os.Readlink(path) + if err != nil { + return err + } + return os.Symlink(link, target) + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + return agent.copySessionFile(path, target) + }) +} + +func (agent *Agent) copySessionFile(source, destination string) error { + if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + return err + } + + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + + info, err := input.Stat() + if err != nil { + return err + } + + output, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + return err + } + defer output.Close() + if _, err := io.Copy(output, input); err != nil { + return err + } + return nil +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/session_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/session_test.go new file mode 100644 index 0000000000..cc747c5be4 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/session_test.go @@ -0,0 +1,35 @@ +package codex + +import ( + "context" + "os" + stdexec "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestAgentSessionExportSkipsFIFO(t *testing.T) { + source := t.TempDir() + if err := os.WriteFile(filepath.Join(source, "session.jsonl"), []byte("session"), 0644); err != nil { + t.Fatal(err) + } + fifo := filepath.Join(source, "blocked.pipe") + if err := stdexec.Command("mkfifo", fifo).Run(); err != nil { + t.Skipf("mkfifo is unavailable: %v", err) + } + destination := t.TempDir() + done := make(chan error, 1) + go func() { done <- (&Agent{}).copySessionDirectory(context.Background(), source, destination) }() + select { + case err := <-done: + if err != nil { + t.Fatalf("copySessionDirectory() error = %v", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("copySessionDirectory() blocked on FIFO") + } + if _, err := os.Stat(filepath.Join(destination, "blocked.pipe")); !os.IsNotExist(err) { + t.Fatalf("FIFO was copied, stat error = %v", err) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates.go new file mode 100644 index 0000000000..a5131c1914 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates.go @@ -0,0 +1,73 @@ +package codex + +import ( + _ "embed" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "text/template" +) + +//go:embed templates/config.toml.gotmpl +var configTemplateText string + +// These names identify Codex's embedded native configuration template and +// generated file. +const ( + codexConfigTemplateName = "config.toml" + codexConfigFileName = "config.toml" +) + +func configTemplate(input *ConfigTemplateInput) (fileName, content string, err error) { + quote := func(value string) (string, error) { + quoted, err := json.Marshal(value) + return string(quoted), err + } + tmpl, err := template.New(codexConfigTemplateName).Funcs(template.FuncMap{ + "quote": quote, + }).Parse(configTemplateText) + if err != nil { + return "", "", err + } + + output := new(strings.Builder) + if err := tmpl.Execute(output, input); err != nil { + return codexConfigFileName, "", err + } + return codexConfigFileName, output.String(), nil +} + +func (agent *Agent) writeConfig(basePath string, input *ConfigTemplateInput) (string, error) { + if err := os.MkdirAll(basePath, 0755); err != nil { + return "", err + } + + _, content, err := configTemplate(input) + if err != nil { + return "", err + } + filePath := filepath.Join(basePath, codexConfigFileName) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return "", err + } + return filePath, nil +} + +func (agent *Agent) templateKeyValues(values map[string]string) []configTemplateKeyValue { + if len(values) == 0 { + return nil + } + + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]configTemplateKeyValue, 0, len(keys)) + for _, key := range keys { + result = append(result, configTemplateKeyValue{Key: key, Value: values[key]}) + } + return result +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates/config.toml.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates/config.toml.gotmpl new file mode 100644 index 0000000000..4e0082b6b2 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates/config.toml.gotmpl @@ -0,0 +1,108 @@ +[features] +skills = true + +[projects.{{ quote .RepositoryDir }}] +trust_level = "trusted" + +{{ range .Providers }} +[model_providers.{{ quote .Name }}] +{{ if .Name }} +name = {{ quote .Name }} +{{ end }} +{{ if .BaseURL }} +base_url = {{ quote .BaseURL }} +{{ end }} +{{ if .EnvKey }} +env_key = {{ quote .EnvKey }} +{{ end }} +{{ if .WireAPI }} +wire_api = {{ quote .WireAPI }} +{{ end }} +{{ end }} + +[profiles.{{ quote .Profile.Name }}] +model = {{ quote .Profile.Model }} +{{ if .Profile.ModelProvider }} +model_provider = {{ quote .Profile.ModelProvider }} +{{ end }} +sandbox_mode = {{ quote .Profile.SandboxMode }} +approval_policy = {{ quote .Profile.ApprovalPolicy }} +model_reasoning_effort = {{ quote .Profile.ModelReasoningEffort }} +{{ if .Profile.ModelInstructionsFile }} +model_instructions_file = {{ quote .Profile.ModelInstructionsFile }} +{{ end }} + +{{ if .Profile.ShellEnvironmentPolicy }} +[profiles.{{ quote .Profile.Name }}.shell_environment_policy] +{{ if .Profile.ShellEnvironmentPolicy.IncludeOnly }} +include_only = [{{ range $index, $value := .Profile.ShellEnvironmentPolicy.IncludeOnly }}{{ if $index }}, {{ end }}{{ quote $value }}{{ end }}] +{{ end }} + +{{ if .Profile.ShellEnvironmentPolicy.Set }} +[profiles.{{ quote .Profile.Name }}.shell_environment_policy.set] +{{ range .Profile.ShellEnvironmentPolicy.Set }} +{{ quote .Key }} = {{ quote .Value }} +{{ end }} +{{ end }} +{{ end }} + +[profiles.{{ quote .Profile.Name }}.features] +{{ if .Profile.EnableWebSearch }} +web_search_request = true +{{ end }} +{{ if .Profile.EnableShellCache }} +shell_snapshot = true +{{ end }} + +{{ range .MCPServers }} +[mcp_servers.{{ quote .Name }}] +{{ if .Type }} +type = {{ quote .Type }} +{{ end }} +{{ if .URL }} +url = {{ quote .URL }} +{{ end }} +{{ if .Command }} +command = {{ quote .Command }} +{{ end }} +{{ if .Args }} +args = [{{ range $index, $value := .Args }}{{ if $index }}, {{ end }}{{ quote $value }}{{ end }}] +{{ end }} +{{ if .EnabledTools }} +enabled_tools = [{{ range $index, $value := .EnabledTools }}{{ if $index }}, {{ end }}{{ quote $value }}{{ end }}] +{{ end }} +{{ if .DisabledTools }} +disabled_tools = [{{ range $index, $value := .DisabledTools }}{{ if $index }}, {{ end }}{{ quote $value }}{{ end }}] +{{ end }} +{{ if .TrustPolicy }} +trust_policy = {{ quote .TrustPolicy }} +{{ end }} + +{{ if .Env }} +[mcp_servers.{{ quote .Name }}.env] +{{ range .Env }} +{{ quote .Key }} = {{ quote .Value }} +{{ end }} +{{ end }} + +{{ if .Headers }} +[mcp_servers.{{ quote .Name }}.headers] +{{ range .Headers }} +{{ quote .Key }} = {{ quote .Value }} +{{ end }} +{{ end }} + +{{ if .HTTPHeaders }} +[mcp_servers.{{ quote .Name }}.http_headers] +{{ range .HTTPHeaders }} +{{ quote .Key }} = {{ quote .Value }} +{{ end }} +{{ end }} + +{{ if .EnvHTTPHeaders }} +[mcp_servers.{{ quote .Name }}.env_http_headers] +{{ range .EnvHTTPHeaders }} +{{ quote .Key }} = {{ quote .Value }} +{{ end }} +{{ end }} +{{ end }} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates_test.go new file mode 100644 index 0000000000..b5e73187ca --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates_test.go @@ -0,0 +1,252 @@ +package codex + +import ( + "testing" + + "github.com/pelletier/go-toml/v2" + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/dind" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/common" +) + +func TestConfigTemplateProxyChat(t *testing.T) { + doc := renderConfigTemplate(t, ConfigTemplateInput{ + RepositoryDir: "/repo", + Profile: configTemplateProfile{ + Name: autonomousProfile, + Model: "openai/gpt-5.4", + ModelProvider: pluralProvider, + SandboxMode: sandboxModeHarness, + ApprovalPolicy: approvalPolicyNever, + ModelReasoningEffort: defaultReasoning, + EnableWebSearch: true, + EnableShellCache: true, + }, + Providers: []configTemplateProvider{{ + Name: pluralProvider, + BaseURL: "https://console.plural.sh/ext/ai/v1", + EnvKey: consoleTokenEnv, + WireAPI: chatWireAPI, + }}, + }) + + features := tableValue(t, doc, "features") + if features["skills"] != true { + t.Fatalf("features = %#v, expected skills", features) + } + projects := tableValue(t, doc, "projects") + if tableValue(t, projects, "/repo")["trust_level"] != "trusted" { + t.Fatalf("projects = %#v, expected trusted repository", projects) + } + profile := tableValue(t, tableValue(t, doc, "profiles"), autonomousProfile) + if profile["model"] != "openai/gpt-5.4" || profile["model_provider"] != pluralProvider || + profile["sandbox_mode"] != sandboxModeHarness || profile["approval_policy"] != approvalPolicyNever || + profile["model_reasoning_effort"] != defaultReasoning { + t.Fatalf("profile = %#v", profile) + } + profileFeatures := tableValue(t, profile, "features") + if profileFeatures["web_search_request"] != true || profileFeatures["shell_snapshot"] != true { + t.Fatalf("profile features = %#v", profileFeatures) + } + provider := tableValue(t, tableValue(t, doc, "model_providers"), pluralProvider) + if provider["base_url"] != "https://console.plural.sh/ext/ai/v1" || provider["env_key"] != consoleTokenEnv || provider["wire_api"] != chatWireAPI { + t.Fatalf("provider = %#v", provider) + } +} + +func TestConfigTemplateCustomEndpointAndAutoOmission(t *testing.T) { + custom := renderConfigTemplate(t, ConfigTemplateInput{ + RepositoryDir: "/repo", + Profile: configTemplateProfile{ + Name: reviewProfile, + Model: "gpt-5.4", + ModelProvider: customProvider, + SandboxMode: sandboxModeHarness, + ApprovalPolicy: approvalPolicyNever, + ModelReasoningEffort: defaultReasoning, + }, + Providers: []configTemplateProvider{{ + Name: customProvider, + BaseURL: "https://custom.example/v1", + EnvKey: openAIAPIKeyEnv, + WireAPI: responsesWireAPI, + }}, + }) + provider := tableValue(t, tableValue(t, custom, "model_providers"), customProvider) + if provider["base_url"] != "https://custom.example/v1" || provider["wire_api"] != responsesWireAPI { + t.Fatalf("custom provider = %#v", provider) + } + + auto := renderConfigTemplate(t, ConfigTemplateInput{ + RepositoryDir: "/repo", + Profile: configTemplateProfile{ + Name: analysisProfile, + Model: "gpt-5.4", + SandboxMode: sandboxModeHarness, + ApprovalPolicy: approvalPolicyNever, + ModelReasoningEffort: defaultReasoning, + }, + }) + profile := tableValue(t, tableValue(t, auto, "profiles"), analysisProfile) + if _, ok := profile["model_provider"]; ok { + t.Fatalf("auto profile unexpectedly selected provider: %#v", profile) + } + if _, ok := auto["model_providers"]; ok { + t.Fatalf("auto config unexpectedly emitted providers: %#v", auto) + } + if _, ok := profile["shell_environment_policy"]; ok { + t.Fatalf("empty shell policy unexpectedly emitted: %#v", profile) + } +} + +func TestConfigTemplateDindShellEnvironment(t *testing.T) { + t.Setenv(dind.DockerHostEnv, "tcp://docker:2375") + agent := NewAgent(toolConfigForTemplateTests()) + shell := agent.shellEnvironmentPolicy(true) + doc := renderConfigTemplate(t, ConfigTemplateInput{ + RepositoryDir: "/repo", + Profile: configTemplateProfile{ + Name: autonomousProfile, + Model: "gpt-5.4", + SandboxMode: sandboxModeHarness, + ApprovalPolicy: approvalPolicyNever, + ModelReasoningEffort: defaultReasoning, + ShellEnvironmentPolicy: shell, + }, + }) + policy := tableValue(t, tableValue(t, tableValue(t, doc, "profiles"), autonomousProfile), "shell_environment_policy") + includeOnly, ok := policy["include_only"].([]any) + if !ok || len(includeOnly) == 0 { + t.Fatalf("shell policy include_only = %#v", policy["include_only"]) + } + set := tableValue(t, policy, "set") + if set[dind.DockerHostEnv] != "tcp://docker:2375" { + t.Fatalf("shell policy set = %#v", set) + } +} + +func TestConfigTemplateBuiltInAndExternalMCP(t *testing.T) { + agent := NewAgent(toolConfigForTemplateTests()) + servers := agent.nativeMCPServers(nil) + servers = append(servers, configTemplateMCP{ + Name: "linear", + Type: "http", + URL: "https://mcp.linear.app/mcp", + Args: []string{"--transport", "http"}, + Env: []configTemplateKeyValue{{Key: "LINEAR_TEAM", Value: "console"}}, + Headers: []configTemplateKeyValue{{Key: "X-Client", Value: "agent-harness"}}, + HTTPHeaders: []configTemplateKeyValue{{Key: "Authorization", Value: "Bearer token"}}, + EnvHTTPHeaders: []configTemplateKeyValue{{ + Key: "X-Api-Key", Value: "LINEAR_API_KEY", + }}, + EnabledTools: []string{"list_issues"}, + DisabledTools: []string{"delete_issue"}, + TrustPolicy: trustPolicyAlways, + }) + doc := renderConfigTemplate(t, ConfigTemplateInput{ + RepositoryDir: "/repo", + Profile: configTemplateProfile{Name: autonomousProfile, Model: "gpt-5.4"}, + MCPServers: servers, + }) + mcps := tableValue(t, doc, "mcp_servers") + plural := tableValue(t, mcps, pluralProvider) + if plural["type"] != mcpHTTPTransport || plural["url"] != common.AgentMCPServerURL || plural["trust_policy"] != trustPolicyAlways { + t.Fatalf("plural MCP = %#v", plural) + } + codebase := tableValue(t, mcps, common.CodebaseMemoryMCPServerName) + if codebase["type"] != mcpStdioTransport || codebase["command"] != common.CodebaseMemoryMCPCommand { + t.Fatalf("codebase MCP = %#v", codebase) + } + env := tableValue(t, codebase, "env") + if env[common.CodebaseMemoryCacheEnv] != common.CodebaseMemoryCacheDir { + t.Fatalf("codebase MCP env = %#v", env) + } + linear := tableValue(t, mcps, "linear") + if linear["url"] != "https://mcp.linear.app/mcp" || linear["trust_policy"] != trustPolicyAlways { + t.Fatalf("linear MCP = %#v", linear) + } + header := tableValue(t, linear, "http_headers") + if header["Authorization"] != "Bearer token" { + t.Fatalf("linear headers = %#v", header) + } + if got := linear["args"].([]any); len(got) != 2 || got[0] != "--transport" || got[1] != "http" { + t.Fatalf("linear args = %#v", linear["args"]) + } + if tableValue(t, linear, "env")["LINEAR_TEAM"] != "console" || tableValue(t, linear, "headers")["X-Client"] != "agent-harness" || + tableValue(t, linear, "env_http_headers")["X-Api-Key"] != "LINEAR_API_KEY" { + t.Fatalf("linear optional fields = %#v", linear) + } + if got := linear["enabled_tools"].([]any); len(got) != 1 || got[0] != "list_issues" { + t.Fatalf("linear enabled tools = %#v", linear["enabled_tools"]) + } + if got := linear["disabled_tools"].([]any); len(got) != 1 || got[0] != "delete_issue" { + t.Fatalf("linear disabled tools = %#v", linear["disabled_tools"]) + } +} + +func TestConfigTemplateEscapesDynamicStrings(t *testing.T) { + repository := "C:\\repo\\it's\nquoted" + profileName := "review\"profile" + model := "vendor\\model\n\"name\a\v" + key := "X-Header\\name" + value := "line 1\nline 2 with \"quotes\"" + doc := renderConfigTemplate(t, ConfigTemplateInput{ + RepositoryDir: repository, + Profile: configTemplateProfile{ + Name: profileName, + Model: model, + SandboxMode: sandboxModeHarness, + ApprovalPolicy: approvalPolicyNever, + ModelReasoningEffort: defaultReasoning, + }, + MCPServers: []configTemplateMCP{{ + Name: "mcp\\\"server", + Type: "http", + HTTPHeaders: []configTemplateKeyValue{{Key: key, Value: value}}, + }}, + }) + if tableValue(t, tableValue(t, doc, "projects"), repository)["trust_level"] != "trusted" { + t.Fatalf("escaped repository key missing: %#v", doc["projects"]) + } + profile := tableValue(t, tableValue(t, doc, "profiles"), profileName) + if profile["model"] != model { + t.Fatalf("escaped model = %#v, want %q", profile["model"], model) + } + header := tableValue(t, tableValue(t, tableValue(t, doc, "mcp_servers"), "mcp\\\"server"), "http_headers") + if header[key] != value { + t.Fatalf("escaped header = %#v, want %q", header, value) + } +} + +func renderConfigTemplate(t *testing.T, input ConfigTemplateInput) map[string]any { + t.Helper() + _, content, err := configTemplate(&input) + if err != nil { + t.Fatalf("configTemplate() error = %v", err) + } + var doc map[string]any + if err := toml.Unmarshal([]byte(content), &doc); err != nil { + t.Fatalf("parse rendered TOML: %v\n%s", err, content) + } + return doc +} + +func tableValue(t *testing.T, value map[string]any, key string) map[string]any { + t.Helper() + child, ok := value[key].(map[string]any) + if !ok { + t.Fatalf("table %q = %#v", key, value[key]) + } + return child +} + +func toolConfigForTemplateTests() toolv1.Config { + return toolv1.Config{WorkDir: "/work", RepositoryDir: "/repo", Run: &agentrunv1.AgentRun{ + Mode: console.AgentRunModeWrite, + Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{ + Codex: &agentrunv1.CodexConfig{}, + }}, + }} +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go new file mode 100644 index 0000000000..0662b8a7c6 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go @@ -0,0 +1,108 @@ +package codex + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" +) + +// The agent image supplies this executable as Codex's ACP adapter. +const codexACPBinary = "codex-acp" + +// Transport invokes Codex through its ACP adapter. It owns process launch and +// projects runtime settings into ACP identifiers; acp.Engine owns the protocol. +type Transport struct { + agent *Agent + engine *acp.Engine + repositoryDir string +} + +var _ toolv1.Transport = (*Transport)(nil) + +// NewTransport creates a Codex ACP transport for an Agent. +func NewTransport(agent *Agent) (*Transport, error) { + if agent == nil { + return nil, errors.New("codex agent is not set") + } + config, err := agent.configWithCodex() + if err != nil { + return nil, err + } + repositoryDir, err := filepath.Abs(config.RepositoryDir) + if err != nil { + return nil, fmt.Errorf("resolve codex repository directory: %w", err) + } + return &Transport{ + agent: agent, + engine: acp.NewEngine(acp.Config{}), + repositoryDir: repositoryDir, + }, nil +} + +// Kind identifies this as an Agent Client Protocol transport. +func (*Transport) Kind() toolv1.TransportKind { + return toolv1.TransportKindACP +} + +// Capabilities reports the ACP features implemented by Codex. +func (*Transport) Capabilities() toolv1.TransportCapabilities { + return toolv1.TransportCapabilities{ + SessionResume: true, + ToolCallOutputStreaming: true, + UsageReporting: true, + FileSystemRead: true, + FileSystemWrite: true, + } +} + +// Turn launches codex-acp and delegates session lifecycle and event mapping to +// the provider-neutral ACP engine. +func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest, sink toolv1.TurnSink) (toolv1.TurnResult, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + + model, reasoning, modeID, err := transport.agent.resolveACPSettings(request.Settings) + if err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + process, err := transport.launch(request.Options, model) + if err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + result, err := transport.engine.Turn(ctx, process, acp.Request{ + Cwd: transport.repositoryDir, + Prompt: request.Prompt, + SessionID: request.SessionID, + Settings: acp.SessionSettings{ModeID: modeID, ModelID: model, Reasoning: reasoning}, + }, sink) + return toolv1.TurnResult{SessionID: result.SessionID}, err +} + +func (transport *Transport) launch(options []exec.Option, model string) (*exec.StdioProcess, error) { + config := transport.agent.config + + provider := transport.agent.resolveACPProvider(config) + env, err := transport.agent.env(config, model, provider) + if err != nil { + return nil, err + } + + launchOptions := append([]exec.Option(nil), options...) + launchOptions = append(launchOptions, + exec.WithEnv(env), + exec.WithDir(transport.repositoryDir), + exec.WithTimeout(config.Run.Runtime.Config.Codex.Timeout), + ) + // ACP owns cancellation ordering. The engine sends session/cancel before + // closing stdin or killing the process, so the child is detached from ctx. + return exec.StartWithStdio(context.Background(), codexACPBinary, launchOptions...) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport_test.go new file mode 100644 index 0000000000..b7ecb1abff --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport_test.go @@ -0,0 +1,136 @@ +package codex + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + console "github.com/pluralsh/console/go/client" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" + stackv1 "github.com/pluralsh/console/go/deployment-operator/pkg/harness/stackrun/v1" +) + +func TestTransportLaunchPreservesHooksAndACPEnvironment(t *testing.T) { + binDir := t.TempDir() + envPath := filepath.Join(t.TempDir(), "env") + writeCodexACPBinary(t, binDir) + t.Setenv(pathEnv, binDir+string(os.PathListSeparator)+os.Getenv(pathEnv)) + t.Setenv("CODEX_ENV_FILE", envPath) + endpoint := "https://api.example/v1" + run := codexTestRun(console.AgentRunModeWrite, "gpt-5.4", false) + run.Runtime.Config.Codex.Endpoint = &endpoint + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: run} + agent := NewAgent(config) + settings, err := agent.ResolveSettings(run) + if err != nil { + t.Fatal(err) + } + transport, err := NewTransport(agent) + if err != nil { + t.Fatal(err) + } + model := agent.resolveModelForSettings(config, settings) + var preStarts, postStarts atomic.Int32 + process, err := transport.launch([]exec.Option{ + exec.WithHook(stackv1.LifecyclePreStart, func() error { + preStarts.Add(1) + return nil + }), + exec.WithHook(stackv1.LifecyclePostStart, func() error { + postStarts.Add(1) + return nil + }), + }, model) + if err != nil { + t.Fatalf("launch() error = %v", err) + } + go io.Copy(io.Discard, process.Stdout) + go io.Copy(io.Discard, process.Stderr) + if err := process.Wait(); err != nil { + t.Fatalf("process.Wait() error = %v", err) + } + if preStarts.Load() != 1 || postStarts.Load() != 1 { + t.Fatalf("hooks = %d/%d, want 1/1", preStarts.Load(), postStarts.Load()) + } + env, err := os.ReadFile(envPath) + if err != nil { + t.Fatal(err) + } + content := string(env) + for _, want := range []string{ + consoleTokenEnv + "=", + codexAPIKeyEnv + "=api-key", + openAIAPIKeyEnv + "=api-key", + defaultAuthRequestEnv + `={"methodId":"api-key"}`, + noBrowserEnv + "=1", + modelProviderEnv + "=custom", + codexHomeEnv + "=" + filepath.Join(config.WorkDir, codexHomeDir), + codexConfigEnv + `={"model":"gpt-5.4"}`, + } { + if !strings.Contains(content, want) { + t.Fatalf("environment missing %q: %s", want, content) + } + } +} + +func TestTransportProjectsCodexACP(t *testing.T) { + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: codexTestRun(console.AgentRunModeAnalyze, "gpt-5.4", true)} + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatal(err) + } + if transport.Kind() != toolv1.TransportKindACP { + t.Fatalf("transport kind = %q, want ACP", transport.Kind()) + } + model, reasoning, modeID, err := transport.agent.resolveACPSettings(toolv1.Settings{Mode: console.AgentRunModeAnalyze, Model: toolv1.ModelSelection{Name: "openai/gpt-5.4"}}) + if err != nil { + t.Fatal(err) + } + if model != "openai/gpt-5.4" || reasoning != defaultReasoning || modeID != acpModeID { + t.Fatalf("ACP settings = %q, %q, %q", model, reasoning, modeID) + } +} + +func TestTransportTurnReturnsPreCancelledContextBeforeLaunch(t *testing.T) { + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: codexTestRun(console.AgentRunModeWrite, "gpt-5.4", false)} + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = transport.Turn(ctx, toolv1.TurnRequest{}, nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Turn() error = %v, want context canceled", err) + } +} + +func TestTransportTurnAcceptsNilContext(t *testing.T) { + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: codexTestRun(console.AgentRunModeWrite, "gpt-5.4", false)} + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatal(err) + } + + _, err = transport.Turn(nil, toolv1.TurnRequest{Settings: toolv1.Settings{Mode: console.AgentRunMode("unsupported")}}, nil) + if err == nil || !strings.Contains(err.Error(), "unsupported codex ACP mode") { + t.Fatalf("Turn() error = %v, want unsupported mode", err) + } +} + +func writeCodexACPBinary(t *testing.T, binDir string) { + t.Helper() + path := filepath.Join(binDir, codexACPBinary) + script := "#!/bin/sh\n" + + "printf 'PLRL_CONSOLE_TOKEN=%s\\nCODEX_HOME=%s\\nCODEX_API_KEY=%s\\nOPENAI_API_KEY=%s\\nDEFAULT_AUTH_REQUEST=%s\\nNO_BROWSER=%s\\nMODEL_PROVIDER=%s\\nCODEX_CONFIG=%s\\n' \"$PLRL_CONSOLE_TOKEN\" \"$CODEX_HOME\" \"$CODEX_API_KEY\" \"$OPENAI_API_KEY\" \"$DEFAULT_AUTH_REQUEST\" \"$NO_BROWSER\" \"$MODEL_PROVIDER\" \"$CODEX_CONFIG\" > \"$CODEX_ENV_FILE\"\n" + if err := os.WriteFile(path, []byte(script), 0755); err != nil { + t.Fatal(err) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/types.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/types.go new file mode 100644 index 0000000000..70a994c46a --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/types.go @@ -0,0 +1,56 @@ +package codex + +// ConfigTemplateInput contains the native Codex settings that vary per run. +// Slices replace maps so templates can preserve a deterministic order while +// still representing Codex's map-shaped TOML sections. +type ConfigTemplateInput struct { + RepositoryDir string + Profile configTemplateProfile + Providers []configTemplateProvider + MCPServers []configTemplateMCP +} + +type configTemplateProfile struct { + Name string + Model string + ModelProvider string + SandboxMode string + ApprovalPolicy string + ModelReasoningEffort string + ShellEnvironmentPolicy *configTemplateShellEnvironmentPolicy + EnableWebSearch bool + EnableShellCache bool + ModelInstructionsFile string +} + +type configTemplateShellEnvironmentPolicy struct { + IncludeOnly []string + Set []configTemplateKeyValue +} + +type configTemplateProvider struct { + Name string + BaseURL string + EnvKey string + WireAPI string +} + +type configTemplateMCP struct { + Name string + Type string + URL string + Command string + Args []string + Env []configTemplateKeyValue + Headers []configTemplateKeyValue + HTTPHeaders []configTemplateKeyValue + EnvHTTPHeaders []configTemplateKeyValue + EnabledTools []string + DisabledTools []string + TrustPolicy string +} + +type configTemplateKeyValue struct { + Key string + Value string +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/acp_environment.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/acp_environment.go new file mode 100644 index 0000000000..2cd7b4a9a9 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/acp_environment.go @@ -0,0 +1,32 @@ +package opencode + +import ( + "fmt" + "path/filepath" + + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +// These variables point OpenCode's ACP process at the run-local configuration +// and state directories instead of the container user's global directories. +const ( + opencodeConfigEnv = "OPENCODE_CONFIG" + xdgConfigHomeEnv = "XDG_CONFIG_HOME" + xdgDataHomeEnv = "XDG_DATA_HOME" +) + +func (*Agent) configHome(config toolv1.Config) string { + return filepath.Join(config.WorkDir, ".config") +} + +func (*Agent) dataHome(config toolv1.Config) string { + return filepath.Join(config.WorkDir, ".local", "share") +} + +func (agent *Agent) env(config toolv1.Config, configPath string) []string { + return []string{ + fmt.Sprintf("%s=%s", opencodeConfigEnv, configPath), + fmt.Sprintf("%s=%s", xdgConfigHomeEnv, agent.configHome(config)), + fmt.Sprintf("%s=%s", xdgDataHomeEnv, agent.dataHome(config)), + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/acp_environment_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/acp_environment_test.go new file mode 100644 index 0000000000..f10783878f --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/acp_environment_test.go @@ -0,0 +1,29 @@ +package opencode + +import ( + "strings" + "testing" + + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestEnvUsesHarnessHomeAndConfigHome(t *testing.T) { + workDir := t.TempDir() + config := toolv1.Config{ + WorkDir: workDir, + Run: &agentrunv1.AgentRun{ + Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{OpenCode: &agentrunv1.OpencodeConfig{}}}, + }, + } + + env := strings.Join(NewAgent(config).env(config, "/tmp/opencode.json"), "\n") + for _, want := range []string{ + "XDG_CONFIG_HOME=" + workDir + "/.config", + "XDG_DATA_HOME=" + workDir + "/.local/share", + } { + if !strings.Contains(env, want) { + t.Fatalf("expected env to contain %q, got:\n%s", want, env) + } + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent.go index 83a17de431..ff290f07d7 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent.go @@ -4,16 +4,15 @@ import ( "context" "fmt" "path/filepath" - "strings" console "github.com/pluralsh/console/go/client" - agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" ) -// Agent owns the provider-specific settings and configuration for OpenCode. -// Turn execution remains the responsibility of a v1.Transport. +// Agent owns OpenCode's shared preparation and lifecycle entrypoints. Runtime +// settings, native configuration, and ACP environment details live in their +// responsibility-specific files. type Agent struct { config toolv1.Config } @@ -41,26 +40,6 @@ func (*Agent) Capabilities() toolv1.AgentCapabilities { }} } -// ResolveSettings resolves provider/model defaults without copying credentials -// into the provider-neutral runtime settings. -func (agent *Agent) ResolveSettings(run *agentrunv1.AgentRun) (toolv1.Settings, error) { - openCode, err := agent.runConfig(run) - if err != nil { - return toolv1.Settings{}, err - } - - resolved := agent.resolveSettings(openCode.Provider, openCode.Model, openCode.OpenAICompatible, run.IsProxyEnabled()) - return toolv1.Settings{ - Mode: run.Mode, - Model: toolv1.ModelSelection{ - Provider: agent.aiProvider(resolved.provider), - Name: resolved.model, - }, - Timeout: openCode.Timeout, - Proxy: run.IsProxyEnabled(), - }, nil -} - // Prepare writes the OpenCode system prompt and run skills for a phase. func (agent *Agent) Prepare(ctx context.Context, request toolv1.FileSystemRequest) error { if ctx != nil && ctx.Err() != nil { @@ -111,12 +90,15 @@ func (agent *Agent) Configure(ctx context.Context, request toolv1.ConfigureReque if err != nil { return err } + resolved := agent.resolveSettings(openCode.Provider, openCode.Model, openCode.OpenAICompatible, agent.config.Run.IsProxyEnabled()) model := request.Settings.Model.Name if model == "" { model = resolved.model } + // The provider configuration is written once during the initial phase. The + // runtime supplies the already resolved model for this configuration pass. if err := agent.configureNative( agent.config, request.ConsoleURL, @@ -137,8 +119,11 @@ func (agent *Agent) Configure(ctx context.Context, request toolv1.ConfigureReque // Export writes the native OpenCode session export into OutputDir and returns // that directory as the source for the shared artifact builder. func (agent *Agent) Export(ctx context.Context, request toolv1.ExportRequest) (toolv1.ExportResult, error) { - if ctx != nil && ctx.Err() != nil { - return toolv1.ExportResult{}, ctx.Err() + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return toolv1.ExportResult{}, err } if request.SessionID == "" { return toolv1.ExportResult{}, fmt.Errorf("opencode session id is not set") @@ -161,65 +146,3 @@ func (agent *Agent) Export(ctx context.Context, request toolv1.ExportRequest) (t ArchivePath: "opencode", }}, nil } - -func (agent *Agent) configWithOpenCode() (*agentrunv1.OpencodeConfig, error) { - if agent.config.WorkDir == "" { - return nil, fmt.Errorf("work directory is not set") - } - if agent.config.RepositoryDir == "" { - return nil, fmt.Errorf("repository directory is not set") - } - return agent.runConfig(agent.config.Run) -} - -func (*Agent) runConfig(run *agentrunv1.AgentRun) (*agentrunv1.OpencodeConfig, error) { - if run == nil { - return nil, fmt.Errorf("agent run is not set") - } - if run.Runtime == nil || run.Runtime.Config == nil || run.Runtime.Config.OpenCode == nil { - return nil, fmt.Errorf("opencode runtime configuration is not set") - } - return run.Runtime.Config.OpenCode, nil -} - -func (*Agent) aiProvider(provider Provider) *console.AiProvider { - var mapped console.AiProvider - switch strings.ToLower(string(provider)) { - case string(ProviderPlural), string(ProviderOpenAI): - mapped = console.AiProviderOpenai - case string(ProviderAnthropic): - mapped = console.AiProviderAnthropic - case string(ProviderOllama): - mapped = console.AiProviderOllama - case string(ProviderAzure): - mapped = console.AiProviderAzure - case string(ProviderAmazonBedrock), string(ProviderBedrock): - mapped = console.AiProviderBedrock - case string(ProviderGoogleVertex), string(ProviderVertex): - mapped = console.AiProviderVertex - case string(ProviderOpenAICompatible): - mapped = console.AiProviderOpenaiCompatible - case string(ProviderXAI): - mapped = console.AiProviderXai - default: - return nil - } - return &mapped -} - -func (agent *Agent) configForFilesystem(request toolv1.FileSystemRequest) (toolv1.Config, error) { - if request.WorkDir == "" { - return toolv1.Config{}, fmt.Errorf("work directory is not set") - } - if request.RepositoryDir == "" { - return toolv1.Config{}, fmt.Errorf("repository directory is not set") - } - if agent.config.Run == nil { - return toolv1.Config{}, fmt.Errorf("agent run is not set") - } - - config := agent.config - config.WorkDir = request.WorkDir - config.RepositoryDir = request.RepositoryDir - return config, nil -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/config.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_config.go similarity index 51% rename from go/deployment-operator/pkg/agentrun-harness/tool/opencode/config.go rename to go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_config.go index d91ca423bb..769ac864f5 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_config.go @@ -1,18 +1,25 @@ package opencode import ( - "bytes" - "context" "fmt" - "os" - stdexec "os/exec" "path/filepath" "github.com/pluralsh/console/go/deployment-operator/internal/helpers" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/common" ) +// These paths keep OpenCode's native configuration and skills inside the +// workspace owned by the agent run. +const ( + opencodeHomeDir = ".opencode" + opencodeSkillsDir = "skills" +) + +// ConfigFileName is the native OpenCode configuration filename. +const ConfigFileName = "opencode.json" + // configureNative writes only the provider-native OpenCode configuration. The // shared prompt and skill files are prepared separately by Agent.Prepare. func (agent *Agent) configureNative(config toolv1.Config, consoleURL, consoleToken string, provider Provider, model string, openaiCompatible bool, token string) error { @@ -40,11 +47,12 @@ func (agent *Agent) configureNative(config toolv1.Config, consoleURL, consoleTok if err = helpers.File().Create(configPath, content, 0644); err != nil { return fmt.Errorf("failed configuring opencode config file %q: %w", ConfigFileName, err) } + return nil } -func (*Agent) providerPath(config toolv1.Config) string { - return filepath.Join(config.WorkDir, ".opencode") +func (agent *Agent) providerPath(config toolv1.Config) string { + return filepath.Join(config.WorkDir, opencodeHomeDir) } func (agent *Agent) configPath(config toolv1.Config) string { @@ -52,49 +60,32 @@ func (agent *Agent) configPath(config toolv1.Config) string { } func (agent *Agent) skillsPath(config toolv1.Config) string { - return filepath.Join(agent.providerPath(config), "skills") -} - -func (*Agent) configHome(config toolv1.Config) string { - return filepath.Join(config.WorkDir, ".config") -} - -func (*Agent) dataHome(config toolv1.Config) string { - return filepath.Join(config.WorkDir, ".local", "share") + return filepath.Join(agent.providerPath(config), opencodeSkillsDir) } -func (agent *Agent) env(config toolv1.Config, configPath string) []string { - return []string{ - fmt.Sprintf("OPENCODE_CONFIG=%s", configPath), - fmt.Sprintf("XDG_CONFIG_HOME=%s", agent.configHome(config)), - fmt.Sprintf("XDG_DATA_HOME=%s", agent.dataHome(config)), +func (agent *Agent) configWithOpenCode() (*agentrunv1.OpencodeConfig, error) { + if agent.config.WorkDir == "" { + return nil, fmt.Errorf("work directory is not set") } + if agent.config.RepositoryDir == "" { + return nil, fmt.Errorf("repository directory is not set") + } + return agent.runConfig(agent.config.Run) } -// exportSession writes an OpenCode native session export to outputPath. -func (agent *Agent) exportSession(ctx context.Context, config toolv1.Config, sessionID, outputPath string) error { - if sessionID == "" { - return fmt.Errorf("opencode session id is not set") +func (agent *Agent) configForFilesystem(request toolv1.FileSystemRequest) (toolv1.Config, error) { + if request.WorkDir == "" { + return toolv1.Config{}, fmt.Errorf("work directory is not set") } - configPath, err := filepath.Abs(agent.configPath(config)) - if err != nil { - return err + if request.RepositoryDir == "" { + return toolv1.Config{}, fmt.Errorf("repository directory is not set") } - - file, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("create opencode session export %q: %w", outputPath, err) + if agent.config.Run == nil { + return toolv1.Config{}, fmt.Errorf("agent run is not set") } - defer file.Close() - cmd := stdexec.CommandContext(ctx, "opencode", "export", sessionID) - cmd.Env = append(os.Environ(), agent.env(config, configPath)...) - cmd.Dir = config.RepositoryDir - cmd.Stdout = file - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("opencode export session %q: %w: %s", sessionID, err, stderr.String()) - } - return nil + config := agent.config + config.WorkDir = request.WorkDir + config.RepositoryDir = request.RepositoryDir + return config, nil } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_config_test.go new file mode 100644 index 0000000000..97d57040f7 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_config_test.go @@ -0,0 +1,70 @@ +package opencode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestAgentConfigurePreservesNativeConfigForBabysit(t *testing.T) { + useTestSystemTemplates(t) + workDir := t.TempDir() + config := toolv1.Config{ + WorkDir: workDir, + RepositoryDir: t.TempDir(), + Run: agentRun("anthropic", "claude-sonnet-4-5", false, false), + } + agent := NewAgent(config) + initial := toolv1.FileSystemRequest{Phase: toolv1.ConfigurePhaseInitial, WorkDir: config.WorkDir, RepositoryDir: config.RepositoryDir} + if err := agent.Prepare(context.Background(), initial); err != nil { + t.Fatalf("Prepare(initial) error = %v", err) + } + settings, err := agent.ResolveSettings(config.Run) + if err != nil { + t.Fatalf("ResolveSettings() error = %v", err) + } + configure := toolv1.ConfigureRequest{ + Phase: toolv1.ConfigurePhaseInitial, + ConsoleURL: "https://console.example", + ConsoleToken: "console-token", + Settings: settings, + } + if err := agent.Configure(context.Background(), configure); err != nil { + t.Fatalf("Configure(initial) error = %v", err) + } + configPath := filepath.Join(workDir, ".opencode", ConfigFileName) + before, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read native config: %v", err) + } + + babysit := initial + babysit.Phase = toolv1.ConfigurePhaseBabysit + if err := agent.Prepare(context.Background(), babysit); err != nil { + t.Fatalf("Prepare(babysit) error = %v", err) + } + configure.Phase = toolv1.ConfigurePhaseBabysit + configure.ConsoleToken = "" + if err := agent.Configure(context.Background(), configure); err != nil { + t.Fatalf("Configure(babysit) error = %v", err) + } + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read native config after babysit: %v", err) + } + if string(before) != string(after) { + t.Fatal("babysit configuration unexpectedly rewrote native config") + } + + var native map[string]any + if err := json.Unmarshal(before, &native); err != nil { + t.Fatalf("decode native config: %v", err) + } + if native["model"] != "anthropic/claude-sonnet-4-5" { + t.Fatalf("native model = %v", native["model"]) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_test.go index 4afd29185e..7bb40caf7f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_test.go @@ -2,122 +2,16 @@ package opencode import ( "context" - "encoding/json" "os" "path/filepath" "strings" "testing" - "time" console "github.com/pluralsh/console/go/client" agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" ) -func TestAgentResolveSettings(t *testing.T) { - tests := []struct { - name string - provider string - model string - compat bool - proxy bool - want console.AiProvider - wantName string - wantACP string - }{ - {name: "default", want: console.AiProviderOpenai, wantName: defaultModel, wantACP: "plural/" + defaultModel}, - {name: "native anthropic", provider: "anthropic", model: "claude-sonnet-4-5", want: console.AiProviderAnthropic, wantName: "claude-sonnet-4-5", wantACP: "anthropic/claude-sonnet-4-5"}, - {name: "native bedrock", provider: "amazon-bedrock", model: "anthropic.claude-3", want: console.AiProviderBedrock, wantName: "anthropic.claude-3", wantACP: "amazon-bedrock/anthropic.claude-3"}, - {name: "native bedrock alias", provider: "bedrock", model: "anthropic.claude-3", want: console.AiProviderBedrock, wantName: "anthropic.claude-3", wantACP: "bedrock/anthropic.claude-3"}, - {name: "native vertex", provider: "google-vertex", model: "gemini-2.5-pro", want: console.AiProviderVertex, wantName: "gemini-2.5-pro", wantACP: "google-vertex/gemini-2.5-pro"}, - {name: "native vertex alias", provider: "vertex", model: "gemini-2.5-pro", want: console.AiProviderVertex, wantName: "gemini-2.5-pro", wantACP: "vertex/gemini-2.5-pro"}, - {name: "native ollama", provider: "ollama", model: "qwen3", want: console.AiProviderOllama, wantName: "qwen3", wantACP: "ollama/qwen3"}, - {name: "native azure", provider: "azure", model: "gpt-5", want: console.AiProviderAzure, wantName: "gpt-5", wantACP: "azure/gpt-5"}, - {name: "native xai", provider: "xai", model: "grok-4", want: console.AiProviderXai, wantName: "grok-4", wantACP: "xai/grok-4"}, - {name: "native google has no Console equivalent", provider: "google", model: "gemini-2.5-pro", wantName: "gemini-2.5-pro", wantACP: "google/gemini-2.5-pro"}, - {name: "proxy", provider: "anthropic", model: "gpt-5.4", proxy: true, want: console.AiProviderOpenai, wantName: "openai/gpt-5.4", wantACP: "plural/openai/gpt-5.4"}, - {name: "proxy preserves model provider", model: "openai/gpt-5.4", proxy: true, want: console.AiProviderOpenai, wantName: "openai/gpt-5.4", wantACP: "plural/openai/gpt-5.4"}, - {name: "openai compatible", provider: "litellm", model: "custom-model", compat: true, want: console.AiProviderOpenaiCompatible, wantName: "custom-model", wantACP: "openai-compatible/custom-model"}, - {name: "unknown native provider", provider: "mistral", model: "large-latest", wantName: "large-latest", wantACP: "mistral/large-latest"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - run := agentRun(tt.provider, tt.model, tt.compat, tt.proxy) - agent := NewAgent(toolv1.Config{Run: run, RepositoryDir: t.TempDir()}) - settings, err := agent.ResolveSettings(run) - if err != nil { - t.Fatalf("ResolveSettings() error = %v", err) - } - if settings.Mode != console.AgentRunModeWrite { - t.Fatalf("mode = %q, want %q", settings.Mode, console.AgentRunModeWrite) - } - if tt.want == "" { - if settings.Model.Provider != nil { - t.Fatalf("provider = %q, want nil for unknown native provider", *settings.Model.Provider) - } - } else if settings.Model.Provider == nil || *settings.Model.Provider != tt.want { - t.Fatalf("provider = %v, want %q", settings.Model.Provider, tt.want) - } - if settings.Model.Name != tt.wantName { - t.Fatalf("model = %q, want %q", settings.Model.Name, tt.wantName) - } - transport, err := NewTransport(agent) - if err != nil { - t.Fatal(err) - } - projected, err := transport.sessionSettings(settings) - if err != nil { - t.Fatal(err) - } - if projected.ModelID != tt.wantACP { - t.Fatalf("ACP model = %q, want %q", projected.ModelID, tt.wantACP) - } - if projected.ModeID != writeModeID { - t.Fatalf("ACP mode = %q, want %q", projected.ModeID, writeModeID) - } - if settings.Timeout != 9*time.Minute { - t.Fatalf("timeout = %s, want 9m", settings.Timeout) - } - if settings.Proxy != tt.proxy { - t.Fatalf("proxy = %v, want %v", settings.Proxy, tt.proxy) - } - }) - } -} - -func TestAgentResolveSettingsMapsACPMode(t *testing.T) { - tests := []struct { - mode console.AgentRunMode - want string - }{ - {mode: console.AgentRunModeAnalyze, want: analysisModeID}, - {mode: console.AgentRunModeWrite, want: writeModeID}, - {mode: console.AgentRunModeReview, want: reviewModeID}, - } - for _, test := range tests { - run := agentRun("openai", "gpt-5.4", false, false) - run.Mode = test.mode - agent := NewAgent(toolv1.Config{Run: run, RepositoryDir: t.TempDir()}) - settings, err := agent.ResolveSettings(run) - if err != nil { - t.Fatal(err) - } - transport, err := NewTransport(agent) - if err != nil { - t.Fatal(err) - } - projected, err := transport.sessionSettings(settings) - if err != nil { - t.Fatal(err) - } - if projected.ModeID != test.want { - t.Fatalf("mode %q mapped to %q, want %q", test.mode, projected.ModeID, test.want) - } - } -} - func TestAgentCapabilities(t *testing.T) { capabilities := NewAgent(toolv1.Config{}).Capabilities() for _, mode := range []console.AgentRunMode{ @@ -173,110 +67,6 @@ func TestAgentPreparePhases(t *testing.T) { } } -func TestAgentConfigurePreservesNativeConfigForBabysit(t *testing.T) { - useTestSystemTemplates(t) - workDir := t.TempDir() - config := toolv1.Config{ - WorkDir: workDir, - RepositoryDir: t.TempDir(), - Run: agentRun("anthropic", "claude-sonnet-4-5", false, false), - } - agent := NewAgent(config) - initial := toolv1.FileSystemRequest{Phase: toolv1.ConfigurePhaseInitial, WorkDir: config.WorkDir, RepositoryDir: config.RepositoryDir} - if err := agent.Prepare(context.Background(), initial); err != nil { - t.Fatalf("Prepare(initial) error = %v", err) - } - settings, err := agent.ResolveSettings(config.Run) - if err != nil { - t.Fatalf("ResolveSettings() error = %v", err) - } - configure := toolv1.ConfigureRequest{ - Phase: toolv1.ConfigurePhaseInitial, - ConsoleURL: "https://console.example", - ConsoleToken: "console-token", - Settings: settings, - } - if err := agent.Configure(context.Background(), configure); err != nil { - t.Fatalf("Configure(initial) error = %v", err) - } - configPath := filepath.Join(workDir, ".opencode", ConfigFileName) - before, err := os.ReadFile(configPath) - if err != nil { - t.Fatalf("read native config: %v", err) - } - - babysit := initial - babysit.Phase = toolv1.ConfigurePhaseBabysit - if err := agent.Prepare(context.Background(), babysit); err != nil { - t.Fatalf("Prepare(babysit) error = %v", err) - } - configure.Phase = toolv1.ConfigurePhaseBabysit - configure.ConsoleToken = "" - if err := agent.Configure(context.Background(), configure); err != nil { - t.Fatalf("Configure(babysit) error = %v", err) - } - after, err := os.ReadFile(configPath) - if err != nil { - t.Fatalf("read native config after babysit: %v", err) - } - if string(before) != string(after) { - t.Fatal("babysit configuration unexpectedly rewrote native config") - } - - var native map[string]any - if err := json.Unmarshal(before, &native); err != nil { - t.Fatalf("decode native config: %v", err) - } - if native["model"] != "anthropic/claude-sonnet-4-5" { - t.Fatalf("native model = %v", native["model"]) - } -} - -func TestAgentExportStagesNativeSession(t *testing.T) { - binDir := t.TempDir() - opencodePath := filepath.Join(binDir, "opencode") - if err := os.WriteFile(opencodePath, []byte("#!/bin/sh\nprintf '%s' '{\"id\":\"session-1\"}'\n"), 0755); err != nil { - t.Fatalf("write fake opencode: %v", err) - } - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) - - config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: agentRun("openai", "gpt-5.4", false, false)} - outputDir := t.TempDir() - result, err := NewAgent(config).Export(context.Background(), toolv1.ExportRequest{SessionID: "session-1", OutputDir: outputDir}) - if err != nil { - t.Fatalf("Export() error = %v", err) - } - if result.SessionSource.Path != outputDir || result.SessionSource.ArchivePath != "opencode" { - t.Fatalf("session source = %#v", result.SessionSource) - } - data, err := os.ReadFile(filepath.Join(outputDir, artifacts.SessionJSONName)) - if err != nil { - t.Fatalf("read staged session: %v", err) - } - if string(data) != `{"id":"session-1"}` { - t.Fatalf("staged session = %q", data) - } -} - -func agentRun(provider, model string, compat, proxy bool) *agentrunv1.AgentRun { - return &agentrunv1.AgentRun{ - ID: "run-1", - Mode: console.AgentRunModeWrite, - Runtime: &agentrunv1.AgentRuntime{ - AiProxy: proxy, - Config: &agentrunv1.AgentRuntimeConfig{ - OpenCode: &agentrunv1.OpencodeConfig{ - Provider: provider, - Model: model, - OpenAICompatible: compat, - Timeout: 9 * time.Minute, - Token: "native-token", - }, - }, - }, - } -} - func useTestSystemTemplates(t *testing.T) { t.Helper() root := t.TempDir() diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/provider_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/provider_test.go deleted file mode 100644 index b2b3862422..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/provider_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package opencode - -import ( - "testing" - - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" -) - -func TestResolveSettingsProvider(t *testing.T) { - tests := []struct { - name string - provider string - want Provider - }{ - {name: "empty defaults to plural", provider: "", want: ProviderPlural}, - {name: "passes through models.dev slug", provider: "anthropic", want: ProviderAnthropic}, - {name: "passes through amazon-bedrock", provider: "amazon-bedrock", want: ProviderAmazonBedrock}, - {name: "passes through google-vertex", provider: "google-vertex", want: ProviderGoogleVertex}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := NewAgent(toolv1.Config{}).resolveSettings(tt.provider, "model", false, false).provider - if got != tt.want { - t.Fatalf("resolveSettings(%q, false, false).provider = %q, want %q", tt.provider, got, tt.want) - } - }) - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/runtime_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/runtime_config.go new file mode 100644 index 0000000000..283ffdde62 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/runtime_config.go @@ -0,0 +1,182 @@ +package opencode + +import ( + "fmt" + "strings" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +// The default model is used when Console omits a model from an OpenCode run. +const defaultModel = "gpt-5.6-luna" + +// Provider is an OpenCode provider id (https://models.dev). +type Provider string + +const ( + // ProviderPlural routes requests through the Console AI proxy (/ext/ai/v1). + ProviderPlural Provider = "plural" + ProviderOpenAI Provider = "openai" + + // Common models.dev provider ids for direct (non-proxy) usage. + ProviderAnthropic Provider = "anthropic" + ProviderAmazonBedrock Provider = "amazon-bedrock" + ProviderGoogleVertex Provider = "google-vertex" + ProviderOllama Provider = "ollama" + ProviderAzure Provider = "azure" + ProviderXAI Provider = "xai" + + // ProviderBedrock and ProviderVertex are legacy aliases accepted by the + // Console provider contract in addition to the canonical models.dev IDs. + ProviderBedrock Provider = "bedrock" + ProviderVertex Provider = "vertex" + + // ProviderOpenAICompatible is the fixed provider key for custom OpenAI-compatible endpoints. + ProviderOpenAICompatible Provider = "openai-compatible" +) + +type opencodeSettings struct { + provider Provider + model string + openaiCompatible bool +} + +// Console run modes map to these agent identifiers in OpenCode's ACP session. +const ( + analysisModeID = "analysis" + reviewModeID = "review" + writeModeID = "autonomous" +) + +// resolveSettings selects provider/model wiring for opencode.json and ACP. +// The proxy branch stays separate so proxy behavior remains unchanged when +// OpenAI-compatible providers are configured. +func (*Agent) resolveSettings(provider, model string, openaiCompatible, proxyEnabled bool) opencodeSettings { + if model == "" { + model = defaultModel + } + + if proxyEnabled { + return opencodeSettings{ + provider: ProviderPlural, + model: proxymodel.ProxyModel(console.AgentRuntimeTypeOpencode, model), + } + } + + if openaiCompatible { + return opencodeSettings{ + provider: ProviderOpenAICompatible, + model: strings.TrimPrefix(model, string(ProviderOpenAICompatible)+"/"), + openaiCompatible: true, + } + } + + selectedProvider := Provider(provider) + if selectedProvider == "" { + selectedProvider = ProviderPlural + } + + return opencodeSettings{ + provider: selectedProvider, + model: strings.TrimPrefix(model, string(selectedProvider)+"/"), + } +} + +// ResolveSettings resolves provider/model defaults without copying credentials +// into the provider-neutral runtime settings. +func (agent *Agent) ResolveSettings(run *agentrunv1.AgentRun) (toolv1.Settings, error) { + openCode, err := agent.runConfig(run) + if err != nil { + return toolv1.Settings{}, err + } + + resolved := agent.resolveSettings(openCode.Provider, openCode.Model, openCode.OpenAICompatible, run.IsProxyEnabled()) + + return toolv1.Settings{ + Mode: run.Mode, + Model: toolv1.ModelSelection{ + Provider: agent.aiProvider(resolved.provider), + Name: resolved.model, + }, + Timeout: openCode.Timeout, + Proxy: run.IsProxyEnabled(), + }, nil +} + +func (*Agent) aiProvider(provider Provider) *console.AiProvider { + var mapped console.AiProvider + switch strings.ToLower(string(provider)) { + case string(ProviderPlural), string(ProviderOpenAI): + mapped = console.AiProviderOpenai + case string(ProviderAnthropic): + mapped = console.AiProviderAnthropic + case string(ProviderOllama): + mapped = console.AiProviderOllama + case string(ProviderAzure): + mapped = console.AiProviderAzure + case string(ProviderAmazonBedrock), string(ProviderBedrock): + mapped = console.AiProviderBedrock + case string(ProviderGoogleVertex), string(ProviderVertex): + mapped = console.AiProviderVertex + case string(ProviderOpenAICompatible): + mapped = console.AiProviderOpenaiCompatible + case string(ProviderXAI): + mapped = console.AiProviderXai + default: + return nil + } + return &mapped +} + +func (*Agent) runConfig(run *agentrunv1.AgentRun) (*agentrunv1.OpencodeConfig, error) { + if run == nil { + return nil, fmt.Errorf("agent run is not set") + } + if run.Runtime == nil || run.Runtime.Config == nil || run.Runtime.Config.OpenCode == nil { + return nil, fmt.Errorf("opencode runtime configuration is not set") + } + return run.Runtime.Config.OpenCode, nil +} + +func (transport *Transport) sessionSettings(settings toolv1.Settings) (acp.SessionSettings, error) { + mode, err := transport.modeID(settings.Mode) + if err != nil { + return acp.SessionSettings{}, err + } + + model := settings.Model.Name + provider := transport.agent.resolveACPProvider(transport.agent.config) + + return acp.SessionSettings{ModeID: mode, ModelID: string(provider) + "/" + model}, nil +} + +func (*Agent) resolveACPProvider(config toolv1.Config) Provider { + openCode := config.Run.Runtime.Config.OpenCode + if config.Run.IsProxyEnabled() { + return ProviderPlural + } + if openCode.OpenAICompatible { + return ProviderOpenAICompatible + } + if openCode.Provider == "" { + return ProviderPlural + } + return Provider(openCode.Provider) +} + +func (*Transport) modeID(mode console.AgentRunMode) (string, error) { + switch mode { + case console.AgentRunModeAnalyze: + return analysisModeID, nil + case console.AgentRunModeReview: + return reviewModeID, nil + case console.AgentRunModeWrite: + return writeModeID, nil + default: + return "", fmt.Errorf("unsupported opencode ACP mode %q", mode) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/runtime_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/runtime_config_test.go new file mode 100644 index 0000000000..36bfc2da82 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/runtime_config_test.go @@ -0,0 +1,254 @@ +package opencode + +import ( + "testing" + "time" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestAgentResolveSettings(t *testing.T) { + tests := []struct { + name string + provider string + model string + compat bool + proxy bool + want console.AiProvider + wantName string + wantACP string + }{ + {name: "default", want: console.AiProviderOpenai, wantName: defaultModel, wantACP: "plural/" + defaultModel}, + {name: "native anthropic", provider: "anthropic", model: "claude-sonnet-4-5", want: console.AiProviderAnthropic, wantName: "claude-sonnet-4-5", wantACP: "anthropic/claude-sonnet-4-5"}, + {name: "native bedrock", provider: "amazon-bedrock", model: "anthropic.claude-3", want: console.AiProviderBedrock, wantName: "anthropic.claude-3", wantACP: "amazon-bedrock/anthropic.claude-3"}, + {name: "native bedrock alias", provider: "bedrock", model: "anthropic.claude-3", want: console.AiProviderBedrock, wantName: "anthropic.claude-3", wantACP: "bedrock/anthropic.claude-3"}, + {name: "native vertex", provider: "google-vertex", model: "gemini-2.5-pro", want: console.AiProviderVertex, wantName: "gemini-2.5-pro", wantACP: "google-vertex/gemini-2.5-pro"}, + {name: "native vertex alias", provider: "vertex", model: "gemini-2.5-pro", want: console.AiProviderVertex, wantName: "gemini-2.5-pro", wantACP: "vertex/gemini-2.5-pro"}, + {name: "native ollama", provider: "ollama", model: "qwen3", want: console.AiProviderOllama, wantName: "qwen3", wantACP: "ollama/qwen3"}, + {name: "native azure", provider: "azure", model: "gpt-5", want: console.AiProviderAzure, wantName: "gpt-5", wantACP: "azure/gpt-5"}, + {name: "native xai", provider: "xai", model: "grok-4", want: console.AiProviderXai, wantName: "grok-4", wantACP: "xai/grok-4"}, + {name: "native google has no Console equivalent", provider: "google", model: "gemini-2.5-pro", wantName: "gemini-2.5-pro", wantACP: "google/gemini-2.5-pro"}, + {name: "proxy", provider: "anthropic", model: "gpt-5.4", proxy: true, want: console.AiProviderOpenai, wantName: "openai/gpt-5.4", wantACP: "plural/openai/gpt-5.4"}, + {name: "proxy preserves model provider", model: "openai/gpt-5.4", proxy: true, want: console.AiProviderOpenai, wantName: "openai/gpt-5.4", wantACP: "plural/openai/gpt-5.4"}, + {name: "openai compatible", provider: "litellm", model: "custom-model", compat: true, want: console.AiProviderOpenaiCompatible, wantName: "custom-model", wantACP: "openai-compatible/custom-model"}, + {name: "unknown native provider", provider: "mistral", model: "large-latest", wantName: "large-latest", wantACP: "mistral/large-latest"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + run := agentRun(tt.provider, tt.model, tt.compat, tt.proxy) + agent := NewAgent(toolv1.Config{Run: run, RepositoryDir: t.TempDir()}) + settings, err := agent.ResolveSettings(run) + if err != nil { + t.Fatalf("ResolveSettings() error = %v", err) + } + if settings.Mode != console.AgentRunModeWrite { + t.Fatalf("mode = %q, want %q", settings.Mode, console.AgentRunModeWrite) + } + if tt.want == "" { + if settings.Model.Provider != nil { + t.Fatalf("provider = %q, want nil for unknown native provider", *settings.Model.Provider) + } + } else if settings.Model.Provider == nil || *settings.Model.Provider != tt.want { + t.Fatalf("provider = %v, want %q", settings.Model.Provider, tt.want) + } + if settings.Model.Name != tt.wantName { + t.Fatalf("model = %q, want %q", settings.Model.Name, tt.wantName) + } + transport, err := NewTransport(agent) + if err != nil { + t.Fatal(err) + } + projected, err := transport.sessionSettings(settings) + if err != nil { + t.Fatal(err) + } + if projected.ModelID != tt.wantACP { + t.Fatalf("ACP model = %q, want %q", projected.ModelID, tt.wantACP) + } + if projected.ModeID != writeModeID { + t.Fatalf("ACP mode = %q, want %q", projected.ModeID, writeModeID) + } + if settings.Timeout != 9*time.Minute { + t.Fatalf("timeout = %s, want 9m", settings.Timeout) + } + if settings.Proxy != tt.proxy { + t.Fatalf("proxy = %v, want %v", settings.Proxy, tt.proxy) + } + }) + } +} + +func TestAgentResolveSettingsMapsACPMode(t *testing.T) { + tests := []struct { + mode console.AgentRunMode + want string + }{ + {mode: console.AgentRunModeAnalyze, want: analysisModeID}, + {mode: console.AgentRunModeWrite, want: writeModeID}, + {mode: console.AgentRunModeReview, want: reviewModeID}, + } + for _, test := range tests { + run := agentRun("openai", "gpt-5.4", false, false) + run.Mode = test.mode + agent := NewAgent(toolv1.Config{Run: run, RepositoryDir: t.TempDir()}) + settings, err := agent.ResolveSettings(run) + if err != nil { + t.Fatal(err) + } + transport, err := NewTransport(agent) + if err != nil { + t.Fatal(err) + } + projected, err := transport.sessionSettings(settings) + if err != nil { + t.Fatal(err) + } + if projected.ModeID != test.want { + t.Fatalf("mode %q mapped to %q, want %q", test.mode, projected.ModeID, test.want) + } + } +} + +func TestResolveSettingsProvider(t *testing.T) { + tests := []struct { + name string + provider string + want Provider + }{ + {name: "empty defaults to plural", provider: "", want: ProviderPlural}, + {name: "passes through models.dev slug", provider: "anthropic", want: ProviderAnthropic}, + {name: "passes through amazon-bedrock", provider: "amazon-bedrock", want: ProviderAmazonBedrock}, + {name: "passes through google-vertex", provider: "google-vertex", want: ProviderGoogleVertex}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewAgent(toolv1.Config{}).resolveSettings(tt.provider, "model", false, false).provider + if got != tt.want { + t.Fatalf("resolveSettings(%q, false, false).provider = %q, want %q", tt.provider, got, tt.want) + } + }) + } +} + +func TestResolveOpenCodeSettings(t *testing.T) { + tests := []struct { + name string + provider string + model string + openaiCompatible bool + proxyEnabled bool + wantProvider Provider + wantModel string + wantOpenAICompat bool + }{ + { + name: "aiProxy forces plural and prefixes bare model", + provider: "anthropic", + model: "gpt-5.4", + proxyEnabled: true, + wantProvider: ProviderPlural, + wantModel: "openai/gpt-5.4", + }, + { + name: "aiProxy leaves provider-prefixed model unchanged", + provider: "openai", + model: "openai/gpt-5.4", + proxyEnabled: true, + wantProvider: ProviderPlural, + wantModel: "openai/gpt-5.4", + }, + { + name: "aiProxy ignores openaiCompatible", + provider: "openai-compatible", + model: "gpt-4", + openaiCompatible: true, + proxyEnabled: true, + wantProvider: ProviderPlural, + wantModel: "openai/gpt-4", + }, + { + name: "openaiCompatible uses fixed provider", + provider: "litellm", + model: "gpt-4", + openaiCompatible: true, + wantProvider: ProviderOpenAICompatible, + wantModel: "gpt-4", + wantOpenAICompat: true, + }, + { + name: "openaiCompatible strips only its provider prefix", + provider: "litellm", + model: "openai-compatible/custom/model", + openaiCompatible: true, + wantProvider: ProviderOpenAICompatible, + wantModel: "custom/model", + wantOpenAICompat: true, + }, + { + name: "openaiCompatible preserves other slash-containing model names", + provider: "litellm", + model: "tenant/custom/model", + openaiCompatible: true, + wantProvider: ProviderOpenAICompatible, + wantModel: "tenant/custom/model", + wantOpenAICompat: true, + }, + { + name: "empty native provider defaults to plural", + wantProvider: ProviderPlural, + wantModel: defaultModel, + }, + { + name: "native provider strips its prefix", + provider: "anthropic", + model: "anthropic/claude-sonnet-4-5", + wantProvider: ProviderAnthropic, + wantModel: "claude-sonnet-4-5", + }, + { + name: "native provider strips exactly one prefix", + provider: "anthropic", + model: "anthropic/anthropic/claude-sonnet-4-5", + wantProvider: ProviderAnthropic, + wantModel: "anthropic/claude-sonnet-4-5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewAgent(toolv1.Config{}).resolveSettings(tt.provider, tt.model, tt.openaiCompatible, tt.proxyEnabled) + if got.provider != tt.wantProvider { + t.Fatalf("provider = %q, want %q", got.provider, tt.wantProvider) + } + if got.model != tt.wantModel { + t.Fatalf("model = %q, want %q", got.model, tt.wantModel) + } + if got.openaiCompatible != tt.wantOpenAICompat { + t.Fatalf("openaiCompatible = %v, want %v", got.openaiCompatible, tt.wantOpenAICompat) + } + }) + } +} + +func agentRun(provider, model string, compat, proxy bool) *agentrunv1.AgentRun { + return &agentrunv1.AgentRun{ + ID: "run-1", + Mode: console.AgentRunModeWrite, + Runtime: &agentrunv1.AgentRuntime{ + AiProxy: proxy, + Config: &agentrunv1.AgentRuntimeConfig{ + OpenCode: &agentrunv1.OpencodeConfig{ + Provider: provider, + Model: model, + OpenAICompatible: compat, + Timeout: 9 * time.Minute, + Token: "native-token", + }, + }, + }, + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/session.go new file mode 100644 index 0000000000..6126d92bdf --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/session.go @@ -0,0 +1,41 @@ +package opencode + +import ( + "bytes" + "context" + "fmt" + "os" + stdexec "os/exec" + "path/filepath" + + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +// exportSession writes an OpenCode native session export to outputPath. +func (agent *Agent) exportSession(ctx context.Context, config toolv1.Config, sessionID, outputPath string) error { + if sessionID == "" { + return fmt.Errorf("opencode session id is not set") + } + configPath, err := filepath.Abs(agent.configPath(config)) + if err != nil { + return err + } + + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create opencode session export %q: %w", outputPath, err) + } + defer file.Close() + + cmd := stdexec.CommandContext(ctx, "opencode", "export", sessionID) + cmd.Env = append(os.Environ(), agent.env(config, configPath)...) + cmd.Dir = config.RepositoryDir + cmd.Stdout = file + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("opencode export session %q: %w: %s", sessionID, err, stderr.String()) + } + + return nil +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/session_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/session_test.go new file mode 100644 index 0000000000..b130752654 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/session_test.go @@ -0,0 +1,36 @@ +package opencode + +import ( + "os" + "path/filepath" + "testing" + + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestAgentExportStagesNativeSession(t *testing.T) { + binDir := t.TempDir() + opencodePath := filepath.Join(binDir, "opencode") + if err := os.WriteFile(opencodePath, []byte("#!/bin/sh\nprintf '%s' '{\"id\":\"session-1\"}'\n"), 0755); err != nil { + t.Fatalf("write fake opencode: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: agentRun("openai", "gpt-5.4", false, false)} + outputDir := t.TempDir() + result, err := NewAgent(config).Export(nil, toolv1.ExportRequest{SessionID: "session-1", OutputDir: outputDir}) + if err != nil { + t.Fatalf("Export() error = %v", err) + } + if result.SessionSource.Path != outputDir || result.SessionSource.ArchivePath != "opencode" { + t.Fatalf("session source = %#v", result.SessionSource) + } + data, err := os.ReadFile(filepath.Join(outputDir, artifacts.SessionJSONName)) + if err != nil { + t.Fatalf("read staged session: %v", err) + } + if string(data) != `{"id":"session-1"}` { + t.Fatalf("staged session = %q", data) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings.go deleted file mode 100644 index d4ee7a50e0..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings.go +++ /dev/null @@ -1,74 +0,0 @@ -package opencode - -import ( - "strings" - - console "github.com/pluralsh/console/go/client" - proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" -) - -const defaultModel = "gpt-5.6-luna" - -// Provider is an OpenCode provider id (https://models.dev). -type Provider string - -const ( - // ProviderPlural routes requests through the Console AI proxy (/ext/ai/v1). - ProviderPlural Provider = "plural" - ProviderOpenAI Provider = "openai" - - // Common models.dev provider ids for direct (non-proxy) usage. - ProviderAnthropic Provider = "anthropic" - ProviderAmazonBedrock Provider = "amazon-bedrock" - ProviderGoogleVertex Provider = "google-vertex" - ProviderOllama Provider = "ollama" - ProviderAzure Provider = "azure" - ProviderXAI Provider = "xai" - - // ProviderBedrock and ProviderVertex are legacy aliases accepted by the - // Console provider contract in addition to the canonical models.dev IDs. - ProviderBedrock Provider = "bedrock" - ProviderVertex Provider = "vertex" - - // ProviderOpenAICompatible is the fixed provider key for custom OpenAI-compatible endpoints. - ProviderOpenAICompatible Provider = "openai-compatible" -) - -type opencodeSettings struct { - provider Provider - model string - openaiCompatible bool -} - -// resolveSettings selects provider/model wiring for opencode.json and ACP. -// The aiProxy branch is kept separate so proxy behavior stays unchanged when openaiCompatible is added. -func (agent *Agent) resolveSettings(provider, model string, openaiCompatible, proxyEnabled bool) opencodeSettings { - if model == "" { - model = defaultModel - } - - if proxyEnabled { - return opencodeSettings{ - provider: ProviderPlural, - model: proxymodel.ProxyModel(console.AgentRuntimeTypeOpencode, model), - } - } - - if openaiCompatible { - return opencodeSettings{ - provider: ProviderOpenAICompatible, - model: strings.TrimPrefix(model, string(ProviderOpenAICompatible)+"/"), - openaiCompatible: true, - } - } - - selectedProvider := Provider(provider) - if selectedProvider == "" { - selectedProvider = ProviderPlural - } - - return opencodeSettings{ - provider: selectedProvider, - model: strings.TrimPrefix(model, string(selectedProvider)+"/"), - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings_test.go deleted file mode 100644 index b7aa510fb6..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/settings_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package opencode - -import ( - "testing" - - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" -) - -func TestResolveOpenCodeSettings(t *testing.T) { - tests := []struct { - name string - provider string - model string - openaiCompatible bool - proxyEnabled bool - wantProvider Provider - wantModel string - wantOpenAICompat bool - }{ - { - name: "aiProxy forces plural and prefixes bare model", - provider: "anthropic", - model: "gpt-5.4", - proxyEnabled: true, - wantProvider: ProviderPlural, - wantModel: "openai/gpt-5.4", - }, - { - name: "aiProxy leaves provider-prefixed model unchanged", - provider: "openai", - model: "openai/gpt-5.4", - proxyEnabled: true, - wantProvider: ProviderPlural, - wantModel: "openai/gpt-5.4", - }, - { - name: "aiProxy ignores openaiCompatible", - provider: "openai-compatible", - model: "gpt-4", - openaiCompatible: true, - proxyEnabled: true, - wantProvider: ProviderPlural, - wantModel: "openai/gpt-4", - }, - { - name: "openaiCompatible uses fixed provider", - provider: "litellm", - model: "gpt-4", - openaiCompatible: true, - wantProvider: ProviderOpenAICompatible, - wantModel: "gpt-4", - wantOpenAICompat: true, - }, - { - name: "openaiCompatible strips only its provider prefix", - provider: "litellm", - model: "openai-compatible/custom/model", - openaiCompatible: true, - wantProvider: ProviderOpenAICompatible, - wantModel: "custom/model", - wantOpenAICompat: true, - }, - { - name: "openaiCompatible preserves other slash-containing model names", - provider: "litellm", - model: "tenant/custom/model", - openaiCompatible: true, - wantProvider: ProviderOpenAICompatible, - wantModel: "tenant/custom/model", - wantOpenAICompat: true, - }, - { - name: "empty native provider defaults to plural", - wantProvider: ProviderPlural, - wantModel: defaultModel, - }, - { - name: "native provider strips its prefix", - provider: "anthropic", - model: "anthropic/claude-sonnet-4-5", - wantProvider: ProviderAnthropic, - wantModel: "claude-sonnet-4-5", - }, - { - name: "native provider strips exactly one prefix", - provider: "anthropic", - model: "anthropic/anthropic/claude-sonnet-4-5", - wantProvider: ProviderAnthropic, - wantModel: "anthropic/claude-sonnet-4-5", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := NewAgent(toolv1.Config{}).resolveSettings(tt.provider, tt.model, tt.openaiCompatible, tt.proxyEnabled) - if got.provider != tt.wantProvider { - t.Fatalf("provider = %q, want %q", got.provider, tt.wantProvider) - } - if got.model != tt.wantModel { - t.Fatalf("model = %q, want %q", got.model, tt.wantModel) - } - if got.openaiCompatible != tt.wantOpenAICompat { - t.Fatalf("openaiCompatible = %v, want %v", got.openaiCompatible, tt.wantOpenAICompat) - } - }) - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates.go index 3a27f15639..acad36a6fd 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates.go @@ -13,10 +13,6 @@ import ( //go:embed templates/opencode.json.gotmpl var configTemplateText string -const ( - ConfigFileName = "opencode.json" -) - type ConfigTemplateInput struct { ConsoleURL string ConsoleToken string diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates_test.go index 02d8df1a35..5b64cdf51a 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates_test.go @@ -2,13 +2,10 @@ package opencode import ( "encoding/json" - "strings" "testing" console "github.com/pluralsh/console/go/client" - agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/common" ) @@ -160,26 +157,6 @@ func TestConfigTemplate_AllowsSkillLoading(t *testing.T) { } } -func TestEnvUsesHarnessHomeAndConfigHome(t *testing.T) { - workDir := t.TempDir() - config := toolv1.Config{ - WorkDir: workDir, - Run: &agentrunv1.AgentRun{ - Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{OpenCode: &agentrunv1.OpencodeConfig{}}}, - }, - } - - env := strings.Join(NewAgent(config).env(config, "/tmp/opencode.json"), "\n") - for _, want := range []string{ - "XDG_CONFIG_HOME=" + workDir + "/.config", - "XDG_DATA_HOME=" + workDir + "/.local/share", - } { - if !strings.Contains(env, want) { - t.Fatalf("expected env to contain %q, got:\n%s", want, env) - } - } -} - func TestConfigTemplate_DindPermissions(t *testing.T) { t.Run("ANALYZE with dind allows bash for docker", func(t *testing.T) { input := baseInput(console.AgentRunModeAnalyze) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go index ffbd965249..07457b4418 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go @@ -6,21 +6,14 @@ import ( "fmt" "path/filepath" - console "github.com/pluralsh/console/go/client" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" ) -const ( - analysisModeID = "analysis" - reviewModeID = "review" - writeModeID = "autonomous" -) - -// Transport invokes OpenCode through its ACP interface. It owns the -// provider-specific process launch and projects Runtime settings into ACP -// model and mode identifiers. The protocol session itself belongs to acp.Engine. +// Transport invokes OpenCode through its ACP interface. It owns process +// launch, while ACP session projection and protocol handling stay with the +// runtime configuration and provider-neutral engine. type Transport struct { agent *Agent engine *acp.Engine @@ -72,12 +65,19 @@ func (*Transport) Capabilities() toolv1.TransportCapabilities { // Turn launches OpenCode, then delegates ACP session lifecycle and event // mapping to the provider-neutral engine. func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest, sink toolv1.TurnSink) (toolv1.TurnResult, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + settings, err := transport.sessionSettings(request.Settings) if err != nil { return toolv1.TurnResult{SessionID: request.SessionID}, err } - process, err := transport.launch(ctx, request.Options) + process, err := transport.launch(request.Options) if err != nil { return toolv1.TurnResult{SessionID: request.SessionID}, err } @@ -92,16 +92,8 @@ func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest return toolv1.TurnResult{SessionID: result.SessionID}, err } -func (transport *Transport) launch(ctx context.Context, options []exec.Option) (*exec.StdioProcess, error) { - if ctx != nil { - if err := ctx.Err(); err != nil { - return nil, err - } - } - openCode, err := transport.agent.runConfig(transport.agent.config.Run) - if err != nil { - return nil, err - } +func (transport *Transport) launch(options []exec.Option) (*exec.StdioProcess, error) { + openCode := transport.agent.config.Run.Runtime.Config.OpenCode configPath, err := filepath.Abs(transport.agent.configPath(transport.agent.config)) if err != nil { @@ -121,36 +113,3 @@ func (transport *Transport) launch(ctx context.Context, options []exec.Option) ( // the caller's context here. return exec.StartWithStdio(context.Background(), "opencode", launchOptions...) } - -func (transport *Transport) sessionSettings(settings toolv1.Settings) (acp.SessionSettings, error) { - openCode, err := transport.agent.runConfig(transport.agent.config.Run) - if err != nil { - return acp.SessionSettings{}, err - } - - resolved := transport.agent.resolveSettings(openCode.Provider, openCode.Model, openCode.OpenAICompatible, transport.agent.config.Run.IsProxyEnabled()) - model := settings.Model.Name - if model == "" { - model = resolved.model - } - - mode, err := transport.modeID(settings.Mode) - if err != nil { - return acp.SessionSettings{}, err - } - - return acp.SessionSettings{ModeID: mode, ModelID: string(resolved.provider) + "/" + model}, nil -} - -func (*Transport) modeID(mode console.AgentRunMode) (string, error) { - switch mode { - case console.AgentRunModeAnalyze: - return analysisModeID, nil - case console.AgentRunModeReview: - return reviewModeID, nil - case console.AgentRunModeWrite: - return writeModeID, nil - default: - return "", fmt.Errorf("unsupported opencode ACP mode %q", mode) - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go index 264c096464..8a4a67ba52 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go @@ -2,13 +2,16 @@ package opencode import ( "context" + "errors" "io" "os" "path/filepath" + "strings" "sync/atomic" "testing" "time" + console "github.com/pluralsh/console/go/client" agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" @@ -36,7 +39,7 @@ func TestTransportLaunchPreservesLifecycleHooks(t *testing.T) { } var preStarts, postStarts atomic.Int32 - process, err := transport.launch(context.Background(), []exec.Option{ + process, err := transport.launch([]exec.Option{ exec.WithHook(stackv1.LifecyclePreStart, func() error { preStarts.Add(1) return nil @@ -61,3 +64,39 @@ func TestTransportLaunchPreservesLifecycleHooks(t *testing.T) { t.Fatalf("post-start hooks = %d, want 1", got) } } + +func TestTransportTurnReturnsPreCancelledContextBeforeLaunch(t *testing.T) { + config := toolv1.Config{ + WorkDir: t.TempDir(), + RepositoryDir: t.TempDir(), + Run: &agentrunv1.AgentRun{Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{OpenCode: &agentrunv1.OpencodeConfig{Timeout: time.Minute}}}}, + } + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = transport.Turn(ctx, toolv1.TurnRequest{}, nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Turn() error = %v, want context canceled", err) + } +} + +func TestTransportTurnAcceptsNilContext(t *testing.T) { + config := toolv1.Config{ + WorkDir: t.TempDir(), + RepositoryDir: t.TempDir(), + Run: &agentrunv1.AgentRun{Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{OpenCode: &agentrunv1.OpencodeConfig{Timeout: time.Minute}}}}, + } + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatal(err) + } + + _, err = transport.Turn(nil, toolv1.TurnRequest{Settings: toolv1.Settings{Mode: console.AgentRunMode("unsupported")}}, nil) + if err == nil || !strings.Contains(err.Error(), "unsupported opencode ACP mode") { + t.Fatalf("Turn() error = %v, want unsupported mode", err) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go index 79af9d6d37..5ca3f9adff 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go @@ -1,6 +1,7 @@ package tool import ( + "errors" "fmt" "k8s.io/klog/v2" @@ -18,6 +19,10 @@ import ( // New creates a specific tool implementation structure based on the provided // console.AgentRuntimeType func New(runtimeType console.AgentRuntimeType, config v1.Config) (v1.Tool, error) { + if config.Run == nil { + return nil, errors.New("agent run is not set") + } + klog.V(log.LogLevelInfo).InfoS("creating tool", "runtimeType", runtimeType, "proxy", config.Run.IsProxyEnabled()) switch runtimeType { @@ -33,7 +38,12 @@ func New(runtimeType console.AgentRuntimeType, config v1.Config) (v1.Tool, error case console.AgentRuntimeTypeGemini: return gemini.New(config), nil case console.AgentRuntimeTypeCodex: - return codex.New(config), nil + agent := codex.NewAgent(config) + transport, err := codex.NewTransport(agent) + if err != nil { + return nil, err + } + return v1.NewRuntime(config, agent, transport) case console.AgentRuntimeTypePi: return pi.New(config), nil diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go index 74119d0243..43de6298f6 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go @@ -29,3 +29,30 @@ func TestNewComposesOpenCodeRuntime(t *testing.T) { t.Fatalf("OpenCode factory returned %T, want *v1.Runtime", created) } } + +func TestNewComposesCodexRuntime(t *testing.T) { + config := toolv1.Config{ + WorkDir: t.TempDir(), + RepositoryDir: t.TempDir(), + Run: &agentrunv1.AgentRun{ + Mode: console.AgentRunModeWrite, + Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{ + Codex: &agentrunv1.CodexConfig{Model: "gpt-5.4", Timeout: time.Minute}, + }}, + }, + } + + created, err := New(console.AgentRuntimeTypeCodex, config) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if _, ok := created.(*toolv1.Runtime); !ok { + t.Fatalf("Codex factory returned %T, want *v1.Runtime", created) + } +} + +func TestNewRejectsMissingAgentRun(t *testing.T) { + if _, err := New(console.AgentRuntimeTypeClaude, toolv1.Config{}); err == nil { + t.Fatal("New() error = nil, want missing agent run error") + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime.go b/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime.go index a10bb4d5b7..88f44b04dc 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime.go @@ -206,9 +206,6 @@ func (runtime *Runtime) configure(ctx context.Context, request ConfigureRequest) } func (runtime *Runtime) turn(ctx context.Context, request TurnRequest) error { - if ctx == nil { - ctx = context.Background() - } if request.Kind == "" { return errors.New("turn kind is not set") } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_types.go b/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_types.go index ad822c04bd..c53e2707a8 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_types.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_types.go @@ -41,8 +41,14 @@ const ( // turn. Provider is nil when a provider-native configuration has no exact // Console AI provider equivalent. type ModelSelection struct { + // Provider is the Console AI provider selected for the turn. Provider *console.AiProvider - Name string + + // Name is the name of the model selected for the turn. + Name string + + // Reasoning is the provider's reasoning effort, when configurable. + Reasoning string } // Settings are the provider-neutral settings resolved by an Agent. @@ -108,7 +114,7 @@ type ExportResult struct { SessionSource artifacts.SessionSource } -// TurnSink receives provider-neutral events from a Transport. Every callback +// TurnSink receives provider-neutral events from Transport. Every callback // is optional; Runtime supplies a nil-safe implementation when it starts a // turn. type TurnSink interface { @@ -130,7 +136,7 @@ type TurnRequest struct { Options []exec.Option } -// TurnResult contains the latest session state observed by a transport. A +// TurnResult contains the latest session state observed by transport. A // transport may return both a result and an error; Runtime retains a nonempty // result session ID before routing the error. type TurnResult struct { @@ -138,7 +144,7 @@ type TurnResult struct { } // Agent owns provider-specific settings, configuration, and session export. -// FileSystemConfiguration is embedded so every agent exposes the same +// FileSystemConfiguration is embedded, so every agent exposes the same // preparation seam. type Agent interface { FileSystemConfiguration From 28a5873331389da050e9196b048c1aa85eb727e4 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Mon, 7 Sep 2026 12:37:51 +0200 Subject: [PATCH 14/46] refactor(tool): replace `Profile` with `Settings` in Codex configuration and tests - Updated Codex configuration to use `Settings` instead of `Profile` for better semantic alignment. - Replaced `Profile`-based logic with `Settings` in tests and runtime handling (`agent_config_test.go`, `templates_test.go`, and others). - Removed outdated profiles: `analysisProfile`, `autonomousProfile`, and `reviewProfile` in favor of streamlined ACP settings. - Enhanced diagnostic handling with detailed process STDERR tailing in `exec_stdio` package. - Deprecated `chat` wire API, enforced `responses` wire API throughout Codex logic (`agent_config.go`, `runtime_config.go`, etc.). - Improved runtime failure handling by introducing structured error propagation in `session.go`. - Adjusted template rendering to align with the updated Codex `settings` structure (`config.toml.gotmpl`). --- .../agent-harness/codex.Dockerfile | 5 +- .../pkg/agentrun-harness/tool/acp/session.go | 33 ++-- .../agentrun-harness/tool/acp/session_test.go | 41 ++++ .../pkg/agentrun-harness/tool/codex/agent.go | 5 +- .../tool/codex/agent_config.go | 35 +--- .../tool/codex/agent_config_test.go | 6 +- .../tool/codex/runtime_config.go | 23 --- .../tool/codex/runtime_config_test.go | 27 +-- .../tool/codex/templates/config.toml.gotmpl | 73 +++---- .../tool/codex/templates_test.go | 66 +++---- .../pkg/agentrun-harness/tool/codex/types.go | 7 +- .../pkg/harness/exec/exec_stdio.go | 180 +++++++++++------- .../pkg/harness/exec/exec_stdio_test.go | 21 ++ .../pkg/harness/exec/exec_stdio_types.go | 47 +++-- 14 files changed, 305 insertions(+), 264 deletions(-) diff --git a/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile index 6a15a2fb68..aed1b8a154 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile @@ -20,7 +20,6 @@ RUN codex-acp --version # Stage 2: Copy the Codex ACP adapter into agent-harness base FROM $AGENT_HARNESS_BASE_IMAGE AS final -COPY --from=node /usr/local/bin/codex-acp /usr/local/bin/codex-acp COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules # Copy the Node.js runtime needed by the adapter. @@ -28,7 +27,9 @@ COPY --from=node /usr/local/bin/node /usr/local/bin/node # Ensure proper ownership for nonroot user USER root -RUN chown -R 65532:65532 /usr/local/bin/codex-acp /usr/local/lib/node_modules /usr/local/bin/node +# COPY dereferences the npm launcher symlink, so recreate it in the final image. +RUN ln -s ../lib/node_modules/@agentclientprotocol/codex-acp/dist/index.js /usr/local/bin/codex-acp && \ + chown -R 65532:65532 /usr/local/bin/codex-acp /usr/local/lib/node_modules /usr/local/bin/node # Switch back to nonroot user USER 65532:65532 diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go index 69c5b79030..d7f66507b5 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go @@ -66,7 +66,7 @@ func (attempt *sessionAttempt) run(prompt string) error { return err } if err := attempt.stop(false); err != nil { - return fmt.Errorf("stop acp process: %w", err) + return attempt.processFailure(fmt.Errorf("stop acp process: %w", err), nil) } return attempt.promptResult(response.StopReason) } @@ -159,17 +159,6 @@ func (attempt *sessionAttempt) finishTurn(usage *acpsdk.Usage) { attempt.turn.emitAssistant(usage) } -func (attempt *sessionAttempt) drainStderr() { - if attempt.process.Stderr == nil { - return - } - go func() { - if _, err := io.Copy(io.Discard, attempt.process.Stderr); err != nil && !errors.Is(err, io.ErrClosedPipe) { - klog.V(log.LogLevelDebug).InfoS("ACP stderr drain ended", "error", err) - } - }() -} - func (attempt *sessionAttempt) close() { // The process is stopped explicitly during the run. This final guard // handles setup failures and keeps test launchers from leaking children. @@ -178,17 +167,30 @@ func (attempt *sessionAttempt) close() { func (attempt *sessionAttempt) promptFailure(err error) error { cancelled := attempt.cancelled() - _ = attempt.stop(cancelled) + stopErr := attempt.stop(cancelled) if cancelled { return context.Cause(attempt.ctx) } // Prompt has crossed the dispatch boundary. Its result is never replayed // because the agent may have received it. - return fmt.Errorf("acp session/prompt: %w", err) + return attempt.processFailure(fmt.Errorf("acp session/prompt: %w", err), stopErr) } func (attempt *sessionAttempt) fail(err error, cancel bool) error { - _ = attempt.stop(cancel) + stopErr := attempt.stop(cancel) + if cancel { + return err + } + return attempt.processFailure(err, stopErr) +} + +func (attempt *sessionAttempt) processFailure(err, stopErr error) error { + if stopErr != nil { + err = fmt.Errorf("%w: acp process exited: %w", err, stopErr) + } + if stderr := attempt.process.StderrTail(); stderr != "" { + return fmt.Errorf("%w: acp stderr: %s", err, stderr) + } return err } @@ -281,6 +283,5 @@ func newSessionAttempt(engine *Engine, ctx context.Context, process *exec.StdioP sessionID: request.SessionID, } attempt.connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) - attempt.drainStderr() return attempt } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go index c1cfa38c6f..a0fcce4e38 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go @@ -2,12 +2,53 @@ package acp import ( "context" + "errors" + "fmt" + "os" + stdexec "os/exec" "strings" "testing" acpsdk "github.com/coder/acp-go-sdk" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" ) +func TestEngineTurnReportsInitializeProcessFailure(t *testing.T) { + process, err := exec.StartWithStdio(context.Background(), os.Args[0], + exec.WithArgs([]string{"-test.run=TestInitializeFailureHelperProcess", "--"}), + exec.WithEnv([]string{"ACP_INITIALIZE_FAILURE_HELPER=1"}), + ) + if err != nil { + t.Fatalf("start helper: %v", err) + } + + _, err = NewEngine(Config{}).Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "prompt"}, &testSink{}) + if err == nil { + t.Fatal("initialize failure succeeded") + } + var exitErr *stdexec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 42 { + t.Fatalf("initialize failure did not preserve child exit status: %v", err) + } + message := err.Error() + for _, expected := range []string{"peer disconnected before response", "exit status 42", "startup failure"} { + if !strings.Contains(message, expected) { + t.Errorf("initialize failure %q does not contain %q", message, expected) + } + } + if strings.Contains(message, "discarded-prefix") { + t.Errorf("initialize failure did not bound stderr tail: %q", message) + } +} + +func TestInitializeFailureHelperProcess(t *testing.T) { + if os.Getenv("ACP_INITIALIZE_FAILURE_HELPER") != "1" { + return + } + _, _ = fmt.Fprint(os.Stderr, "discarded-prefix"+strings.Repeat("x", 9*1024)+"\nstartup failure\n") + os.Exit(42) +} + func TestEngineTurnDeliversUpdatesSentBeforeSessionResponse(t *testing.T) { state := newTestState() state.newSessionUpdates = []acpsdk.SessionNotification{ diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go index 2e70b27de1..74d4d7379f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go @@ -46,7 +46,7 @@ func (*Agent) Type() console.AgentRuntimeType { return console.AgentRuntimeTypeCodex } -// Capabilities advertises the modes supported by Codex profiles. +// Capabilities advertises the modes supported by Codex. func (*Agent) Capabilities() toolv1.AgentCapabilities { return toolv1.AgentCapabilities{Modes: []console.AgentRunMode{ console.AgentRunModeAnalyze, @@ -184,6 +184,9 @@ func (*Agent) runConfig(run *agentrunv1.AgentRun) (*agentrunv1.CodexConfig, erro if run.Runtime == nil || run.Runtime.Config == nil || run.Runtime.Config.Codex == nil { return nil, fmt.Errorf("codex runtime configuration is not set") } + if console.OpenAiMethod(run.Runtime.Config.Codex.Method) == console.OpenAiMethodChat { + return nil, fmt.Errorf("codex does not support CHAT wire API; use RESPONSES or AUTO") + } return run.Runtime.Config.Codex, nil } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config.go index 0965f5340a..d6749ba4a9 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config.go @@ -1,9 +1,7 @@ package codex import ( - "fmt" "os" - "path/filepath" "k8s.io/klog/v2" @@ -33,23 +31,14 @@ const ( approvalPolicyNever = "never" ) -// Built-in and user-configured MCP servers use these Codex transport and trust -// labels when native configuration is generated. -const ( - mcpHTTPTransport = "http" - mcpStdioTransport = "stdio" - trustPolicyAlways = "always" -) - func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error { external, err := mcpcfg.Load() if err != nil { return err } - profile, ok := agent.profileForMode(config.Run.Mode) - if !ok { - return fmt.Errorf("unsupported agent run mode %q for codex", config.Run.Mode) + if _, err := agent.resolveACPMode(config.Run.Mode, ""); err != nil { + return err } modelInstructionsFile, err := agent.systemPromptPath(config) if err != nil { @@ -69,8 +58,7 @@ func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error templateInput := &ConfigTemplateInput{ RepositoryDir: config.RepositoryDir, - Profile: configTemplateProfile{ - Name: profile, + Settings: configTemplateSettings{ Model: model, ModelProvider: provider, SandboxMode: sandboxModeHarness, @@ -85,7 +73,7 @@ func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error MCPServers: agent.nativeMCPServers(external), } - configPath, err := agent.writeConfig(filepath.Join(agent.codexHome(config)), templateInput) + configPath, err := agent.writeConfig(agent.codexHome(config), templateInput) if err != nil { return err } @@ -96,16 +84,12 @@ func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error func (agent *Agent) nativeMCPServers(external []mcpcfg.Server) []configTemplateMCP { result := []configTemplateMCP{{ - Name: pluralProvider, - Type: mcpHTTPTransport, - URL: common.AgentMCPServerURL, - TrustPolicy: trustPolicyAlways, + Name: pluralProvider, + URL: common.AgentMCPServerURL, }, { - Name: common.CodebaseMemoryMCPServerName, - Type: mcpStdioTransport, - Command: common.CodebaseMemoryMCPCommand, - Env: agent.templateKeyValues(map[string]string{common.CodebaseMemoryCacheEnv: common.CodebaseMemoryCacheDir}), - TrustPolicy: trustPolicyAlways, + Name: common.CodebaseMemoryMCPServerName, + Command: common.CodebaseMemoryMCPCommand, + Env: agent.templateKeyValues(map[string]string{common.CodebaseMemoryCacheEnv: common.CodebaseMemoryCacheDir}), }} indices := map[string]int{ pluralProvider: 0, @@ -117,7 +101,6 @@ func (agent *Agent) nativeMCPServers(external []mcpcfg.Server) []configTemplateM Name: server.Name, URL: server.URL, HTTPHeaders: agent.templateKeyValues(server.Headers), - TrustPolicy: trustPolicyAlways, } if server.HasAllowedTools() { input.EnabledTools = server.AllowedTools diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config_test.go index 52937ffa04..df9fea2295 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config_test.go @@ -77,7 +77,7 @@ func TestAgentPrepareAndConfigurePhases(t *testing.T) { func TestResolveProviderSettingsPreservesProxyEndpointAndWirePolicy(t *testing.T) { endpoint := "https://custom.example/v1" - method := console.OpenAiMethodChat + method := console.OpenAiMethodResponses run := codexTestRun(console.AgentRunModeWrite, "gpt-5.4", true) run.Runtime.Config.Codex.Endpoint = &endpoint run.Runtime.Config.Codex.Method = string(method) @@ -85,12 +85,12 @@ func TestResolveProviderSettingsPreservesProxyEndpointAndWirePolicy(t *testing.T agent := NewAgent(config) agent.consoleURL = "https://console.example" provider, baseURL, _, wireAPI := agent.resolveProviderSettings(config) - if provider != pluralProvider || baseURL != "https://console.example/ext/ai/v1" || wireAPI != chatWireAPI { + if provider != pluralProvider || baseURL != "https://console.example/ext/ai/v1" || wireAPI != responsesWireAPI { t.Fatalf("proxy provider settings = %q, %q, %q", provider, baseURL, wireAPI) } run.Runtime.AiProxy = false provider, baseURL, _, wireAPI = agent.resolveProviderSettings(config) - if provider != customProvider || baseURL != endpoint || wireAPI != chatWireAPI { + if provider != customProvider || baseURL != endpoint || wireAPI != responsesWireAPI { t.Fatalf("custom provider settings = %q, %q, %q", provider, baseURL, wireAPI) } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config.go index 1820c39924..f73a8f72a4 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config.go @@ -17,13 +17,6 @@ const ( defaultReasoning = "medium" ) -// Console run modes map to these profile names in Codex's native config. -const ( - analysisProfile = "analysis" - autonomousProfile = "autonomous" - reviewProfile = "review" -) - // These provider keys, endpoint, and wire labels are serialized into Codex's // model provider configuration for direct, custom, and proxied requests. const ( @@ -31,7 +24,6 @@ const ( customProvider = "custom" openAIProvider = "openai-api" openAIBaseURL = "https://api.openai.com/v1" - chatWireAPI = "chat" responsesWireAPI = "responses" ) @@ -133,23 +125,8 @@ func (agent *Agent) resolveProviderSettings(config toolv1.Config) (string, strin return openAIProvider, openAIBaseURL, openAIAPIKeyEnv, wireAPI } -func (*Agent) profileForMode(mode console.AgentRunMode) (string, bool) { - switch mode { - case console.AgentRunModeAnalyze: - return analysisProfile, true - case console.AgentRunModeWrite: - return autonomousProfile, true - case console.AgentRunModeReview: - return reviewProfile, true - default: - return "", false - } -} - func (*Agent) wireAPI(method string) string { switch console.OpenAiMethod(method) { - case console.OpenAiMethodChat: - return chatWireAPI case console.OpenAiMethodResponses: return responsesWireAPI default: diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config_test.go index 36f52ca65c..c225d3bc2d 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config_test.go @@ -57,19 +57,12 @@ func TestResolveSettingsPreservesExplicitModel(t *testing.T) { } } -func TestCodexProfilesAndACPSettings(t *testing.T) { +func TestCodexACPSettings(t *testing.T) { agent := NewAgent(toolv1.Config{Run: codexTestRun(console.AgentRunModeWrite, "gpt-5.4", false)}) - for _, test := range []struct { - mode console.AgentRunMode - profile string - }{ - {console.AgentRunModeAnalyze, analysisProfile}, - {console.AgentRunModeWrite, autonomousProfile}, - {console.AgentRunModeReview, reviewProfile}, - } { - profile, ok := agent.profileForMode(test.mode) - if !ok || profile != test.profile { - t.Fatalf("profileForMode(%s) = %q, %v", test.mode, profile, ok) + for _, mode := range []console.AgentRunMode{console.AgentRunModeAnalyze, console.AgentRunModeWrite, console.AgentRunModeReview} { + modeID, err := agent.resolveACPMode(mode, "") + if err != nil || modeID != acpModeID { + t.Fatalf("resolveACPMode(%q) = %q, %v", mode, modeID, err) } } model, reasoning, modeID, err := agent.resolveACPSettings(toolv1.Settings{Mode: console.AgentRunModeReview, Model: toolv1.ModelSelection{Name: "gpt-5.4"}}) @@ -93,7 +86,6 @@ func TestCodexWireAPI(t *testing.T) { method string want string }{ - {method: string(console.OpenAiMethodChat), want: chatWireAPI}, {method: string(console.OpenAiMethodResponses), want: responsesWireAPI}, {method: string(console.OpenAiMethodAuto), want: ""}, {method: "", want: ""}, @@ -103,3 +95,12 @@ func TestCodexWireAPI(t *testing.T) { } } } + +func TestRunConfigRejectsChatWireAPI(t *testing.T) { + run := codexTestRun(console.AgentRunModeWrite, "gpt-5.4", false) + run.Runtime.Config.Codex.Method = string(console.OpenAiMethodChat) + _, err := NewAgent(toolv1.Config{Run: run}).ResolveSettings(run) + if err == nil || err.Error() != "codex does not support CHAT wire API; use RESPONSES or AUTO" { + t.Fatalf("ResolveSettings() error = %v", err) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates/config.toml.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates/config.toml.gotmpl index 4e0082b6b2..bad0c1364f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates/config.toml.gotmpl +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates/config.toml.gotmpl @@ -1,5 +1,35 @@ +model = {{ quote .Settings.Model }} +{{ if .Settings.ModelProvider }} +model_provider = {{ quote .Settings.ModelProvider }} +{{ end }} +sandbox_mode = {{ quote .Settings.SandboxMode }} +approval_policy = {{ quote .Settings.ApprovalPolicy }} +model_reasoning_effort = {{ quote .Settings.ModelReasoningEffort }} +{{ if .Settings.ModelInstructionsFile }} +model_instructions_file = {{ quote .Settings.ModelInstructionsFile }} +{{ end }} + +{{ if .Settings.ShellEnvironmentPolicy }} +[shell_environment_policy] +{{ if .Settings.ShellEnvironmentPolicy.IncludeOnly }} +include_only = [{{ range $index, $value := .Settings.ShellEnvironmentPolicy.IncludeOnly }}{{ if $index }}, {{ end }}{{ quote $value }}{{ end }}] +{{ end }} + +{{ if .Settings.ShellEnvironmentPolicy.Set }} +[shell_environment_policy.set] +{{ range .Settings.ShellEnvironmentPolicy.Set }} +{{ quote .Key }} = {{ quote .Value }} +{{ end }} +{{ end }} +{{ end }} + [features] -skills = true +{{ if .Settings.EnableWebSearch }} +web_search_request = true +{{ end }} +{{ if .Settings.EnableShellCache }} +shell_snapshot = true +{{ end }} [projects.{{ quote .RepositoryDir }}] trust_level = "trusted" @@ -20,45 +50,8 @@ wire_api = {{ quote .WireAPI }} {{ end }} {{ end }} -[profiles.{{ quote .Profile.Name }}] -model = {{ quote .Profile.Model }} -{{ if .Profile.ModelProvider }} -model_provider = {{ quote .Profile.ModelProvider }} -{{ end }} -sandbox_mode = {{ quote .Profile.SandboxMode }} -approval_policy = {{ quote .Profile.ApprovalPolicy }} -model_reasoning_effort = {{ quote .Profile.ModelReasoningEffort }} -{{ if .Profile.ModelInstructionsFile }} -model_instructions_file = {{ quote .Profile.ModelInstructionsFile }} -{{ end }} - -{{ if .Profile.ShellEnvironmentPolicy }} -[profiles.{{ quote .Profile.Name }}.shell_environment_policy] -{{ if .Profile.ShellEnvironmentPolicy.IncludeOnly }} -include_only = [{{ range $index, $value := .Profile.ShellEnvironmentPolicy.IncludeOnly }}{{ if $index }}, {{ end }}{{ quote $value }}{{ end }}] -{{ end }} - -{{ if .Profile.ShellEnvironmentPolicy.Set }} -[profiles.{{ quote .Profile.Name }}.shell_environment_policy.set] -{{ range .Profile.ShellEnvironmentPolicy.Set }} -{{ quote .Key }} = {{ quote .Value }} -{{ end }} -{{ end }} -{{ end }} - -[profiles.{{ quote .Profile.Name }}.features] -{{ if .Profile.EnableWebSearch }} -web_search_request = true -{{ end }} -{{ if .Profile.EnableShellCache }} -shell_snapshot = true -{{ end }} - {{ range .MCPServers }} [mcp_servers.{{ quote .Name }}] -{{ if .Type }} -type = {{ quote .Type }} -{{ end }} {{ if .URL }} url = {{ quote .URL }} {{ end }} @@ -74,10 +67,6 @@ enabled_tools = [{{ range $index, $value := .EnabledTools }}{{ if $index }}, {{ {{ if .DisabledTools }} disabled_tools = [{{ range $index, $value := .DisabledTools }}{{ if $index }}, {{ end }}{{ quote $value }}{{ end }}] {{ end }} -{{ if .TrustPolicy }} -trust_policy = {{ quote .TrustPolicy }} -{{ end }} - {{ if .Env }} [mcp_servers.{{ quote .Name }}.env] {{ range .Env }} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates_test.go index b5e73187ca..d684b39abf 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates_test.go @@ -11,11 +11,10 @@ import ( "github.com/pluralsh/console/go/deployment-operator/pkg/common" ) -func TestConfigTemplateProxyChat(t *testing.T) { +func TestConfigTemplateProxyResponses(t *testing.T) { doc := renderConfigTemplate(t, ConfigTemplateInput{ RepositoryDir: "/repo", - Profile: configTemplateProfile{ - Name: autonomousProfile, + Settings: configTemplateSettings{ Model: "openai/gpt-5.4", ModelProvider: pluralProvider, SandboxMode: sandboxModeHarness, @@ -28,30 +27,25 @@ func TestConfigTemplateProxyChat(t *testing.T) { Name: pluralProvider, BaseURL: "https://console.plural.sh/ext/ai/v1", EnvKey: consoleTokenEnv, - WireAPI: chatWireAPI, + WireAPI: responsesWireAPI, }}, }) features := tableValue(t, doc, "features") - if features["skills"] != true { - t.Fatalf("features = %#v, expected skills", features) - } projects := tableValue(t, doc, "projects") if tableValue(t, projects, "/repo")["trust_level"] != "trusted" { t.Fatalf("projects = %#v, expected trusted repository", projects) } - profile := tableValue(t, tableValue(t, doc, "profiles"), autonomousProfile) - if profile["model"] != "openai/gpt-5.4" || profile["model_provider"] != pluralProvider || - profile["sandbox_mode"] != sandboxModeHarness || profile["approval_policy"] != approvalPolicyNever || - profile["model_reasoning_effort"] != defaultReasoning { - t.Fatalf("profile = %#v", profile) + if doc["model"] != "openai/gpt-5.4" || doc["model_provider"] != pluralProvider || + doc["sandbox_mode"] != sandboxModeHarness || doc["approval_policy"] != approvalPolicyNever || + doc["model_reasoning_effort"] != defaultReasoning { + t.Fatalf("settings = %#v", doc) } - profileFeatures := tableValue(t, profile, "features") - if profileFeatures["web_search_request"] != true || profileFeatures["shell_snapshot"] != true { - t.Fatalf("profile features = %#v", profileFeatures) + if features["web_search_request"] != true || features["shell_snapshot"] != true { + t.Fatalf("features = %#v", features) } provider := tableValue(t, tableValue(t, doc, "model_providers"), pluralProvider) - if provider["base_url"] != "https://console.plural.sh/ext/ai/v1" || provider["env_key"] != consoleTokenEnv || provider["wire_api"] != chatWireAPI { + if provider["base_url"] != "https://console.plural.sh/ext/ai/v1" || provider["env_key"] != consoleTokenEnv || provider["wire_api"] != responsesWireAPI { t.Fatalf("provider = %#v", provider) } } @@ -59,8 +53,7 @@ func TestConfigTemplateProxyChat(t *testing.T) { func TestConfigTemplateCustomEndpointAndAutoOmission(t *testing.T) { custom := renderConfigTemplate(t, ConfigTemplateInput{ RepositoryDir: "/repo", - Profile: configTemplateProfile{ - Name: reviewProfile, + Settings: configTemplateSettings{ Model: "gpt-5.4", ModelProvider: customProvider, SandboxMode: sandboxModeHarness, @@ -81,23 +74,21 @@ func TestConfigTemplateCustomEndpointAndAutoOmission(t *testing.T) { auto := renderConfigTemplate(t, ConfigTemplateInput{ RepositoryDir: "/repo", - Profile: configTemplateProfile{ - Name: analysisProfile, + Settings: configTemplateSettings{ Model: "gpt-5.4", SandboxMode: sandboxModeHarness, ApprovalPolicy: approvalPolicyNever, ModelReasoningEffort: defaultReasoning, }, }) - profile := tableValue(t, tableValue(t, auto, "profiles"), analysisProfile) - if _, ok := profile["model_provider"]; ok { - t.Fatalf("auto profile unexpectedly selected provider: %#v", profile) + if _, ok := auto["model_provider"]; ok { + t.Fatalf("auto settings unexpectedly selected provider: %#v", auto) } if _, ok := auto["model_providers"]; ok { t.Fatalf("auto config unexpectedly emitted providers: %#v", auto) } - if _, ok := profile["shell_environment_policy"]; ok { - t.Fatalf("empty shell policy unexpectedly emitted: %#v", profile) + if _, ok := auto["shell_environment_policy"]; ok { + t.Fatalf("empty shell policy unexpectedly emitted: %#v", auto) } } @@ -107,8 +98,7 @@ func TestConfigTemplateDindShellEnvironment(t *testing.T) { shell := agent.shellEnvironmentPolicy(true) doc := renderConfigTemplate(t, ConfigTemplateInput{ RepositoryDir: "/repo", - Profile: configTemplateProfile{ - Name: autonomousProfile, + Settings: configTemplateSettings{ Model: "gpt-5.4", SandboxMode: sandboxModeHarness, ApprovalPolicy: approvalPolicyNever, @@ -116,7 +106,7 @@ func TestConfigTemplateDindShellEnvironment(t *testing.T) { ShellEnvironmentPolicy: shell, }, }) - policy := tableValue(t, tableValue(t, tableValue(t, doc, "profiles"), autonomousProfile), "shell_environment_policy") + policy := tableValue(t, doc, "shell_environment_policy") includeOnly, ok := policy["include_only"].([]any) if !ok || len(includeOnly) == 0 { t.Fatalf("shell policy include_only = %#v", policy["include_only"]) @@ -132,7 +122,6 @@ func TestConfigTemplateBuiltInAndExternalMCP(t *testing.T) { servers := agent.nativeMCPServers(nil) servers = append(servers, configTemplateMCP{ Name: "linear", - Type: "http", URL: "https://mcp.linear.app/mcp", Args: []string{"--transport", "http"}, Env: []configTemplateKeyValue{{Key: "LINEAR_TEAM", Value: "console"}}, @@ -143,20 +132,19 @@ func TestConfigTemplateBuiltInAndExternalMCP(t *testing.T) { }}, EnabledTools: []string{"list_issues"}, DisabledTools: []string{"delete_issue"}, - TrustPolicy: trustPolicyAlways, }) doc := renderConfigTemplate(t, ConfigTemplateInput{ RepositoryDir: "/repo", - Profile: configTemplateProfile{Name: autonomousProfile, Model: "gpt-5.4"}, + Settings: configTemplateSettings{Model: "gpt-5.4"}, MCPServers: servers, }) mcps := tableValue(t, doc, "mcp_servers") plural := tableValue(t, mcps, pluralProvider) - if plural["type"] != mcpHTTPTransport || plural["url"] != common.AgentMCPServerURL || plural["trust_policy"] != trustPolicyAlways { + if plural["url"] != common.AgentMCPServerURL { t.Fatalf("plural MCP = %#v", plural) } codebase := tableValue(t, mcps, common.CodebaseMemoryMCPServerName) - if codebase["type"] != mcpStdioTransport || codebase["command"] != common.CodebaseMemoryMCPCommand { + if codebase["command"] != common.CodebaseMemoryMCPCommand { t.Fatalf("codebase MCP = %#v", codebase) } env := tableValue(t, codebase, "env") @@ -164,7 +152,7 @@ func TestConfigTemplateBuiltInAndExternalMCP(t *testing.T) { t.Fatalf("codebase MCP env = %#v", env) } linear := tableValue(t, mcps, "linear") - if linear["url"] != "https://mcp.linear.app/mcp" || linear["trust_policy"] != trustPolicyAlways { + if linear["url"] != "https://mcp.linear.app/mcp" { t.Fatalf("linear MCP = %#v", linear) } header := tableValue(t, linear, "http_headers") @@ -188,14 +176,12 @@ func TestConfigTemplateBuiltInAndExternalMCP(t *testing.T) { func TestConfigTemplateEscapesDynamicStrings(t *testing.T) { repository := "C:\\repo\\it's\nquoted" - profileName := "review\"profile" model := "vendor\\model\n\"name\a\v" key := "X-Header\\name" value := "line 1\nline 2 with \"quotes\"" doc := renderConfigTemplate(t, ConfigTemplateInput{ RepositoryDir: repository, - Profile: configTemplateProfile{ - Name: profileName, + Settings: configTemplateSettings{ Model: model, SandboxMode: sandboxModeHarness, ApprovalPolicy: approvalPolicyNever, @@ -203,16 +189,14 @@ func TestConfigTemplateEscapesDynamicStrings(t *testing.T) { }, MCPServers: []configTemplateMCP{{ Name: "mcp\\\"server", - Type: "http", HTTPHeaders: []configTemplateKeyValue{{Key: key, Value: value}}, }}, }) if tableValue(t, tableValue(t, doc, "projects"), repository)["trust_level"] != "trusted" { t.Fatalf("escaped repository key missing: %#v", doc["projects"]) } - profile := tableValue(t, tableValue(t, doc, "profiles"), profileName) - if profile["model"] != model { - t.Fatalf("escaped model = %#v, want %q", profile["model"], model) + if doc["model"] != model { + t.Fatalf("escaped model = %#v, want %q", doc["model"], model) } header := tableValue(t, tableValue(t, tableValue(t, doc, "mcp_servers"), "mcp\\\"server"), "http_headers") if header[key] != value { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/types.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/types.go index 70a994c46a..86d61c326d 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/types.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/types.go @@ -5,13 +5,12 @@ package codex // still representing Codex's map-shaped TOML sections. type ConfigTemplateInput struct { RepositoryDir string - Profile configTemplateProfile + Settings configTemplateSettings Providers []configTemplateProvider MCPServers []configTemplateMCP } -type configTemplateProfile struct { - Name string +type configTemplateSettings struct { Model string ModelProvider string SandboxMode string @@ -37,7 +36,6 @@ type configTemplateProvider struct { type configTemplateMCP struct { Name string - Type string URL string Command string Args []string @@ -47,7 +45,6 @@ type configTemplateMCP struct { EnvHTTPHeaders []configTemplateKeyValue EnabledTools []string DisabledTools []string - TrustPolicy string } type configTemplateKeyValue struct { diff --git a/go/deployment-operator/pkg/harness/exec/exec_stdio.go b/go/deployment-operator/pkg/harness/exec/exec_stdio.go index bbc61c98a7..0f521a1bcf 100644 --- a/go/deployment-operator/pkg/harness/exec/exec_stdio.go +++ b/go/deployment-operator/pkg/harness/exec/exec_stdio.go @@ -7,7 +7,9 @@ import ( "io" "os" "os/exec" + "strings" "sync" + "time" "k8s.io/klog/v2" @@ -15,9 +17,11 @@ import ( "github.com/pluralsh/console/go/deployment-operator/pkg/log" ) -// StartWithStdio starts an executable without taking ownership of its output -// streams. This is used by protocols that carry their own framed messages over -// stdin/stdout. Callers must drain Stdout and Stderr, then call Wait. +const stderrTailLimit = 8 * 1024 + +// StartWithStdio starts an executable without taking ownership of its standard +// output stream. It retains a bounded standard-error tail for protocol failure +// diagnostics. Callers must drain Stdout before calling Wait. func (in *executable) StartWithStdio(ctx context.Context) (*StdioProcess, error) { if err := in.runLifecycleFunction(v1.LifecyclePreStart); err != nil { return nil, err @@ -50,96 +54,124 @@ func (in *executable) StartWithStdio(ctx context.Context) (*StdioProcess, error) _ = stdin.Close() return nil, err } - stderr, err := cmd.StderrPipe() - if err != nil { - cancelRun() - _ = stdin.Close() - _ = stdout.Close() - return nil, err + runtime := &stdioRuntime{ + cmd: cmd, + runCtx: runCtx, + cancelRun: cancelRun, + executable: in, + stdin: stdin, + stdout: stdout, } + cmd.Stderr = runtime + cmd.WaitDelay = time.Second klog.V(log.LogLevelExtended).InfoS("executing", "command", in.Command()) if err := cmd.Start(); err != nil { cancelRun() _ = stdin.Close() _ = stdout.Close() - _ = stderr.Close() return nil, err } - var waitOnce sync.Once - var waitErr error - var closeOnce sync.Once - var closeErr error - var stopOnce sync.Once - var stopErr error - intentionalStop := false - var stateMu sync.Mutex - - closeStreams := func() error { - closeOnce.Do(func() { - for _, stream := range []io.Closer{stdin, stdout, stderr} { - if err := stream.Close(); err != nil && !errors.Is(err, os.ErrClosed) { - closeErr = errors.Join(closeErr, err) - } - } - }) - return closeErr + return NewStdioProcess(stdin, stdout, io.NopCloser(strings.NewReader("")), StdioProcessHooks{ + Wait: runtime.wait, + Kill: runtime.kill, + Stop: runtime.stop, + Close: runtime.closeStreams, + StderrTail: runtime.stderrTail, + }), nil +} + +type stdioRuntime struct { + cmd *exec.Cmd + runCtx context.Context + cancelRun context.CancelFunc + executable *executable + stdin io.WriteCloser + stdout io.ReadCloser + + waitOnce sync.Once + waitErr error + closeOnce sync.Once + closeErr error + stopOnce sync.Once + stopErr error + stopped bool + stderr []byte + mu sync.Mutex +} + +func (runtime *stdioRuntime) Write(input []byte) (int, error) { + runtime.mu.Lock() + defer runtime.mu.Unlock() + runtime.stderr = append(runtime.stderr, input...) + if overflow := len(runtime.stderr) - stderrTailLimit; overflow > 0 { + runtime.stderr = append([]byte(nil), runtime.stderr[overflow:]...) } + return len(input), nil +} - wait := func() error { - waitOnce.Do(func() { - waitErr = cmd.Wait() - cause := context.Cause(runCtx) - cancelRun() - _ = closeStreams() - - stateMu.Lock() - wasStopped := intentionalStop - stateMu.Unlock() - if cause != nil && !wasStopped { - waitErr = errors.Join(waitErr, cause) - } - if wasStopped { - // Stop is an intentional lifecycle operation. The process is - // expected to report a signal-related exit in this case. - waitErr = nil - } - if err := in.runLifecycleFunction(v1.LifecyclePostStart); err != nil { - waitErr = errors.Join(waitErr, err) +func (runtime *stdioRuntime) stderrTail() string { + runtime.mu.Lock() + defer runtime.mu.Unlock() + return string(runtime.stderr) +} + +func (runtime *stdioRuntime) closeStreams() error { + runtime.closeOnce.Do(func() { + for _, stream := range []io.Closer{runtime.stdin, runtime.stdout} { + if err := stream.Close(); err != nil && !errors.Is(err, os.ErrClosed) { + runtime.closeErr = errors.Join(runtime.closeErr, err) } - }) - return waitErr - } + } + }) + return runtime.closeErr +} - kill := func() error { - if cmd.Process == nil { - return nil +func (runtime *stdioRuntime) wait() error { + runtime.waitOnce.Do(func() { + runtime.waitErr = runtime.cmd.Wait() + cause := context.Cause(runtime.runCtx) + runtime.cancelRun() + _ = runtime.closeStreams() + if runtime.wasStopped() { + runtime.waitErr = nil + } else if cause != nil { + runtime.waitErr = errors.Join(runtime.waitErr, cause) } - err := cmd.Process.Kill() - if errors.Is(err, os.ErrProcessDone) { - return nil + if err := runtime.executable.runLifecycleFunction(v1.LifecyclePostStart); err != nil { + runtime.waitErr = errors.Join(runtime.waitErr, err) } - return err - } + }) + return runtime.waitErr +} - stop := func() error { - stopOnce.Do(func() { - stateMu.Lock() - intentionalStop = true - stateMu.Unlock() - _ = stdin.Close() - stopErr = kill() - }) - return stopErr +func (runtime *stdioRuntime) kill() error { + if runtime.cmd.Process == nil { + return nil } + err := runtime.cmd.Process.Kill() + if errors.Is(err, os.ErrProcessDone) { + return nil + } + return err +} - return NewStdioProcess(stdin, stdout, stderr, StdioProcessHooks{ - Wait: wait, - Kill: kill, - Stop: stop, - Close: closeStreams, - }), nil +func (runtime *stdioRuntime) stop() error { + runtime.stopOnce.Do(func() { + runtime.mu.Lock() + runtime.stopped = true + runtime.mu.Unlock() + _ = runtime.stdin.Close() + runtime.stopErr = runtime.kill() + }) + return runtime.stopErr +} + +func (runtime *stdioRuntime) wasStopped() bool { + runtime.mu.Lock() + defer runtime.mu.Unlock() + return runtime.stopped } // StartWithStdio creates and starts a command with bidirectional standard diff --git a/go/deployment-operator/pkg/harness/exec/exec_stdio_test.go b/go/deployment-operator/pkg/harness/exec/exec_stdio_test.go index 78e7010b04..7c7697ee20 100644 --- a/go/deployment-operator/pkg/harness/exec/exec_stdio_test.go +++ b/go/deployment-operator/pkg/harness/exec/exec_stdio_test.go @@ -6,6 +6,7 @@ import ( "io" "sync/atomic" "testing" + "time" stackv1 "github.com/pluralsh/console/go/deployment-operator/pkg/harness/stackrun/v1" "github.com/stretchr/testify/require" @@ -71,3 +72,23 @@ func TestStartWithStdioPreStartFailureDoesNotStartProcess(t *testing.T) { require.ErrorIs(t, err, preErr) require.Nil(t, process) } + +func TestStartWithStdioWaitBoundsInheritedStderr(t *testing.T) { + process, err := StartWithStdio(context.Background(), "sh", + WithArgs([]string{"-c", "sleep 2 >&2 & printf 'startup failure\\n' >&2"}), + ) + if err != nil { + t.Fatalf("start process: %v", err) + } + + started := time.Now() + if err := process.Wait(); err == nil { + t.Fatal("wait succeeded with an inherited stderr descriptor") + } + if elapsed := time.Since(started); elapsed > 1500*time.Millisecond { + t.Fatalf("wait exceeded stderr drain bound: %v", elapsed) + } + if tail := process.StderrTail(); tail != "startup failure\n" { + t.Fatalf("stderr tail = %q", tail) + } +} diff --git a/go/deployment-operator/pkg/harness/exec/exec_stdio_types.go b/go/deployment-operator/pkg/harness/exec/exec_stdio_types.go index a0f72ed169..c985afaabb 100644 --- a/go/deployment-operator/pkg/harness/exec/exec_stdio_types.go +++ b/go/deployment-operator/pkg/harness/exec/exec_stdio_types.go @@ -6,43 +6,54 @@ import ( ) // StdioProcess is a running executable with bidirectional standard input and -// output. The process owner must drain Stdout and Stderr before calling Wait. -// Stop closes the input stream and terminates the process; it is safe to call -// more than once. +// output. The process owner must drain Stdout before calling Wait. Standard +// error is retained as a bounded diagnostic tail. Stop closes the +// input stream and terminates the process; it is safe to call more than once. type StdioProcess struct { Stdin io.WriteCloser Stdout io.ReadCloser Stderr io.ReadCloser - wait func() error - kill func() error - close func() error - stop func() error + wait func() error + kill func() error + close func() error + stop func() error + stderrTail func() string } // StdioProcessHooks supplies lifecycle operations for a StdioProcess. It is // useful for protocol adapters and deterministic tests that provide their own // in-memory streams. type StdioProcessHooks struct { - Wait func() error - Kill func() error - Stop func() error - Close func() error + Wait func() error + Kill func() error + Stop func() error + Close func() error + StderrTail func() string } // NewStdioProcess wraps bidirectional streams and their lifecycle operations. func NewStdioProcess(stdin io.WriteCloser, stdout, stderr io.ReadCloser, hooks StdioProcessHooks) *StdioProcess { return &StdioProcess{ - Stdin: stdin, - Stdout: stdout, - Stderr: stderr, - wait: hooks.Wait, - kill: hooks.Kill, - stop: hooks.Stop, - close: hooks.Close, + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + wait: hooks.Wait, + kill: hooks.Kill, + stop: hooks.Stop, + close: hooks.Close, + stderrTail: hooks.StderrTail, } } +// StderrTail returns a bounded tail of the child's standard error. +func (p *StdioProcess) StderrTail() string { + if p == nil || p.stderrTail == nil { + return "" + } + return p.stderrTail() +} + // Wait waits for the process and runs its post-start lifecycle hook. It also // closes the process streams after the child exits. func (p *StdioProcess) Wait() error { From 195e1778b5d0cfbcbc64f46adbfa94348c2d2e0f Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Mon, 7 Sep 2026 13:51:43 +0200 Subject: [PATCH 15/46] feat(router, session, ci): enhance OpenAI Responses handling, agent session workflow, and CI dependencies - **Router Enhancements**: - Added support for streaming error handling in OpenAI Responses. - Preserved raw OpenAI payloads upstream for better compatibility and future extensibility. - Introduced new request parsing and error conversion mechanisms in `openai_responses.go`. - Improved session and response failure diagnostics. - Added unit tests to enforce proper handling of raw OpenAI requests, streaming errors, and failure conditions. - **Session Workflow Updates**: - Replaced `killAndWait` with `stopAndWait` for improved process termination semantics. - Updated `engine_test.go` and `session.go` to distinguish between killed and stopped states. - Preserved stop reason during cleanup and spontaneous exit scenarios. - Ensured session updates are handled consistently. - **Codex Dependency Updates**: - Bumped Codex version to `1.10.0` in both `codex.Dockerfile` and CI workflows. - Introduced native Codex binaries integration for streamlined builds and runtime. - Adjusted npm symlink setup to include new binary installation. - **Test Coverage**: - Added comprehensive tests for session handling, streaming errors, and OpenAI response workflows. - Verified compatibility with updated Codex and session lifecycle across multiple test scenarios. --- .../deployment-operator-cd-agent-harness.yaml | 4 +- .../agent-harness/codex.Dockerfile | 17 +- .../agentrun-harness/tool/acp/engine_test.go | 54 +++++- .../pkg/agentrun-harness/tool/acp/session.go | 8 +- .../pkg/harness/exec/exec.go | 3 + go/nexus/internal/router/openai_method.go | 5 + go/nexus/internal/router/openai_responses.go | 119 +++++++++++- .../internal/router/openai_responses_test.go | 182 ++++++++++++++++++ 8 files changed, 376 insertions(+), 16 deletions(-) create mode 100644 go/nexus/internal/router/openai_responses_test.go diff --git a/.github/workflows/deployment-operator-cd-agent-harness.yaml b/.github/workflows/deployment-operator-cd-agent-harness.yaml index 401506d3df..0962b10cbf 100644 --- a/.github/workflows/deployment-operator-cd-agent-harness.yaml +++ b/.github/workflows/deployment-operator-cd-agent-harness.yaml @@ -34,7 +34,7 @@ jobs: CLAUDE_VERSION: 2.1.72 GEMINI_VERSION: 0.44.1 OPENCODE_VERSION: 1.18.23 - CODEX_VERSION: 1.9.0 + CODEX_VERSION: 1.10.0 PI_VERSION: 0.84.1 outputs: node: ${{ env.NODE_VERSION }} @@ -185,7 +185,7 @@ jobs: - name: opencode version: 1.18.23 - name: codex - version: 1.9.0 + version: 1.10.0 - name: pi version: 0.84.1 permissions: diff --git a/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile index aed1b8a154..42d2f41702 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile @@ -1,23 +1,25 @@ ARG NODE_IMAGE_TAG=24 ARG NODE_IMAGE=node:${NODE_IMAGE_TAG}-slim -ARG AGENT_VERSION=1.9.0 +ARG AGENT_VERSION=1.10.0 +ARG CODEX_VERSION=0.153.4 ARG AGENT_HARNESS_BASE_IMAGE_TAG=latest ARG AGENT_HARNESS_BASE_IMAGE_REPO=ghcr.io/pluralsh/agent-harness-base ARG AGENT_HARNESS_BASE_IMAGE=$AGENT_HARNESS_BASE_IMAGE_REPO:$AGENT_HARNESS_BASE_IMAGE_TAG -# Stage 1: Install the Codex ACP adapter and its compatible Codex dependency +# Stage 1: Install pinned Codex ACP adapter and native Codex binaries. FROM $NODE_IMAGE AS node USER root ARG AGENT_VERSION -RUN npm install -g "@agentclientprotocol/codex-acp@$AGENT_VERSION" +ARG CODEX_VERSION +RUN npm install -g "@agentclientprotocol/codex-acp@$AGENT_VERSION" "@openai/codex@$CODEX_VERSION" # Verify installation RUN codex-acp --version -# Stage 2: Copy the Codex ACP adapter into agent-harness base +# Stage 2: Copy Codex ACP adapter and native Codex into agent-harness base FROM $AGENT_HARNESS_BASE_IMAGE AS final COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules @@ -27,9 +29,12 @@ COPY --from=node /usr/local/bin/node /usr/local/bin/node # Ensure proper ownership for nonroot user USER root -# COPY dereferences the npm launcher symlink, so recreate it in the final image. +# COPY dereferences npm launcher symlinks, so recreate them in the final image. RUN ln -s ../lib/node_modules/@agentclientprotocol/codex-acp/dist/index.js /usr/local/bin/codex-acp && \ - chown -R 65532:65532 /usr/local/bin/codex-acp /usr/local/lib/node_modules /usr/local/bin/node + ln -s ../lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex && \ + chown -R 65532:65532 /usr/local/bin/codex-acp /usr/local/bin/codex /usr/local/lib/node_modules /usr/local/bin/node + +ENV CODEX_PATH=/usr/local/bin/codex # Switch back to nonroot user USER 65532:65532 diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go index 16eb6febd3..16de70122f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -198,6 +198,8 @@ type testProcess struct { agentIn *io.PipeReader agentOut *io.PipeWriter kills int + killed bool + stopped bool mu sync.Mutex } @@ -230,18 +232,31 @@ func (process *testProcess) closePipes() { func (process *testProcess) wait() error { <-process.done process.closePipes() + process.mu.Lock() + defer process.mu.Unlock() + if process.killed && !process.stopped { + return errors.New("signal: killed") + } return nil } func (process *testProcess) kill() error { process.mu.Lock() process.kills++ + process.killed = true process.mu.Unlock() process.finish() process.closePipes() return nil } +func (process *testProcess) stop() error { + process.mu.Lock() + process.stopped = true + process.mu.Unlock() + return process.kill() +} + func (process *testProcess) close() error { process.finish() process.closePipes() @@ -265,7 +280,7 @@ func newTestProcess(agent *testAgent, stdinCloseEnds bool) (*testProcess, *exec. stdio := exec.NewStdioProcess(process.stdin, agentToClientReader, io.NopCloser(strings.NewReader("")), exec.StdioProcessHooks{ Wait: process.wait, Kill: process.kill, - Stop: process.kill, + Stop: process.stop, Close: process.close, }) return process, stdio @@ -479,3 +494,40 @@ func TestEngineTurnCancellationKillsUncooperativeProcess(t *testing.T) { t.Fatal("cancellation did not send session/cancel") } } + +func TestEngineTurnIgnoresCleanupKillAfterSuccessfulPrompt(t *testing.T) { + state := newTestState() + _, process, _ := newTestAgentProcess(state, false) + _, err := NewEngine(Config{StopTimeout: 10 * time.Millisecond}).Turn(context.Background(), process, Request{ + Cwd: t.TempDir(), Prompt: "complete", + }, &testSink{}) + if err != nil { + t.Fatalf("successful prompt returned shutdown error: %v", err) + } +} + +func TestEngineTurnPreservesPromptStopReasonAfterCleanupKill(t *testing.T) { + state := newTestState() + state.stopReason = acpsdk.StopReasonMaxTokens + _, process, _ := newTestAgentProcess(state, false) + _, err := NewEngine(Config{StopTimeout: 10 * time.Millisecond}).Turn(context.Background(), process, Request{ + Cwd: t.TempDir(), Prompt: "complete", + }, &testSink{}) + if err == nil || !strings.Contains(err.Error(), string(acpsdk.StopReasonMaxTokens)) { + t.Fatalf("prompt stop reason was not preserved: %v", err) + } + if strings.Contains(err.Error(), "signal: killed") { + t.Fatalf("cleanup kill obscured prompt stop reason: %v", err) + } +} + +func TestSessionAttemptPreservesSpontaneousExit(t *testing.T) { + naturalExit := errors.New("agent exited with status 17") + attempt := &sessionAttempt{ + engine: NewEngine(Config{StopTimeout: time.Second}), + process: exec.NewStdioProcess(nil, nil, nil, exec.StdioProcessHooks{Wait: func() error { return naturalExit }}), + } + if err := attempt.waitForExit(); !errors.Is(err, naturalExit) { + t.Fatalf("spontaneous exit = %v, want %v", err, naturalExit) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go index d7f66507b5..6db62a1d55 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go @@ -244,13 +244,13 @@ func (attempt *sessionAttempt) waitForExit() error { case waitErr := <-waitCh: return waitErr case <-timer.C: - return attempt.killAndWait(waitCh) + return attempt.stopAndWait(waitCh) } } -func (attempt *sessionAttempt) killAndWait(waitCh <-chan error) error { - if err := attempt.process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { - klog.V(log.LogLevelDebug).InfoS("ACP process kill failed", "error", err) +func (attempt *sessionAttempt) stopAndWait(waitCh <-chan error) error { + if err := attempt.process.Stop(); err != nil && !errors.Is(err, os.ErrProcessDone) { + klog.V(log.LogLevelDebug).InfoS("ACP process stop failed", "error", err) } return <-waitCh } diff --git a/go/deployment-operator/pkg/harness/exec/exec.go b/go/deployment-operator/pkg/harness/exec/exec.go index 52e673cb1c..901de78399 100644 --- a/go/deployment-operator/pkg/harness/exec/exec.go +++ b/go/deployment-operator/pkg/harness/exec/exec.go @@ -80,6 +80,9 @@ func (in *executable) RunWithOutput(ctx context.Context) ([]byte, error) { } func (in *executable) Command() string { + if len(in.args) == 0 { + return in.command + } return fmt.Sprintf("%s %s", in.command, strings.Join(in.args, " ")) } diff --git a/go/nexus/internal/router/openai_method.go b/go/nexus/internal/router/openai_method.go index 2dffc88c70..552e7de3e8 100644 --- a/go/nexus/internal/router/openai_method.go +++ b/go/nexus/internal/router/openai_method.go @@ -144,6 +144,11 @@ func openAIRequestModel(req interface{}) string { return r.Model case *openai.OpenAIResponsesRequest: return r.Model + case *openAIResponsesRequest: + if r.request != nil { + return r.request.Model + } + return "" case *openai.OpenAIEmbeddingRequest: return r.Model default: diff --git a/go/nexus/internal/router/openai_responses.go b/go/nexus/internal/router/openai_responses.go index f521e077d7..e2892e6c9b 100644 --- a/go/nexus/internal/router/openai_responses.go +++ b/go/nexus/internal/router/openai_responses.go @@ -2,37 +2,95 @@ package router import ( "errors" + "fmt" + "io" "net/http" + "github.com/bytedance/sonic" "github.com/maximhq/bifrost/core/providers/openai" "github.com/maximhq/bifrost/core/schemas" + "github.com/tidwall/sjson" ) +const ( + openAIResponsesStreamErrorMessage = "An internal error occurred while processing your request." + openAIResponsesStreamErrorCode = "server_error" + openAIResponsesStreamFailedStatus = "failed" +) + +type openAIResponsesStreamFailure struct { + Type schemas.ResponsesStreamResponseType `json:"type"` + SequenceNumber int `json:"sequence_number"` + Response openAIResponsesFailedResponse `json:"response"` +} + +type openAIResponsesFailedResponse struct { + Object string `json:"object"` + Status string `json:"status"` + Error openAIResponsesFailedErrorDetail `json:"error"` +} + +type openAIResponsesFailedErrorDetail struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type openAIResponsesRequest struct { + request *openai.OpenAIResponsesRequest + rawBody []byte +} + func (in *OpenAIRouter) newResponsesRoute() RouteConfig { return RouteConfig{ Path: string(RouteResponses), Method: http.MethodPost, GetRequestTypeInstance: in.responsesRequestTypeInstance, + RequestParser: in.responsesRequestParser, RequestConverter: in.responsesRequestConverter, ResponsesResponseConverter: in.responsesResponseConverter, ErrorConverter: in.errorConverter, PreCallback: in.openAIRoutePreCallback(string(RouteResponses)), StreamConfig: &StreamConfig{ ResponsesStreamResponseConverter: in.responsesStreamResponseConverter, - ErrorConverter: in.errorConverter, + ErrorConverter: in.responsesStreamErrorConverter, }, } } func (in *OpenAIRouter) responsesRequestTypeInstance() interface{} { - return &openai.OpenAIResponsesRequest{} + return &openAIResponsesRequest{request: &openai.OpenAIResponsesRequest{}} +} + +func (in *OpenAIRouter) responsesRequestParser(request *http.Request, target interface{}) error { + responsesRequest, ok := target.(*openAIResponsesRequest) + if !ok { + return errors.New("invalid request type") + } + + rawBody, err := io.ReadAll(request.Body) + if err != nil { + return err + } + if len(rawBody) == 0 { + return nil + } + if err := sonic.Unmarshal(rawBody, responsesRequest.request); err != nil { + return err + } + responsesRequest.rawBody = rawBody + + return nil } func (in *OpenAIRouter) responsesRequestConverter(ctx *schemas.BifrostContext, req interface{}) (*schemas.BifrostRequest, error) { - openaiReq, ok := req.(*openai.OpenAIResponsesRequest) + responsesRequest, ok := req.(*openAIResponsesRequest) if !ok { return nil, errors.New("invalid request type") } + openaiReq := responsesRequest.request + if openaiReq == nil { + return nil, errors.New("invalid request type") + } provider, model, _, err := in.resolveModel(ctx, openaiReq.Model) if err != nil { @@ -53,10 +111,30 @@ func (in *OpenAIRouter) responsesRequestConverter(ctx *schemas.BifrostContext, r chatReq.Model = model return &schemas.BifrostRequest{ChatRequest: chatReq}, nil } + // Bifrost's typed Responses schema can drop newer native fields such as additional_tools. + // Preserve native OpenAI payloads; fallback and cross-provider conversions stay typed. + if provider == schemas.OpenAI && len(bifrostReq.Fallbacks) == 0 && len(responsesRequest.rawBody) > 0 { + rawBody, err := sjson.SetBytes(responsesRequest.rawBody, "model", model) + if err != nil { + return nil, fmt.Errorf("failed to update raw request model: %w", err) + } + rawBody, err = sjson.DeleteBytes(rawBody, "fallbacks") + if err != nil { + return nil, fmt.Errorf("failed to remove raw request fallbacks: %w", err) + } + bifrostReq.RawRequestBody = rawBody + ctx.SetValue(schemas.BifrostContextKeyUseRawRequestBody, true) + ctx.SetValue(schemas.BifrostContextKeyAllowPerRequestRawOverride, true) + ctx.SetValue(schemas.BifrostContextKeySendBackRawResponse, true) + } return &schemas.BifrostRequest{ResponsesRequest: bifrostReq}, nil } +func (in *openAIResponsesRequest) IsStreamingRequested() bool { + return in.request != nil && in.request.IsStreamingRequested() +} + func (in *OpenAIRouter) responsesResponseConverter(ctx *schemas.BifrostContext, resp *schemas.BifrostResponsesResponse) (interface{}, error) { if raw, ok := openaiResponsesRawResponse(ctx, resp); ok { return raw, nil @@ -72,3 +150,38 @@ func (in *OpenAIRouter) responsesStreamResponseConverter(ctx *schemas.BifrostCon return string(resp.Type), resp, nil } + +func (in *OpenAIRouter) responsesStreamErrorConverter(_ *schemas.BifrostContext, err *schemas.BifrostError) interface{} { + message := openAIResponsesStreamErrorMessage + code := openAIResponsesStreamErrorCode + if err == nil || err.Error == nil { + return in.openAIResponsesStreamFailure(message, code) + } + + if err.Error.Message != "" { + message = err.Error.Message + } + if err.Error.Code != nil && *err.Error.Code != "" { + code = *err.Error.Code + } else if err.Type != nil && *err.Type != "" { + code = *err.Type + } + + return in.openAIResponsesStreamFailure(message, code) +} + +// openAIResponsesStreamFailure preserves upstream failures for Responses clients without fabricating a full response. +func (in *OpenAIRouter) openAIResponsesStreamFailure(message, code string) *openAIResponsesStreamFailure { + return &openAIResponsesStreamFailure{ + Type: schemas.ResponsesStreamResponseTypeFailed, + SequenceNumber: 0, + Response: openAIResponsesFailedResponse{ + Object: "response", + Status: openAIResponsesStreamFailedStatus, + Error: openAIResponsesFailedErrorDetail{ + Code: code, + Message: message, + }, + }, + } +} diff --git a/go/nexus/internal/router/openai_responses_test.go b/go/nexus/internal/router/openai_responses_test.go new file mode 100644 index 0000000000..8b1acb0e53 --- /dev/null +++ b/go/nexus/internal/router/openai_responses_test.go @@ -0,0 +1,182 @@ +package router + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/pluralsh/console/go/nexus/internal/log" + pb "github.com/pluralsh/console/go/nexus/internal/proto" + "github.com/stretchr/testify/require" +) + +func TestOpenAIResponsesStreamingErrors(t *testing.T) { + t.Parallel() + + message := "upstream request failed" + code := "invalid_request_error" + bifrostErr := &schemas.BifrostError{Error: &schemas.ErrorField{Message: message, Code: &code}} + router := &OpenAIRouter{GenericRouter: &GenericRouter{}} + config := router.newResponsesRoute() + ctx, _ := schemas.NewBifrostContextWithCancel(context.Background()) + + t.Run("initialization error", func(t *testing.T) { + recorder := httptest.NewRecorder() + router.sendStreamingInitError(recorder, ctx, config, bifrostErr) + assertOpenAIResponsesStreamError(t, recorder.Body.String(), message, code) + }) + t.Run("stream error", func(t *testing.T) { + recorder := httptest.NewRecorder() + stream := make(chan *schemas.BifrostStreamChunk, 1) + stream <- &schemas.BifrostStreamChunk{BifrostError: bifrostErr} + close(stream) + router.handleStreaming(recorder, ctx, config, stream, func() {}) + assertOpenAIResponsesStreamError(t, recorder.Body.String(), message, code) + }) +} + +func TestOpenAIResponsesRoutePreservesNativeRawBodyUpstream(t *testing.T) { + require.NoError(t, log.Init("error")) + + upstreamBody := make(chan []byte, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + body, err := io.ReadAll(request.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + upstreamBody <- body + var input struct { + Stream bool `json:"stream"` + } + if err := json.Unmarshal(body, &input); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if input.Stream { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "event: response.completed\ndata: {\"type\":\"response.completed\",\"sequence_number\":1,\"future_field\":{\"type\":\"namespace\",\"name\":\"ns\"}}\n\n") + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.6-luna","output":[],"future_field":{"type":"namespace","name":"ns"}}`) + })) + t.Cleanup(upstream.Close) + + model := "gpt-5.6-luna" + apiKey := "test-key" + handler, err := NewHandler(&mockConsoleClient{cfg: &pb.AiConfig{ + Enabled: true, + Openai: &pb.OpenAiConfig{Model: &model, ApiKey: &apiKey, BaseUrl: &upstream.URL}, + }}) + require.NoError(t, err) + t.Cleanup(handler.Shutdown) + + for _, stream := range []bool{true, false} { + t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) { + body := fmt.Sprintf(`{"model":"openai/gpt-5.6-luna","stream":%t,"fallbacks":[],"input":[{"role":"developer","type":"additional_tools","tools":[{"type":"namespace","name":"ns","tools":[{"type":"custom","name":"grammar","format":{"type":"grammar","syntax":"lark","definition":"start: WORD"}},{"type":"function","name":"fn","parameters":{"type":"object"}}]}]}]}`, stream) + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, string(RouteResponses), strings.NewReader(body))) + require.Equal(t, http.StatusOK, response.Code) + assertOpenAIResponsesNativeRequest(t, upstreamBody, response.Body.String(), stream) + }) + } +} + +func assertOpenAIResponsesNativeRequest(t *testing.T, upstreamBody <-chan []byte, body string, stream bool) { + t.Helper() + var rawBody map[string]any + select { + case upstream := <-upstreamBody: + require.NoError(t, json.Unmarshal(upstream, &rawBody)) + case <-time.After(time.Second): + t.Fatalf("upstream received no request; response body: %s", body) + } + require.Equal(t, "gpt-5.6-luna", rawBody["model"]) + require.NotContains(t, rawBody, "fallbacks") + input := rawBody["input"].([]any) + namespace := input[0].(map[string]any)["tools"].([]any)[0].(map[string]any) + require.Equal(t, "namespace", namespace["type"]) + grammar := namespace["tools"].([]any)[0].(map[string]any) + require.Equal(t, "custom", grammar["type"]) + require.Equal(t, "start: WORD", grammar["format"].(map[string]any)["definition"]) + var event map[string]any + payload := []byte(body) + if stream { + payload = sseData(t, body) + } + require.NoError(t, json.Unmarshal(payload, &event)) + require.Equal(t, "namespace", event["future_field"].(map[string]any)["type"]) +} + +func TestOpenAIResponsesRawBodyEligibility(t *testing.T) { + model := "gpt-5.6-luna" + xaiModel := "grok-4.5" + router := &OpenAIRouter{ + GenericRouter: &GenericRouter{}, + consoleClient: &mockConsoleClient{cfg: &pb.AiConfig{ + Enabled: true, + Openai: &pb.OpenAiConfig{Model: &model}, + Xai: &pb.OpenAiConfig{Model: &xaiModel}, + }}, + } + + tests := []struct { + name string + body string + viaChat bool + wantRawBody bool + wantChat bool + }{ + {"native nonstream", `{"model":"openai/gpt-5.6-luna","input":"hi"}`, false, true, false}, + {"fallback", `{"model":"openai/gpt-5.6-luna","input":"hi","fallbacks":["anthropic/claude"]}`, false, false, false}, + {"chat fallback", `{"model":"openai/gpt-5.6-luna","input":"hi"}`, true, false, true}, + {"xai", `{"model":"xai/grok-4.5","input":"hi"}`, false, false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := router.responsesRequestTypeInstance() + httpRequest := httptest.NewRequest(http.MethodPost, string(RouteResponses), strings.NewReader(tt.body)) + require.NoError(t, router.responsesRequestParser(httpRequest, request)) + ctx, _ := schemas.NewBifrostContextWithCancel(context.Background()) + if tt.viaChat { + ctx.SetValue(schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) + } + bifrostRequest, err := router.responsesRequestConverter(ctx, request) + require.NoError(t, err) + require.Equal(t, tt.wantRawBody, bifrostRequest.ResponsesRequest != nil && len(bifrostRequest.ResponsesRequest.RawRequestBody) > 0) + require.Equal(t, tt.wantChat, bifrostRequest.ChatRequest != nil) + }) + } +} + +func assertOpenAIResponsesStreamError(t *testing.T, body, message, code string) { + t.Helper() + require.NotContains(t, body, "[DONE]") + var response map[string]any + require.NoError(t, json.Unmarshal(sseData(t, body), &response)) + require.Equal(t, "response.failed", response["type"]) + require.Contains(t, response, "sequence_number") + failedResponse, ok := response["response"].(map[string]any) + require.True(t, ok) + errorDetail, ok := failedResponse["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, message, errorDetail["message"]) + require.Equal(t, code, errorDetail["code"]) +} + +func sseData(t *testing.T, body string) []byte { + t.Helper() + if strings.HasPrefix(body, "event: ") { + body = body[strings.Index(body, "\ndata: ")+1:] + } + require.True(t, strings.HasPrefix(body, "data: ")) + return []byte(strings.TrimSuffix(strings.TrimPrefix(body, "data: "), "\n\n")) +} From 8e94c819df5056da59439896e5677cdde3028768 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Mon, 7 Sep 2026 15:07:35 +0200 Subject: [PATCH 16/46] feat(acp): enhance timeout and session workflow handling - Increased default stop timeout to 15 seconds in `engine.go`. - Updated error assignment syntax in `session.go` for consistency. - Improved session cleanup to distinguish between stop and kill processes. - Enhanced error handling during session finalization and cleanup. - Updated `testProcess` in `engine_test.go` to support `stopReportsKill` for more accurate test scenarios. --- .../pkg/agentrun-harness/tool/acp/engine.go | 2 +- .../agentrun-harness/tool/acp/engine_test.go | 32 ++++++++++--------- .../pkg/agentrun-harness/tool/acp/session.go | 22 +++++++++---- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go index b322f69f6c..c38d2b0923 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go @@ -18,7 +18,7 @@ import ( "github.com/pluralsh/console/go/deployment-operator/pkg/log" ) -const defaultStopTimeout = 2 * time.Second +const defaultStopTimeout = 15 * time.Second // Engine owns one provider-neutral ACP protocol implementation. It does not // launch processes or retain provider configuration. diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go index 16de70122f..f267514fbd 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -188,19 +188,20 @@ func (sink *testSink) Usage(record usage.Record) { } type testProcess struct { - stdinCloseEnds bool - done chan struct{} - finishOnce sync.Once - pipeCloseOnce sync.Once - stdin *testWriter - clientIn *io.PipeReader - clientOut *io.PipeReader - agentIn *io.PipeReader - agentOut *io.PipeWriter - kills int - killed bool - stopped bool - mu sync.Mutex + stdinCloseEnds bool + done chan struct{} + finishOnce sync.Once + pipeCloseOnce sync.Once + stdin *testWriter + clientIn *io.PipeReader + clientOut *io.PipeReader + agentIn *io.PipeReader + agentOut *io.PipeWriter + kills int + killed bool + stopped bool + stopReportsKill bool + mu sync.Mutex } type testWriter struct { @@ -252,7 +253,7 @@ func (process *testProcess) kill() error { func (process *testProcess) stop() error { process.mu.Lock() - process.stopped = true + process.stopped = !process.stopReportsKill process.mu.Unlock() return process.kill() } @@ -497,7 +498,8 @@ func TestEngineTurnCancellationKillsUncooperativeProcess(t *testing.T) { func TestEngineTurnIgnoresCleanupKillAfterSuccessfulPrompt(t *testing.T) { state := newTestState() - _, process, _ := newTestAgentProcess(state, false) + _, process, cleanup := newTestAgentProcess(state, false) + cleanup.stopReportsKill = true _, err := NewEngine(Config{StopTimeout: 10 * time.Millisecond}).Turn(context.Background(), process, Request{ Cwd: t.TempDir(), Prompt: "complete", }, &testSink{}) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go index 6db62a1d55..ee78e5dede 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go @@ -47,10 +47,10 @@ func (attempt *sessionAttempt) run(prompt string) error { if err != nil { return attempt.fail(err, attempt.cancelled()) } - if err := attempt.configureSession(details); err != nil { + if err = attempt.configureSession(details); err != nil { return attempt.fail(err, attempt.cancelled()) } - if err := attempt.stopIfCancelled(); err != nil { + if err = attempt.stopIfCancelled(); err != nil { return err } @@ -58,17 +58,25 @@ func (attempt *sessionAttempt) run(prompt string) error { if err != nil { return attempt.promptFailure(err) } + attempt.finishTurn(response.Usage) - if err := attempt.turn.err(); err != nil { + if err = attempt.turn.err(); err != nil { return attempt.fail(err, attempt.cancelled()) } - if err := attempt.stopIfCancelled(); err != nil { + if err = attempt.stopIfCancelled(); err != nil { return err } - if err := attempt.stop(false); err != nil { - return attempt.processFailure(fmt.Errorf("stop acp process: %w", err), nil) + + resultErr := attempt.promptResult(response.StopReason) + stopErr := attempt.stop(false) + if resultErr != nil { + return attempt.processFailure(resultErr, stopErr) } - return attempt.promptResult(response.StopReason) + if stopErr != nil { + klog.V(log.LogLevelDebug).InfoS("ACP process cleanup failed after completed prompt", "error", stopErr) + } + + return nil } func (attempt *sessionAttempt) configureSession(details sessionDetails) error { From a1061235b3b3f83130aba50540db1af2ef3808e6 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Mon, 7 Sep 2026 15:15:24 +0200 Subject: [PATCH 17/46] feat(acp): handle non-blocking FIFO reads and enhance file read logic - Updated `client.go` to open files with `O_NONBLOCK` for improved non-blocking behavior. - Added `TestClientRejectsFIFOWithoutBlocking` to validate FIFO handling without blocking. - Included `syscall` and `time` imports for enhanced test and runtime functionality. --- .../pkg/agentrun-harness/tool/acp/client.go | 3 ++- .../agentrun-harness/tool/acp/client_test.go | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go index 4c826f854d..8b9150489c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "syscall" acpsdk "github.com/coder/acp-go-sdk" ) @@ -53,7 +54,7 @@ func (client *client) openTextFile(path string) (*os.File, error) { if !filepath.IsAbs(path) { return nil, fmt.Errorf("acp filesystem path must be absolute: %q", path) } - file, err := os.Open(path) + file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) if err != nil { return nil, fmt.Errorf("read %s: %w", path, err) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go index 0084e9a858..fc2ec51985 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -5,7 +5,9 @@ import ( "errors" "os" "path/filepath" + "syscall" "testing" + "time" ) import acpsdk "github.com/coder/acp-go-sdk" @@ -76,6 +78,29 @@ func TestClientRejectsOversizedAndCanceledReads(t *testing.T) { } } +func TestClientRejectsFIFOWithoutBlocking(t *testing.T) { + acpClient, directory := newTestClient(t) + path := filepath.Join(directory, "pipe") + if err := syscall.Mkfifo(path, 0o600); err != nil { + t.Fatalf("create FIFO: %v", err) + } + + done := make(chan error, 1) + go func() { + _, err := acpClient.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{SessionId: "session-1", Path: path}) + done <- err + }() + + select { + case err := <-done: + if err == nil { + t.Fatal("FIFO read unexpectedly succeeded") + } + case <-time.After(time.Second): + t.Fatal("FIFO read blocked") + } +} + func TestClientRejectsCanceledWritesBeforeFilesystemSideEffects(t *testing.T) { acpClient, directory := newTestClient(t) ctx, cancel := context.WithCancel(context.Background()) From 7220f50f85f6ab12bdcdc3429eff2184cf2c4caf Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Tue, 8 Sep 2026 10:22:48 +0200 Subject: [PATCH 18/46] feat(tool): add Claude agent implementation and new runtime/test enhancements - Implemented `claude` agent with runtime, configuration, environment setup, and transport logic. - Added `Agent` and `Transport` implementations for Claude runtime. - Introduced functions to handle runtime configuration, session directory copying, and Claude-specific environment settings. - Enhanced configuration features with `WithAvailableModels` in `SettingsBuilder`. - Added and refined test coverage: - For Claude runtime and transport (`agent_test.go`, `transport_test.go`). - To validate Claude configuration and session workflows. - Updated Claude container version to `2.1.236` in related Docker and CI files (`agent-harness.yaml`, `codex.Dockerfile`). - Improved runtime testing for file and proxy handling across Claude logic. --- .../deployment-operator-cd-agent-harness.yaml | 6 +- .../agent-harness/claude.Dockerfile | 55 +- .../agent-harness/codex.Dockerfile | 8 +- .../internal/controller/agentrun_pod.go | 4 +- .../tool/claude/acp_environment.go | 35 ++ .../pkg/agentrun-harness/tool/claude/agent.go | 199 +++++++ .../tool/claude/agent_config.go | 73 +++ .../tool/claude/agent_test.go | 129 +++++ .../agentrun-harness/tool/claude/agents.go | 130 ----- .../tool/claude/agents_test.go | 67 --- .../agentrun-harness/tool/claude/artifacts.go | 16 - .../agentrun-harness/tool/claude/claude.go | 539 ------------------ .../tool/claude/claude_args_test.go | 45 -- .../tool/claude/claude_stream_test.go | 89 --- .../tool/claude/claude_templates.go | 24 +- .../tool/claude/claude_types.go | 93 --- .../tool/claude/runtime_config.go | 44 ++ .../agentrun-harness/tool/claude/session.go | 68 +++ .../agentrun-harness/tool/claude/transport.go | 74 +++ .../tool/claude/transport_test.go | 128 +++++ .../pkg/agentrun-harness/tool/tool.go | 7 +- .../pkg/agentrun-harness/tool/tool_test.go | 23 + 22 files changed, 827 insertions(+), 1029 deletions(-) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_config.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/agents.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/agents_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/artifacts.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/claude.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_args_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_stream_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_types.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/runtime_config.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/session.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go diff --git a/.github/workflows/deployment-operator-cd-agent-harness.yaml b/.github/workflows/deployment-operator-cd-agent-harness.yaml index 0962b10cbf..b78e442c46 100644 --- a/.github/workflows/deployment-operator-cd-agent-harness.yaml +++ b/.github/workflows/deployment-operator-cd-agent-harness.yaml @@ -31,10 +31,10 @@ jobs: runs-on: ubuntu-latest env: NODE_VERSION: 24.11.1 - CLAUDE_VERSION: 2.1.72 + CLAUDE_VERSION: 2.1.236 GEMINI_VERSION: 0.44.1 OPENCODE_VERSION: 1.18.23 - CODEX_VERSION: 1.10.0 + CODEX_VERSION: 0.153.4 PI_VERSION: 0.84.1 outputs: node: ${{ env.NODE_VERSION }} @@ -179,7 +179,7 @@ jobs: matrix: agents: - name: claude - version: 2.1.72 + version: 2.1.236 - name: gemini version: 0.44.1 - name: opencode diff --git a/go/deployment-operator/dockerfiles/agent-harness/claude.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/claude.Dockerfile index 47f4eb39d1..86d1d27484 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/claude.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/claude.Dockerfile @@ -1,49 +1,44 @@ -ARG AGENT_VERSION=latest +ARG AGENT_VERSION=2.1.236 +ARG ACP_VERSION=0.75.1 ARG AGENT_HARNESS_BASE_IMAGE_TAG=latest ARG AGENT_HARNESS_BASE_IMAGE_REPO=ghcr.io/pluralsh/agent-harness-base ARG AGENT_HARNESS_BASE_IMAGE=$AGENT_HARNESS_BASE_IMAGE_REPO:$AGENT_HARNESS_BASE_IMAGE_TAG -# Stage 1: Install Claude Code native binary (no npm — postinstall is unreliable in CI) -FROM debian:13-slim AS claude-install +# Stage 1: Install Claude Code and its ACP adapter together. +FROM node:26-bookworm-slim AS claude-install ARG AGENT_VERSION +ARG ACP_VERSION -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - && arch="$(dpkg --print-architecture)" \ - && case "$arch" in \ - amd64) platform=linux-x64 ;; \ - arm64) platform=linux-arm64 ;; \ - *) echo "unsupported architecture: $arch" >&2; exit 1 ;; \ - esac \ - && if [ "$AGENT_VERSION" = "latest" ]; then \ - version="$(curl -fsSL https://downloads.claude.ai/claude-code-releases/latest)"; \ - else \ - version="$AGENT_VERSION"; \ - fi \ - && curl -fsSL \ - "https://downloads.claude.ai/claude-code-releases/${version}/${platform}/claude" \ - -o /usr/local/bin/claude \ - && chmod +x /usr/local/bin/claude \ - && /usr/local/bin/claude --version \ - && apt-get purge -y curl \ - && apt-get autoremove -y \ - && rm -rf /var/lib/apt/lists/* - -# Stage 2: Copy claude binary into agent-harness base +RUN npm install --global \ + "@anthropic-ai/claude-code@${AGENT_VERSION}" \ + "@agentclientprotocol/claude-agent-acp@${ACP_VERSION}" && \ + claude --version && \ + claude-agent-acp --version + +# Stage 2: Copy Claude and its ACP adapter into agent-harness base. FROM $AGENT_HARNESS_BASE_IMAGE AS final -COPY --from=claude-install /usr/local/bin/claude /usr/local/bin/claude +COPY --from=claude-install /usr/local/lib/node_modules /usr/local/lib/node_modules +COPY --from=claude-install /usr/local/bin/node /usr/local/bin/node USER root -RUN chown 65532:65532 /usr/local/bin/claude +# The Node runtime from the install stage links libatomic dynamically, while +# the minimal harness base does not include it. +RUN apt-get update && apt-get install -y --no-install-recommends libatomic1 && \ + rm -rf /var/lib/apt/lists/* + +# Recreate npm launcher symlinks after copying their packages. +RUN ln -s ../lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe /usr/local/bin/claude && \ + ln -s ../lib/node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js /usr/local/bin/claude-agent-acp && \ + chown -R 65532:65532 /usr/local/bin/claude /usr/local/bin/claude-agent-acp /usr/local/bin/node /usr/local/lib/node_modules USER 65532:65532 # Verify the binary runs in the final image (same user and PATH as runtime) RUN claude --version +RUN claude-agent-acp --version # The entrypoint remains the agent-harness binary -# The agent-harness will call the claude CLI as needed +# The agent-harness launches claude-agent-acp, which uses the pinned Claude CLI. diff --git a/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile index 42d2f41702..84003afe1b 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile @@ -1,7 +1,7 @@ ARG NODE_IMAGE_TAG=24 ARG NODE_IMAGE=node:${NODE_IMAGE_TAG}-slim -ARG AGENT_VERSION=1.10.0 -ARG CODEX_VERSION=0.153.4 +ARG AGENT_VERSION=0.153.4 +ARG ACP_VERSION=1.10.0 ARG AGENT_HARNESS_BASE_IMAGE_TAG=latest ARG AGENT_HARNESS_BASE_IMAGE_REPO=ghcr.io/pluralsh/agent-harness-base @@ -12,9 +12,9 @@ FROM $NODE_IMAGE AS node USER root +ARG ACP_VERSION ARG AGENT_VERSION -ARG CODEX_VERSION -RUN npm install -g "@agentclientprotocol/codex-acp@$AGENT_VERSION" "@openai/codex@$CODEX_VERSION" +RUN npm install -g "@agentclientprotocol/codex-acp@$ACP_VERSION" "@openai/codex@$AGENT_VERSION" # Verify installation RUN codex-acp --version diff --git a/go/deployment-operator/internal/controller/agentrun_pod.go b/go/deployment-operator/internal/controller/agentrun_pod.go index 6d20efca08..b75b5c7619 100644 --- a/go/deployment-operator/internal/controller/agentrun_pod.go +++ b/go/deployment-operator/internal/controller/agentrun_pod.go @@ -121,10 +121,10 @@ var ( // Check .github/workflows/deployment-operator-cd-agent-harness.yaml to see images being published. defaultContainerVersions = map[console.AgentRuntimeType]string{ - console.AgentRuntimeTypeClaude: "%s-claude-2.1.72", + console.AgentRuntimeTypeClaude: "%s-claude-2.1.236", console.AgentRuntimeTypeGemini: "%s-gemini-0.44.1", console.AgentRuntimeTypeOpencode: "%s-opencode-1.18.23", - console.AgentRuntimeTypeCodex: "%s-codex-1.9.0", + console.AgentRuntimeTypeCodex: "%s-codex-0.153.4", console.AgentRuntimeTypePi: "%s-pi-0.84.1", } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go new file mode 100644 index 0000000000..6b398d3fad --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go @@ -0,0 +1,35 @@ +package claude + +import ( + "fmt" + + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +const ( + claudeConfigEnv = "CLAUDE_CONFIG_DIR" + claudeExecutableEnv = "CLAUDE_CODE_EXECUTABLE" + anthropicAPIKeyEnv = "ANTHROPIC_API_KEY" + anthropicAuthEnv = "ANTHROPIC_AUTH_TOKEN" + anthropicBaseURLEnv = "ANTHROPIC_BASE_URL" + nativeClaudeBinary = "/usr/local/bin/claude" +) + +func (agent *Agent) env(config toolv1.Config) []string { + claude := config.Run.Runtime.Config.Claude + env := []string{ + fmt.Sprintf("%s=%s", claudeConfigEnv, agent.configPath(config)), + fmt.Sprintf("%s=%s", claudeExecutableEnv, nativeClaudeBinary), + } + if config.Run.IsProxyEnabled() { + return append(env, + fmt.Sprintf("%s=%s", anthropicAuthEnv, agent.consoleToken), + fmt.Sprintf("%s=%s/ext/ai/anthropic", anthropicBaseURLEnv, agent.consoleURL), + ) + } + env = append(env, fmt.Sprintf("%s=%s", anthropicAPIKeyEnv, claude.ApiKey)) + if claude.Endpoint != nil { + env = append(env, fmt.Sprintf("%s=%s", anthropicBaseURLEnv, *claude.Endpoint)) + } + return env +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go new file mode 100644 index 0000000000..56ed5104fe --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go @@ -0,0 +1,199 @@ +package claude + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +const ( + claudeConfigDir = ".claude" + claudeSkillsDir = "skills" + claudeProjectsDir = "projects" + claudePromptFile = "CLAUDE.md" +) + +// Agent owns Claude's native configuration, prompt and skills preparation, +// and staging of Claude's provider-owned session state. +type Agent struct { + config toolv1.Config + consoleURL string + consoleToken string +} + +var _ toolv1.Agent = (*Agent)(nil) + +func NewAgent(config toolv1.Config) *Agent { + return &Agent{config: config} +} + +func (*Agent) Type() console.AgentRuntimeType { + return console.AgentRuntimeTypeClaude +} + +func (*Agent) Capabilities() toolv1.AgentCapabilities { + return toolv1.AgentCapabilities{Modes: []console.AgentRunMode{ + console.AgentRunModeAnalyze, + console.AgentRunModeWrite, + console.AgentRunModeReview, + }} +} + +// Prepare writes the prompt both at the legacy generated path and at Claude's +// configured memory path. The latter is read by the native CLI launched by the +// ACP adapter, which cannot receive a system-prompt option through acp.Engine. +func (agent *Agent) Prepare(ctx context.Context, request toolv1.FileSystemRequest) error { + if err := agent.contextError(ctx); err != nil { + return err + } + config, err := agent.configForFilesystem(request) + if err != nil { + return err + } + + defaultTool := toolv1.DefaultTool{Config: config} + switch request.Phase { + case toolv1.ConfigurePhaseInitial: + err = defaultTool.ConfigureSystemPrompt(console.AgentRuntimeTypeClaude) + case toolv1.ConfigurePhaseBabysit: + err = defaultTool.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeClaude) + default: + return fmt.Errorf("unsupported claude configuration phase %q", request.Phase) + } + if err != nil { + return err + } + if err := agent.writeClaudePrompt(config); err != nil { + return err + } + if err := agent.contextError(ctx); err != nil { + return err + } + return defaultTool.ConfigureSkills(agent.skillsPath(config)) +} + +func (agent *Agent) Configure(ctx context.Context, request toolv1.ConfigureRequest) error { + if err := agent.contextError(ctx); err != nil { + return err + } + if request.Phase != toolv1.ConfigurePhaseInitial && request.Phase != toolv1.ConfigurePhaseBabysit { + return fmt.Errorf("unsupported claude configuration phase %q", request.Phase) + } + if request.Phase == toolv1.ConfigurePhaseBabysit { + return nil + } + + config, err := agent.configWithClaude() + if err != nil { + return err + } + agent.consoleURL = request.ConsoleURL + if request.ConsoleToken != "" { + agent.consoleToken = request.ConsoleToken + } + return agent.writeNativeConfig(config, request.Settings.Model.Name) +} + +func (agent *Agent) Export(ctx context.Context, request toolv1.ExportRequest) (toolv1.ExportResult, error) { + if err := agent.contextError(ctx); err != nil { + return toolv1.ExportResult{}, err + } + if request.SessionID == "" { + return toolv1.ExportResult{}, errors.New("claude session id is not set") + } + if request.OutputDir == "" { + return toolv1.ExportResult{}, errors.New("claude export output directory is not set") + } + config, err := agent.configWithClaude() + if err != nil { + return toolv1.ExportResult{}, err + } + source := filepath.Join(agent.configPath(config), claudeProjectsDir) + if _, err := os.Stat(source); err != nil { + if errors.Is(err, os.ErrNotExist) { + return toolv1.ExportResult{}, nil + } + return toolv1.ExportResult{}, fmt.Errorf("stat claude projects: %w", err) + } + if err := agent.copySessionDirectory(ctx, source, request.OutputDir); err != nil { + return toolv1.ExportResult{}, err + } + return toolv1.ExportResult{SessionSource: artifacts.SessionSource{ + Path: request.OutputDir, ArchivePath: claudeProjectsDir, + }}, nil +} + +func (agent *Agent) configWithClaude() (toolv1.Config, error) { + if agent.config.WorkDir == "" { + return toolv1.Config{}, errors.New("work directory is not set") + } + if agent.config.RepositoryDir == "" { + return toolv1.Config{}, errors.New("repository directory is not set") + } + if _, err := agent.runConfig(agent.config.Run); err != nil { + return toolv1.Config{}, err + } + return agent.config, nil +} + +func (agent *Agent) configForFilesystem(request toolv1.FileSystemRequest) (toolv1.Config, error) { + if request.WorkDir == "" { + return toolv1.Config{}, errors.New("work directory is not set") + } + if request.RepositoryDir == "" { + return toolv1.Config{}, errors.New("repository directory is not set") + } + if agent.config.Run == nil { + return toolv1.Config{}, errors.New("agent run is not set") + } + config := agent.config + config.WorkDir, config.RepositoryDir = request.WorkDir, request.RepositoryDir + return config, nil +} + +func (*Agent) runConfig(run *agentrunv1.AgentRun) (*agentrunv1.ClaudeConfig, error) { + if run == nil { + return nil, errors.New("agent run is not set") + } + if run.Runtime == nil || run.Runtime.Config == nil || run.Runtime.Config.Claude == nil { + return nil, errors.New("claude runtime configuration is not set") + } + return run.Runtime.Config.Claude, nil +} + +func (*Agent) configPath(config toolv1.Config) string { + return filepath.Join(config.WorkDir, claudeConfigDir) +} + +func (agent *Agent) skillsPath(config toolv1.Config) string { + return filepath.Join(agent.configPath(config), claudeSkillsDir) +} +func (agent *Agent) promptPath(config toolv1.Config) string { + return filepath.Join(agent.configPath(config), claudePromptFile) +} + +func (agent *Agent) writeClaudePrompt(config toolv1.Config) error { + source := filepath.Join(agent.configPath(config), "prompts", toolv1.SystemPromptFile) + content, err := os.ReadFile(source) + if err != nil { + return fmt.Errorf("read rendered claude prompt: %w", err) + } + if err := os.WriteFile(agent.promptPath(config), content, 0644); err != nil { + return fmt.Errorf("write claude memory prompt: %w", err) + } + return nil +} + +func (*Agent) contextError(ctx context.Context) error { + if ctx == nil { + return nil + } + return ctx.Err() +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_config.go new file mode 100644 index 0000000000..15bd305a47 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_config.go @@ -0,0 +1,73 @@ +package claude + +import ( + "fmt" + "path/filepath" + + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/common" +) + +func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error { + claude, err := agent.runConfig(config.Run) + if err != nil { + return err + } + if model == "" { + model = agent.resolveModel(claude.Model) + } + + external, err := mcp.Load() + if err != nil { + return err + } + mcpConfig := NewMCPConfigBuilder() + mcpConfig.AddURLServer("plural", common.AgentMCPServerURL).Done(). + AddServer(common.CodebaseMemoryMCPServerName, common.CodebaseMemoryMCPCommand). + Env(common.CodebaseMemoryCacheEnv, common.CodebaseMemoryCacheDir).Done() + for _, server := range external { + builder := mcpConfig.AddURLServer(server.Name, server.URL) + for name, value := range server.Headers { + builder.Header(name, value) + } + builder.Done() + } + if err := mcpConfig.WriteToFile(filepath.Join(config.WorkDir, ".mcp.json")); err != nil { + return err + } + + settings := NewSettingsBuilder(model).WithAvailableModels(model) + settings.WithEnv("BASH_DEFAULT_TIMEOUT_MS", fmt.Sprintf("%d", claude.BashTimeout.Milliseconds())) + settings.WithEnv("BASH_MAX_TIMEOUT_MS", fmt.Sprintf("%d", claude.BashMaxTimeout.Milliseconds())) + if config.Run.Mode == console.AgentRunModeAnalyze || config.Run.Mode == console.AgentRunModeReview { + settings.AllowTools( + "Read", "Grep", "Glob", "Bash(ls:*)", "Bash(cd:*)", "Bash(pwd)", + "Bash(git status)", "Bash(git diff:*)", "Bash(git branch:*)", "Bash(git log:*)", + "Bash(git show:*)", "Bash(git merge-base:*)", "Bash(git rev-parse:*)", + "Bash(head:*)", "Bash(tail:*)", "Bash(cat:*)", "Bash(grep:*)", "Bash(rg:*)", + "Bash(find:*)", "WebFetch", PluralMCPToolsWildcard, CodebaseMemoryMCPToolsWildcard, + ).AllowTools(externalMCPAllowTools(external)...).DenyTools("Edit", "Write", "Bash(rm:*)", "Bash(sudo:*)") + } else { + settings.AllowTools( + "Read", "Write", "Edit", "MultiEdit", "Bash", "WebFetch", + PluralMCPToolsWildcard, CodebaseMemoryMCPToolsWildcard, + ).AllowTools(externalMCPAllowTools(external)...) + } + return settings.WriteToFile(filepath.Join(agent.configPath(config), "settings.local.json")) +} + +func externalMCPAllowTools(servers []mcp.Server) []string { + tools := make([]string, 0) + for _, server := range servers { + if server.HasAllowedTools() { + for _, tool := range server.AllowedTools { + tools = append(tools, fmt.Sprintf("mcp__%s__%s", server.Name, tool)) + } + continue + } + tools = append(tools, fmt.Sprintf("mcp__%s__*", server.Name)) + } + return tools +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go new file mode 100644 index 0000000000..21e1caea3f --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go @@ -0,0 +1,129 @@ +package claude + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestAgentPrepareConfigureAndExport(t *testing.T) { + useClaudeSystemTemplates(t) + workDir, repositoryDir := t.TempDir(), t.TempDir() + run := claudeTestRun(console.AgentRunModeWrite, "", true) + run.Prompt = "initial prompt" + run.Skills = []agentrunv1.AgentSkill{{Name: "guide", Contents: "inspect changes"}} + agent := NewAgent(toolv1.Config{WorkDir: workDir, RepositoryDir: repositoryDir, Run: run}) + request := toolv1.FileSystemRequest{Phase: toolv1.ConfigurePhaseInitial, WorkDir: workDir, RepositoryDir: repositoryDir} + if err := agent.Prepare(context.Background(), request); err != nil { + t.Fatal(err) + } + for _, promptPath := range []string{ + filepath.Join(workDir, claudeConfigDir, "prompts", toolv1.SystemPromptFile), + filepath.Join(workDir, claudeConfigDir, claudePromptFile), + } { + prompt, err := os.ReadFile(promptPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(prompt), "initial prompt") { + t.Fatalf("prompt %q = %q", promptPath, prompt) + } + } + if _, err := os.Stat(filepath.Join(workDir, claudeConfigDir, claudeSkillsDir, "guide", "SKILL.md")); err != nil { + t.Fatal(err) + } + settings, err := agent.ResolveSettings(run) + if err != nil { + t.Fatal(err) + } + if err := agent.Configure(context.Background(), toolv1.ConfigureRequest{Phase: toolv1.ConfigurePhaseInitial, ConsoleURL: "https://console.example", ConsoleToken: "console-token", Settings: settings}); err != nil { + t.Fatal(err) + } + native, err := os.ReadFile(filepath.Join(workDir, claudeConfigDir, "settings.local.json")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"model": "claude-sonnet-4-6"`, `"availableModels": [`, `"Write"`, `"BASH_DEFAULT_TIMEOUT_MS"`} { + if !strings.Contains(string(native), want) { + t.Fatalf("native settings missing %q: %s", want, native) + } + } + if _, err := os.Stat(filepath.Join(workDir, ".mcp.json")); err != nil { + t.Fatal(err) + } + request.Phase = toolv1.ConfigurePhaseBabysit + if err := agent.Prepare(context.Background(), request); err != nil { + t.Fatal(err) + } + if err := agent.Configure(context.Background(), toolv1.ConfigureRequest{Phase: toolv1.ConfigurePhaseBabysit}); err != nil { + t.Fatal(err) + } + afterBabysit, err := os.ReadFile(filepath.Join(workDir, claudeConfigDir, "settings.local.json")) + if err != nil { + t.Fatal(err) + } + if string(native) != string(afterBabysit) { + t.Fatal("babysit configuration unexpectedly rewrote native settings") + } + projectDir := filepath.Join(workDir, claudeConfigDir, claudeProjectsDir, "project") + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectDir, "session.jsonl"), []byte("state"), 0644); err != nil { + t.Fatal(err) + } + outputDir := t.TempDir() + result, err := agent.Export(context.Background(), toolv1.ExportRequest{SessionID: "session", OutputDir: outputDir}) + if err != nil { + t.Fatal(err) + } + if result.SessionSource.Path != outputDir || result.SessionSource.ArchivePath != claudeProjectsDir { + t.Fatalf("session source = %#v", result.SessionSource) + } + if content, err := os.ReadFile(filepath.Join(outputDir, "project", "session.jsonl")); err != nil || string(content) != "state" { + t.Fatalf("staged session = %q, %v", content, err) + } +} + +func TestAgentConfigureReadOnlyPermissions(t *testing.T) { + useClaudeSystemTemplates(t) + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: claudeTestRun(console.AgentRunModeReview, "claude-opus", false)} + agent := NewAgent(config) + if err := agent.Configure(context.Background(), toolv1.ConfigureRequest{Phase: toolv1.ConfigurePhaseInitial}); err != nil { + t.Fatal(err) + } + settings, err := os.ReadFile(filepath.Join(config.WorkDir, claudeConfigDir, "settings.local.json")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"Edit"`, `"Write"`, `"Bash(rm:*)"`} { + if !strings.Contains(string(settings), want) { + t.Fatalf("settings missing deny %q", want) + } + } +} + +func claudeTestRun(mode console.AgentRunMode, model string, proxy bool) *agentrunv1.AgentRun { + return &agentrunv1.AgentRun{ID: "run-1", Mode: mode, Runtime: &agentrunv1.AgentRuntime{AiProxy: proxy, Config: &agentrunv1.AgentRuntimeConfig{Claude: &agentrunv1.ClaudeConfig{ApiKey: "api-key", Model: model, Timeout: 9 * time.Minute, BashTimeout: time.Minute, BashMaxTimeout: 2 * time.Minute}}}} +} + +func useClaudeSystemTemplates(t *testing.T) { + t.Helper() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "system"), 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"analyze", "write", "review", "babysit"} { + if err := os.WriteFile(filepath.Join(root, "system", name+".md.tmpl"), []byte(name+" {{.Prompt}}"), 0644); err != nil { + t.Fatal(err) + } + } + t.Chdir(root) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents.go deleted file mode 100644 index 6d8fe758db..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents.go +++ /dev/null @@ -1,130 +0,0 @@ -package claude - -import ( - "encoding/json" - "fmt" - - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" -) - -const ( - mcpUpdateAnalysis = "mcp__plural__updateAgentRunAnalysis" - mcpAgentPullRequest = "mcp__plural__agentPullRequest" - mcpAgentPrReview = "mcp__plural__agentPrReview" - mcpCreateBranch = "mcp__plural__createBranch" - mcpFetchTodos = "mcp__plural__fetchAgentRunTodos" - mcpUpdateTodos = "mcp__plural__updateAgentRunTodos" - mcpDownloadManifests = "mcp__plural__downloadServiceManifests" - mcpCreateCommit = "mcp__plural__createCommit" - mcpGetPRState = "mcp__plural__getPRState" - mcpGetCILogs = "mcp__plural__getCILogs" - mcpReactToComment = "mcp__plural__reactToComment" -) - -var ( - analyzePluralMCPTools = []string{mcpGetPRState, mcpUpdateAnalysis} - reviewPluralMCPTools = []string{mcpGetPRState, mcpUpdateAnalysis, mcpAgentPrReview} - codebaseMemoryMCPTools = []string{CodebaseMemoryMCPToolsWildcard} - writePluralMCPTools = []string{ - mcpAgentPullRequest, - mcpCreateBranch, - mcpFetchTodos, - mcpUpdateTodos, - mcpDownloadManifests, - mcpCreateCommit, - mcpGetPRState, - mcpGetCILogs, - mcpReactToComment, - mcpUpdateAnalysis, - } - babysitPluralMCPTools = []string{ - mcpCreateCommit, - mcpFetchTodos, - mcpUpdateTodos, - mcpGetPRState, - mcpGetCILogs, - mcpDownloadManifests, - mcpReactToComment, - } -) - -type agentDef struct { - Description string `json:"description"` - Prompt string `json:"prompt"` - Tools []string `json:"tools"` -} - -func agentJSON(name string, def agentDef) string { - payload, err := json.Marshal(map[string]agentDef{name: def}) - if err != nil { - panic(err) - } - return string(payload) -} - -func appendTools(base, extra []string) []string { - return append(append([]string(nil), base...), extra...) -} - -func externalMCPAllowTools(servers []mcp.Server) []string { - var tools []string - for _, server := range servers { - if server.HasAllowedTools() { - for _, tool := range server.AllowedTools { - tools = append(tools, fmt.Sprintf("mcp__%s__%s", server.Name, tool)) - } - continue - } - tools = append(tools, fmt.Sprintf("mcp__%s__*", server.Name)) - } - return tools -} - -func agentWithMCPTools(agentJSON string, extra []string) string { - if len(extra) == 0 { - return agentJSON - } - - payload := map[string]agentDef{} - if err := json.Unmarshal([]byte(agentJSON), &payload); err != nil { - return agentJSON - } - for name, def := range payload { - def.Tools = appendTools(def.Tools, extra) - payload[name] = def - } - out, err := json.Marshal(payload) - if err != nil { - return agentJSON - } - return string(out) -} - -var ( - analysisAgent = agentJSON("analysis", agentDef{ - Description: "Analyze code for potential issues, vulnerabilities and improvements. Use PROACTIVELY.", - Prompt: "You are a read-only autonomous analysis agent.", - Tools: appendTools(appendTools([]string{"Read", "Grep", "Glob", "Bash"}, analyzePluralMCPTools), codebaseMemoryMCPTools), - }) - reviewAgent = agentJSON("review", agentDef{ - Description: "Review pull request changes without modifying the repository. Use PROACTIVELY.", - Prompt: "You are a read-only autonomous pull request review agent.", - Tools: appendTools(appendTools([]string{"Read", "Grep", "Glob", "Bash"}, reviewPluralMCPTools), codebaseMemoryMCPTools), - }) - autonomousAgent = agentJSON("autonomous", agentDef{ - Description: "Autonomous agent for making code changes and creating pull requests. Use PROACTIVELY.", - Prompt: "You are an autonomous coding agent, highly skilled in coding and code analysis.", - Tools: appendTools( - []string{"Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob", "WebFetch"}, - appendTools(writePluralMCPTools, codebaseMemoryMCPTools), - ), - }) - babysitAgent = agentJSON("babysit", agentDef{ - Description: "Autonomous agent responding to pull request feedback. Commits to the existing PR branch. Does NOT create new PRs. Use PROACTIVELY.", - Prompt: "You are an autonomous coding agent. Your pull request is already open. Treat every human-authored PR comment as actionable unless it is clearly informational, and prioritize it over resuming the original task. Consider bot feedback and CI failures too, then commit scoped fixes to the existing branch. Push a CI fix only when logs show a defect in this PR; do not push commits for flakes such as transient network errors, third-party outages, rate limits, or runner issues.", - Tools: appendTools( - []string{"Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob", "WebFetch"}, - appendTools(babysitPluralMCPTools, codebaseMemoryMCPTools), - ), - }) -) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents_test.go deleted file mode 100644 index e2acebcdac..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package claude - -import ( - "encoding/json" - "testing" - - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" -) - -func TestExternalMCPAllowTools(t *testing.T) { - all := externalMCPAllowTools([]mcp.Server{{Name: "linear", URL: "https://mcp.linear.app/mcp"}}) - if len(all) != 1 || all[0] != "mcp__linear__*" { - t.Fatalf("wildcard tools = %#v", all) - } - - filtered := externalMCPAllowTools([]mcp.Server{{ - Name: "linear", - URL: "https://mcp.linear.app/mcp", - AllowedTools: []string{"list_issues", "create_issue"}, - }}) - if len(filtered) != 2 || filtered[0] != "mcp__linear__list_issues" || filtered[1] != "mcp__linear__create_issue" { - t.Fatalf("allowlisted tools = %#v", filtered) - } -} - -func TestAgentWithMCPTools(t *testing.T) { - out := agentWithMCPTools(analysisAgent, []string{"mcp__linear__*"}) - payload := map[string]agentDef{} - if err := json.Unmarshal([]byte(out), &payload); err != nil { - t.Fatal(err) - } - found := false - for _, tool := range payload["analysis"].Tools { - if tool == "mcp__linear__*" { - found = true - } - } - if !found { - t.Fatalf("analysis tools = %#v", payload["analysis"].Tools) - } -} - -func TestReviewAgentTools(t *testing.T) { - payload := map[string]agentDef{} - if err := json.Unmarshal([]byte(reviewAgent), &payload); err != nil { - t.Fatal(err) - } - - tools := payload["review"].Tools - for _, expected := range []string{mcpGetPRState, mcpUpdateAnalysis, mcpAgentPrReview} { - found := false - for _, tool := range tools { - if tool == expected { - found = true - break - } - } - if !found { - t.Fatalf("review tools missing %q: %#v", expected, tools) - } - } - for _, tool := range tools { - if tool == mcpAgentPullRequest || tool == mcpCreateBranch || tool == mcpCreateCommit { - t.Fatalf("review tools unexpectedly include write tool %q", tool) - } - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/artifacts.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/artifacts.go deleted file mode 100644 index 544a0ceaaa..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/artifacts.go +++ /dev/null @@ -1,16 +0,0 @@ -package claude - -import ( - "context" - "path/filepath" - - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" -) - -func (in *Claude) UploadArtifacts(ctx context.Context) (*artifacts.UploadArtifacts, error) { - return in.BuildUploadArtifacts(ctx, artifacts.BuildArtifactsOptions{ - Provider: "claude", - Source: artifacts.SessionSource{Path: filepath.Join(in.configPath(), "projects"), ArchivePath: "projects"}, - SessionID: in.sessionID, - }) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude.go deleted file mode 100644 index 4d090e79a0..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude.go +++ /dev/null @@ -1,539 +0,0 @@ -package claude - -import ( - "context" - "encoding/json" - "fmt" - "path" - "path/filepath" - "strings" - - "github.com/samber/lo" - "k8s.io/klog/v2" - - console "github.com/pluralsh/console/go/client" - - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" - "github.com/pluralsh/console/go/deployment-operator/pkg/common" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" -) - -func New(config v1.Config) v1.Tool { - result := &Claude{ - DefaultTool: v1.DefaultTool{Config: config}, - token: config.Run.Runtime.Config.Claude.ApiKey, - model: EnsureModel(config.Run.Runtime.Config.Claude.Model), - toolUseCache: make(map[string]ContentMsg), - } - - if err := result.ensure(); err != nil { - klog.Fatalf("failed to initialize claude tool: %v", err) - } - - return result -} - -func (in *Claude) Run(ctx context.Context, options ...exec.Option) { - go in.start(ctx, options...) -} - -// BabysitRun re-invokes the Claude CLI with the reprompt from bCtx. -// If bCtx is nil (PR state unchanged) it returns false to keep the babysit loop running. -// Returns true only on a fatal error that should stop the loop. -func (in *Claude) BabysitRun(ctx context.Context, bCtx *v1.BabysitContext) bool { - if bCtx == nil { - return false - } - - klog.V(log.LogLevelInfo).InfoS("babysit: PR state changed, reprompting claude", "prompt_len", len(bCtx.Prompt)) - - // Emit the reprompt as a user message so it appears in the Console conversation log. - if in.onMessage != nil { - in.onMessage(&console.AgentMessageAttributes{ - Message: bCtx.Prompt, - Role: console.AiRoleUser, - }, "") - } - - // promptFile is the absolute path to the rendered system prompt file. - promptFile := path.Join(in.Config.WorkDir, ".claude", "prompts", v1.SystemPromptFile) - agent := in.agentJSON(babysitAgent) - - args := claudeRunArgs(in.Config.RepositoryDir, promptFile, agent, in.model, bCtx.Prompt, in.sessionID) - - var envOpt exec.Option - if in.Config.Run.IsProxyEnabled() { - envOpt = exec.WithEnv(in.withConfigEnv([]string{ - fmt.Sprintf("ANTHROPIC_AUTH_TOKEN=%s", in.consoleToken), - fmt.Sprintf("ANTHROPIC_BASE_URL=%s", fmt.Sprintf("%s/ext/ai/anthropic", in.consoleURL)), - })) - } else { - env := []string{fmt.Sprintf("ANTHROPIC_API_KEY=%s", in.token)} - if in.Config.Run.Runtime.Config.Claude.Endpoint != nil { - env = append(env, fmt.Sprintf("ANTHROPIC_BASE_URL=%s", *in.Config.Run.Runtime.Config.Claude.Endpoint)) - } - envOpt = exec.WithEnv(in.withConfigEnv(env)) - } - - in.executable = exec.NewExecutable( - "claude", - envOpt, - exec.WithArgs(args), - exec.WithDir(in.Config.WorkDir), - exec.WithTimeout(in.Config.Run.Runtime.Config.Claude.Timeout), - ) - - err := in.executable.RunStream(ctx, func(line []byte) { - event := &StreamEvent{} - if err := json.Unmarshal(line, event); err != nil { - klog.ErrorS(err, "failed to unmarshal claude babysit stream event", "line", string(line)) - return - } - in.recordSessionID(event.SessionID) - if event.Message != nil { - emitClaudeContent(event, in.toolUseCache, in.Config.Usage, in.onMessage) - } - }) - if err != nil { - klog.ErrorS(err, "claude execution failed") - in.Config.ErrorChan <- err - return false - } - - klog.V(log.LogLevelExtended).InfoS("claude babysit execution finished") - return false -} - -// FollowUpRun re-runs the Claude CLI with the same agent and system prompt as -// the initial run, swapping only the -p user prompt. Errors are returned to the -// caller and must not be sent on ErrorChan. -func (in *Claude) FollowUpRun(ctx context.Context, followUpPrompt string) error { - klog.V(log.LogLevelInfo).InfoS( - "follow-up: reprompting claude", - "prompt_len", len(followUpPrompt), - "resumeSession", in.sessionID != "", - "sessionID", in.sessionID, - ) - - promptFile := path.Join(in.Config.WorkDir, ".claude", "prompts", v1.SystemPromptFile) - agent := in.agentJSON(analysisAgent) - switch in.Config.Run.Mode { - case console.AgentRunModeWrite: - agent = in.agentJSON(autonomousAgent) - case console.AgentRunModeReview: - agent = in.agentJSON(reviewAgent) - } - args := claudeRunArgs(in.Config.RepositoryDir, promptFile, agent, in.model, followUpPrompt, in.sessionID) - - var opts []exec.Option - if in.Config.Run.IsProxyEnabled() { - opts = append(opts, exec.WithEnv(in.withConfigEnv([]string{ - fmt.Sprintf("ANTHROPIC_AUTH_TOKEN=%s", in.consoleToken), - fmt.Sprintf("ANTHROPIC_BASE_URL=%s", fmt.Sprintf("%s/ext/ai/anthropic", in.consoleURL)), - }))) - } else { - env := []string{fmt.Sprintf("ANTHROPIC_API_KEY=%s", in.token)} - if in.Config.Run.Runtime.Config.Claude.Endpoint != nil { - env = append(env, fmt.Sprintf("ANTHROPIC_BASE_URL=%s", *in.Config.Run.Runtime.Config.Claude.Endpoint)) - } - opts = append(opts, exec.WithEnv(in.withConfigEnv(env))) - } - - in.executable = exec.NewExecutable( - "claude", - append( - opts, - exec.WithArgs(args), - exec.WithDir(in.Config.WorkDir), - exec.WithTimeout(in.Config.Run.Runtime.Config.Claude.Timeout), - )..., - ) - - err := in.executable.RunStream(ctx, func(line []byte) { - event := &StreamEvent{} - if err := json.Unmarshal(line, event); err != nil { - klog.ErrorS(err, "failed to unmarshal claude stream event (follow-up)", "line", string(line)) - return - } - in.recordSessionID(event.SessionID) - if event.Message != nil { - emitClaudeContent(event, in.toolUseCache, in.Config.Usage, in.onMessage) - } - }) - if err != nil { - return fmt.Errorf("claude follow-up execution failed: %w", err) - } - klog.V(log.LogLevelExtended).InfoS("claude follow-up execution finished") - return nil -} - -func (in *Claude) start(ctx context.Context, options ...exec.Option) { - promptFile := path.Join(in.Config.WorkDir, ".claude", "prompts", v1.SystemPromptFile) - agent := in.agentJSON(analysisAgent) - switch in.Config.Run.Mode { - case console.AgentRunModeWrite: - agent = in.agentJSON(autonomousAgent) - case console.AgentRunModeReview: - agent = in.agentJSON(reviewAgent) - } - args := claudeRunArgs(in.Config.RepositoryDir, promptFile, agent, in.model, in.Config.Run.Prompt, "") - - if in.Config.Run.IsProxyEnabled() { - options = append(options, - exec.WithEnv(in.withConfigEnv([]string{ - fmt.Sprintf("ANTHROPIC_AUTH_TOKEN=%s", in.consoleToken), - fmt.Sprintf("ANTHROPIC_BASE_URL=%s", fmt.Sprintf("%s/ext/ai/anthropic", in.consoleURL)), - })), - ) - } else { - env := []string{fmt.Sprintf("ANTHROPIC_API_KEY=%s", in.token)} - if in.Config.Run.Runtime.Config.Claude.Endpoint != nil { - env = append(env, fmt.Sprintf("ANTHROPIC_BASE_URL=%s", *in.Config.Run.Runtime.Config.Claude.Endpoint)) - } - options = append(options, exec.WithEnv(in.withConfigEnv(env))) - } - - in.executable = exec.NewExecutable( - "claude", - append( - options, - exec.WithArgs(args), - exec.WithDir(in.Config.WorkDir), - exec.WithTimeout(in.Config.Run.Runtime.Config.Claude.Timeout), - )..., - ) - klog.V(log.LogLevelInfo).InfoS("claude executable configured", "timeout", in.Config.Run.Runtime.Config.Claude.Timeout, "model", in.model) - - // Send the initial prompt as a message too - if in.onMessage != nil { - in.onMessage(&console.AgentMessageAttributes{Message: in.Config.Run.Prompt, Role: console.AiRoleUser}, "") - } - - err := in.executable.RunStream(ctx, func(line []byte) { - event := &StreamEvent{} - if err := json.Unmarshal(line, event); err != nil { - klog.ErrorS(err, "failed to unmarshal claude stream event", "line", string(line)) - in.Config.ErrorChan <- err - return - } - in.recordSessionID(event.SessionID) - - if event.Message != nil { - emitClaudeContent(event, in.toolUseCache, in.Config.Usage, in.onMessage) - } - }) - if err != nil { - klog.ErrorS(err, "claude execution failed") - in.Config.ErrorChan <- err - return - } - klog.V(log.LogLevelExtended).InfoS("claude execution finished") - // FinishedChan is closed by the controller after the babysit loop exits. -} - -func (in *Claude) ConfigureBabysitRun() error { - if err := in.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeClaude); err != nil { - return err - } - if err := in.ConfigureSkills(in.skillsPath()); err != nil { - return err - } - - settings := NewSettingsBuilder(in.model) - external, err := mcp.Load() - if err != nil { - return err - } - settings.AllowTools( - "Read", - "Write", - "Edit", - "MultiEdit", - "Bash", - "WebFetch", - PluralMCPToolsWildcard, - CodebaseMemoryMCPToolsWildcard, - ).AllowTools(externalMCPAllowTools(external)...) - defaultTimeout := fmt.Sprintf("%d", in.Config.Run.Runtime.Config.Claude.BashTimeout.Milliseconds()) - maxTimeout := fmt.Sprintf("%d", in.Config.Run.Runtime.Config.Claude.BashMaxTimeout.Milliseconds()) - settings.WithEnv("BASH_DEFAULT_TIMEOUT_MS", defaultTimeout) - settings.WithEnv("BASH_MAX_TIMEOUT_MS", maxTimeout) - klog.V(log.LogLevelInfo).InfoS("claude timeouts configured", "default_timeout", defaultTimeout, "max_timeout", maxTimeout) - - return settings.WriteToFile(filepath.Join(in.configPath(), "settings.local.json")) -} - -func (in *Claude) Configure(consoleURL, consoleToken string) error { - if err := in.ConfigureSystemPrompt(console.AgentRuntimeTypeClaude); err != nil { - return err - } - if err := in.ConfigureSkills(in.skillsPath()); err != nil { - return err - } - - mcpCfg := NewMCPConfigBuilder() - mcpCfg. - AddURLServer("plural", common.AgentMCPServerURL). - Done(). - AddServer(common.CodebaseMemoryMCPServerName, common.CodebaseMemoryMCPCommand). - Env(common.CodebaseMemoryCacheEnv, common.CodebaseMemoryCacheDir). - Done() - - external, err := mcp.Load() - if err != nil { - return err - } - for _, server := range external { - builder := mcpCfg.AddURLServer(server.Name, server.URL) - for name, value := range server.Headers { - builder.Header(name, value) - } - builder.Done() - } - - if err := mcpCfg.WriteToFile(filepath.Join(in.Config.WorkDir, ".mcp.json")); err != nil { - return err - } - - if in.Config.Run.IsProxyEnabled() { - in.consoleToken = consoleToken - in.consoleURL = consoleURL - } - - settings := NewSettingsBuilder(in.model) - if in.Config.Run.Mode == console.AgentRunModeAnalyze || - in.Config.Run.Mode == console.AgentRunModeReview { - settings.AllowTools( - "Read", - "Grep", - "Glob", - "Bash(ls:*)", - "Bash(cd:*)", - "Bash(pwd)", - "Bash(git status)", - "Bash(git diff:*)", - "Bash(git branch:*)", - "Bash(git log:*)", - "Bash(git show:*)", - "Bash(git merge-base:*)", - "Bash(git rev-parse:*)", - "Bash(head:*)", - "Bash(tail:*)", - "Bash(cat:*)", - "Bash(grep:*)", - "Bash(rg:*)", - "Bash(find:*)", - "WebFetch", - PluralMCPToolsWildcard, - CodebaseMemoryMCPToolsWildcard, - ).AllowTools(externalMCPAllowTools(external)...).DenyTools("Edit", "Write", "Bash(rm:*)", "Bash(sudo:*)") - } else { - settings.AllowTools( - "Read", - "Write", - "Edit", - "MultiEdit", - "Bash", - "WebFetch", - PluralMCPToolsWildcard, - CodebaseMemoryMCPToolsWildcard, - ).AllowTools(externalMCPAllowTools(external)...) - } - - defaultTimeout := fmt.Sprintf("%d", in.Config.Run.Runtime.Config.Claude.BashTimeout.Milliseconds()) - maxTimeout := fmt.Sprintf("%d", in.Config.Run.Runtime.Config.Claude.BashMaxTimeout.Milliseconds()) - settings.WithEnv("BASH_DEFAULT_TIMEOUT_MS", defaultTimeout) - settings.WithEnv("BASH_MAX_TIMEOUT_MS", maxTimeout) - klog.V(log.LogLevelInfo).InfoS("claude timeouts configured", "default_timeout", defaultTimeout, "max_timeout", maxTimeout) - - return settings.WriteToFile(filepath.Join(in.configPath(), "settings.local.json")) -} - -func (in *Claude) agentJSON(agent string) string { - servers, err := mcp.Load() - if err != nil { - klog.ErrorS(err, "failed to load external mcp servers for claude agents") - return agent - } - return agentWithMCPTools(agent, externalMCPAllowTools(servers)) -} - -func (in *Claude) configPath() string { - return path.Join(in.Config.WorkDir, ".claude") -} - -func (in *Claude) skillsPath() string { - return path.Join(in.configPath(), "skills") -} - -func (in *Claude) withConfigEnv(env []string) []string { - return append(env, fmt.Sprintf("CLAUDE_CONFIG_DIR=%s", in.configPath())) -} - -func (in *Claude) recordSessionID(sessionID string) { - if sessionID == "" { - return - } - in.sessionID = sessionID -} - -func (in *Claude) OnMessage(f v1.MessageCallback) { - in.onMessage = f -} - -func (in *Claude) ensure() error { - if len(in.Config.WorkDir) == 0 { - return fmt.Errorf("work directory is not set") - } - - if len(in.Config.RepositoryDir) == 0 { - return fmt.Errorf("repository directory is not set") - } - - if len(in.Config.WorkDir) == 0 { - return fmt.Errorf("agent run is not set") - } - - return nil -} - -func emitClaudeContent(event *StreamEvent, toolUseCache map[string]ContentMsg, recorder *usage.Usage, onMessage v1.MessageCallback) { - if onMessage == nil || event == nil || event.Message == nil { - return - } - - var textBuilder strings.Builder - for _, c := range event.Message.Content { - klog.V(log.LogLevelExtended).InfoS("claude content", "type", c.Type, "text", c.Text) - - switch c.Type { - case "tool_use": - if c.ID != "" { - toolUseCache[c.ID] = c - } - toolMsg := &console.AgentMessageAttributes{ - Role: console.AiRoleAssistant, - Message: "Called tool", - Metadata: &console.AgentMessageMetadataAttributes{ - Tool: &console.AgentMessageToolAttributes{ - Name: new(c.Name), - State: lo.ToPtr(console.AgentMessageToolStateRunning), - Output: lo.ToPtr(v1.RunningToolOutput), - }, - }, - } - if input, err := json.Marshal(c.Input); err == nil { - toolMsg.Metadata.Tool.Input = new(string(input)) - } - klog.V(log.LogLevelDebug).InfoS("claude tool use started", "tool_use_id", c.ID, "name", c.Name) - onMessage(toolMsg, c.ID) - case "tool_result": - output := "" - if c.Content != nil { - switch o := c.Content.(type) { - case string: - output = o - default: - if outputJSON, err := json.Marshal(o); err == nil { - output = string(outputJSON) - } - } - } - toolUseContent, exists := toolUseCache[c.ToolUseID] - if !exists { - toolUseContent.Name = c.ToolUseID - } - klog.V(log.LogLevelDebug).InfoS("claude tool result", "tool_use_id", c.ToolUseID, "name", toolUseContent.Name, "is_error", c.IsError, "output", output) - - state := console.AgentMessageToolStateCompleted - if c.IsError { - state = console.AgentMessageToolStateError - } - toolMsg := &console.AgentMessageAttributes{ - Role: console.AiRoleAssistant, - Message: "Called tool", - Metadata: &console.AgentMessageMetadataAttributes{ - Tool: &console.AgentMessageToolAttributes{ - Name: new(toolUseContent.Name), - State: new(state), - Output: new(output), - }, - }, - } - if input, err := json.Marshal(toolUseContent.Input); err == nil { - toolMsg.Metadata.Tool.Input = new(string(input)) - } - onMessage(toolMsg, c.ToolUseID) - case "text": - textBuilder.WriteString(c.Text) - } - } - - msg := &console.AgentMessageAttributes{ - Role: mapRole(event.Message.Role), - Message: textBuilder.String(), - } - - if event.Message.Usage != nil { - cached := event.Message.Usage.CacheCreationInputTokens + event.Message.Usage.CacheReadInputTokens - inputTokens := event.Message.Usage.InputTokens + cached - recorder.RecordUsage(usage.Record{ - InputTokens: inputTokens, - OutputTokens: event.Message.Usage.OutputTokens, - CachedTokens: cached, - }) - - total := float64(inputTokens + event.Message.Usage.OutputTokens) - input := float64(inputTokens) - output := float64(event.Message.Usage.OutputTokens) - - msg.Cost = &console.AgentMessageCostAttributes{ - Total: total, - Tokens: &console.AgentMessageTokensAttributes{ - Input: new(input), - Output: new(output), - }, - } - } - - // Empty text messages are not valid unless they carry cost metadata. - if len(msg.Message) == 0 { - if msg.Cost == nil { - return - } - msg.Message = "__plrl_ignore__" - } - - onMessage(msg, "") -} - -func mapRole(role string) console.AiRole { - switch strings.ToLower(role) { - case "assistant": - return console.AiRoleAssistant - case "system": - return console.AiRoleSystem - case "user": - return console.AiRoleUser - default: - return console.AiRoleSystem // Default to system role for unknown roles. - } -} - -func claudeRunArgs(repositoryDir, promptFile, agent string, model Model, prompt, resumeSessionID string) []string { - args := []string{ - "--add-dir", repositoryDir, - "--agents", agent, - "--system-prompt-file", promptFile, - "--model", string(model), - } - if resumeSessionID != "" { - args = append(args, "--resume", resumeSessionID, "-p", prompt) - } else { - args = append(args, "-p", prompt) - } - return append(args, "--output-format", "stream-json", "--verbose") -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_args_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_args_test.go deleted file mode 100644 index aa378f94f0..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_args_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package claude - -import "testing" - -func TestClaudeRunArgs(t *testing.T) { - args := claudeRunArgs("/repo", "/plural/.claude/prompts/AGENTS.md", "autonomous", Sonnet46, "fix tests", "") - want := []string{ - "--add-dir", "/repo", - "--agents", "autonomous", - "--system-prompt-file", "/plural/.claude/prompts/AGENTS.md", - "--model", string(Sonnet46), - "-p", "fix tests", - "--output-format", "stream-json", - "--verbose", - } - assertArgsEqual(t, want, args) -} - -func TestClaudeRunArgsResume(t *testing.T) { - sessionID := "550e8400-e29b-41d4-a716-446655440000" - args := claudeRunArgs("/repo", "/plural/.claude/prompts/AGENTS.md", "autonomous", Sonnet46, "add tests", sessionID) - want := []string{ - "--add-dir", "/repo", - "--agents", "autonomous", - "--system-prompt-file", "/plural/.claude/prompts/AGENTS.md", - "--model", string(Sonnet46), - "--resume", sessionID, - "-p", "add tests", - "--output-format", "stream-json", - "--verbose", - } - assertArgsEqual(t, want, args) -} - -func assertArgsEqual(t *testing.T, want, got []string) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("expected %d args, got %d: %v", len(want), len(got), got) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("arg[%d]: expected %q, got %q (full: %v)", i, want[i], got[i], got) - } - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_stream_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_stream_test.go deleted file mode 100644 index 42c0c37dd6..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_stream_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package claude - -import ( - "testing" - - console "github.com/pluralsh/console/go/client" - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - harnessusage "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" - "github.com/stretchr/testify/require" -) - -func TestClaudeUsageRecordsCacheTokensAsInput(t *testing.T) { - recorder := harnessusage.New(nil) - var emitted []*console.AgentMessageAttributes - emitClaudeContent(&StreamEvent{ - Message: &MessageEvent{ - Role: string(console.AiRoleAssistant), - Content: []ContentMsg{ - {Type: "text", Text: "done"}, - }, - Usage: &Usage{ - InputTokens: 10, - OutputTokens: 5, - CacheCreationInputTokens: 3, - CacheReadInputTokens: 2, - }, - }, - }, map[string]ContentMsg{}, recorder, func(message *console.AgentMessageAttributes, _ string) { - emitted = append(emitted, message) - }) - - require.Len(t, emitted, 1) - msg := emitted[0] - require.NotNil(t, msg.Cost) - require.Equal(t, float64(20), msg.Cost.Total) - require.Equal(t, float64(15), *msg.Cost.Tokens.Input) - require.Equal(t, float64(5), *msg.Cost.Tokens.Output) - - attrs := recorder.Attributes() - require.NotNil(t, attrs) - require.Equal(t, int64(15), *attrs.InputTokens) - require.Equal(t, int64(5), *attrs.OutputTokens) - require.Equal(t, int64(20), *attrs.TotalTokens) - require.Equal(t, int64(5), *attrs.CachedTokens) -} - -func TestClaudeToolUseEmitsRunningThenCompleted(t *testing.T) { - recorder := harnessusage.New(nil) - cache := map[string]ContentMsg{} - var emitted []struct { - msg *console.AgentMessageAttributes - callID string - } - cb := func(message *console.AgentMessageAttributes, callID string) { - emitted = append(emitted, struct { - msg *console.AgentMessageAttributes - callID string - }{message, callID}) - } - - emitClaudeContent(&StreamEvent{ - Message: &MessageEvent{ - Role: string(console.AiRoleAssistant), - Content: []ContentMsg{ - {Type: "tool_use", ID: "tool_1", Name: "Bash", Input: map[string]any{"command": "ls"}}, - }, - }, - }, cache, recorder, cb) - - require.Len(t, emitted, 1) - require.Equal(t, "tool_1", emitted[0].callID) - require.Equal(t, console.AgentMessageToolStateRunning, *emitted[0].msg.Metadata.Tool.State) - require.Equal(t, v1.RunningToolOutput, *emitted[0].msg.Metadata.Tool.Output) - - emitClaudeContent(&StreamEvent{ - Message: &MessageEvent{ - Role: string(console.AiRoleUser), - Content: []ContentMsg{ - {Type: "tool_result", ToolUseID: "tool_1", Content: "ok", IsError: false}, - }, - }, - }, cache, recorder, cb) - - require.Len(t, emitted, 2) - require.Equal(t, "tool_1", emitted[1].callID) - require.Equal(t, console.AgentMessageToolStateCompleted, *emitted[1].msg.Metadata.Tool.State) - require.Equal(t, "ok", *emitted[1].msg.Metadata.Tool.Output) - require.Equal(t, "Bash", *emitted[1].msg.Metadata.Tool.Name) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_templates.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_templates.go index 804e0dcf31..5a16368d6e 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_templates.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_templates.go @@ -18,12 +18,12 @@ type SettingsBuilder struct { } type Settings struct { - Model string `json:"model"` - Temperature float64 `json:"temperature"` - EnableAllProjectMcpServers bool `json:"enableAllProjectMcpServers,omitempty"` - Permissions Permissions `json:"permissions"` - Env map[string]string `json:"env,omitempty"` - Custom map[string]interface{} `json:",inline,omitempty"` + Model string `json:"model"` + Temperature float64 `json:"temperature"` + EnableAllProjectMcpServers bool `json:"enableAllProjectMcpServers,omitempty"` + Permissions Permissions `json:"permissions"` + Env map[string]string `json:"env,omitempty"` + AvailableModels []string `json:"availableModels,omitempty"` } type Permissions struct { @@ -31,21 +31,25 @@ type Permissions struct { Deny []string `json:"deny"` } -func NewSettingsBuilder(model Model) *SettingsBuilder { +func NewSettingsBuilder(model string) *SettingsBuilder { return &SettingsBuilder{ settings: Settings{ - Model: string(model), + Model: model, Temperature: 0.1, EnableAllProjectMcpServers: true, Permissions: Permissions{ Allow: []string{}, Deny: []string{}, }, - Env: make(map[string]string), - Custom: make(map[string]interface{}), + Env: make(map[string]string), }, } } + +func (b *SettingsBuilder) WithAvailableModels(models ...string) *SettingsBuilder { + b.settings.AvailableModels = append([]string(nil), models...) + return b +} func (b *SettingsBuilder) WithModel(model string) *SettingsBuilder { b.settings.Model = model return b diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_types.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_types.go deleted file mode 100644 index ddcf9622e2..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_types.go +++ /dev/null @@ -1,93 +0,0 @@ -package claude - -import ( - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" -) - -type Model string - -const ( - Sonnet45 Model = "claude-sonnet-4-5-20250929" - Sonnet46 Model = "claude-sonnet-4-6" - Opus46 Model = "claude-opus-4-6" - Opus47 Model = "claude-opus-4-7" -) - -func EnsureModel(model string) Model { - if len(model) == 0 { - return Sonnet46 - } - - return Model(model) -} - -type Claude struct { - toolv1.DefaultTool - - // onMessage is a callback called when a new message is received. - onMessage toolv1.MessageCallback - - // executable is the claude executable used to call CLI. - executable exec.Executable - - // token is the token used to authenticate with the API. - token string - - // model is the model used to generate code. - model Model - - // toolUseCache maps tool_use id to ContentMsg for resolving tool_result. - toolUseCache map[string]ContentMsg - - // consoleToken is the token used to authenticate with the console API. - consoleToken string - - // consoleURL is the URL of the console API. - consoleURL string - - // sessionID is the latest native Claude session identifier observed in stream events. - sessionID string -} - -type StreamEvent struct { - Type string `json:"type"` - Message *MessageEvent `json:"message,omitempty"` - // there are other event types but you only need `message` for now - SessionID string `json:"session_id"` - UUID string `json:"uuid"` - ParentToolUseID string `json:"parent_tool_use_id"` -} - -type MessageEvent struct { - Model string `json:"model"` - ID string `json:"id"` - Type string `json:"type"` - Role string `json:"role"` - StopReason *string `json:"stop_reason"` - StopSequence *string `json:"stop_sequence"` - Usage *Usage `json:"usage"` - Content []ContentMsg `json:"content"` -} - -type ContentMsg struct { - Type string `json:"type"` // "text", "tool_use", "tool_result" - Text string `json:"text,omitempty"` - - // Fields for tool_use - ID string `json:"id,omitempty"` // Unique tool invocation ID - Name string `json:"name,omitempty"` // Tool name (e.g., "web_search") - Input map[string]interface{} `json:"input,omitempty"` // Tool input parameters - - // Fields for tool_result - ToolUseID string `json:"tool_use_id,omitempty"` // References the tool_use ID - Content interface{} `json:"content,omitempty"` // Tool output (can be string or structured) - IsError bool `json:"is_error,omitempty"` // Whether tool execution failed -} - -type Usage struct { - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` - CacheReadInputTokens int64 `json:"cache_read_input_tokens"` -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/runtime_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/runtime_config.go new file mode 100644 index 0000000000..24f63faef2 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/runtime_config.go @@ -0,0 +1,44 @@ +package claude + +import ( + "fmt" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +const defaultModel = "claude-sonnet-4-6" + +const ( + defaultModeID = "default" + bypassModeID = "bypassPermissions" +) + +func (*Agent) resolveModel(model string) string { + if model == "" { + return defaultModel + } + return model +} + +func (agent *Agent) ResolveSettings(run *agentrunv1.AgentRun) (toolv1.Settings, error) { + claude, err := agent.runConfig(run) + if err != nil { + return toolv1.Settings{}, err + } + model := agent.resolveModel(claude.Model) + provider := console.AiProviderAnthropic + return toolv1.Settings{Mode: run.Mode, Model: toolv1.ModelSelection{Provider: &provider, Name: model}, Timeout: claude.Timeout, Proxy: run.IsProxyEnabled()}, nil +} + +func (*Agent) modeID(mode console.AgentRunMode) (string, error) { + switch mode { + case console.AgentRunModeAnalyze, console.AgentRunModeReview: + return defaultModeID, nil + case console.AgentRunModeWrite: + return bypassModeID, nil + default: + return "", fmt.Errorf("unsupported claude ACP mode %q", mode) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/session.go new file mode 100644 index 0000000000..631c15c4c3 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/session.go @@ -0,0 +1,68 @@ +package claude + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" +) + +func (agent *Agent) copySessionDirectory(ctx context.Context, source, destination string) error { + if err := os.MkdirAll(destination, 0755); err != nil { + return fmt.Errorf("create claude session export: %w", err) + } + return filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := agent.contextError(ctx); err != nil { + return err + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(destination, rel) + if entry.IsDir() { + return os.MkdirAll(target, 0755) + } + if entry.Type()&os.ModeSymlink != 0 { + link, err := os.Readlink(path) + if err != nil { + return err + } + return os.Symlink(link, target) + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + return agent.copySessionFile(path, target) + }) +} + +func (*Agent) copySessionFile(source, destination string) error { + if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + return err + } + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + info, err := input.Stat() + if err != nil { + return err + } + output, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + return err + } + defer output.Close() + _, err = io.Copy(output, input) + return err +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go new file mode 100644 index 0000000000..9ae82b3218 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go @@ -0,0 +1,74 @@ +package claude + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" +) + +const claudeACPBinary = "claude-agent-acp" + +type Transport struct { + agent *Agent + engine *acp.Engine + workDir string +} + +var _ toolv1.Transport = (*Transport)(nil) + +func NewTransport(agent *Agent) (*Transport, error) { + if agent == nil { + return nil, errors.New("claude agent is not set") + } + config, err := agent.configWithClaude() + if err != nil { + return nil, err + } + workDir, err := filepath.Abs(config.WorkDir) + if err != nil { + return nil, fmt.Errorf("resolve claude work directory: %w", err) + } + return &Transport{agent: agent, engine: acp.NewEngine(acp.Config{}), workDir: workDir}, nil +} + +func (*Transport) Kind() toolv1.TransportKind { + return toolv1.TransportKindACP +} +func (*Transport) Capabilities() toolv1.TransportCapabilities { + return toolv1.TransportCapabilities{SessionResume: true, ToolCallOutputStreaming: true, UsageReporting: true, FileSystemRead: true, FileSystemWrite: true} +} + +func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest, sink toolv1.TurnSink) (toolv1.TurnResult, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + modeID, err := transport.agent.modeID(request.Settings.Mode) + if err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + process, err := transport.launch(request.Options) + if err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + result, err := transport.engine.Turn(ctx, process, acp.Request{Cwd: transport.workDir, Prompt: request.Prompt, SessionID: request.SessionID, Settings: acp.SessionSettings{ModeID: modeID, ModelID: request.Settings.Model.Name}}, sink) + return toolv1.TurnResult{SessionID: result.SessionID}, err +} + +func (transport *Transport) launch(options []exec.Option) (*exec.StdioProcess, error) { + config := transport.agent.config + claude, err := transport.agent.runConfig(config.Run) + if err != nil { + return nil, err + } + launchOptions := append([]exec.Option(nil), options...) + launchOptions = append(launchOptions, exec.WithEnv(transport.agent.env(config)), exec.WithDir(transport.workDir), exec.WithTimeout(claude.Timeout)) + return exec.StartWithStdio(context.Background(), claudeACPBinary, launchOptions...) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go new file mode 100644 index 0000000000..60a4e56a6b --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go @@ -0,0 +1,128 @@ +package claude + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + console "github.com/pluralsh/console/go/client" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" + stackv1 "github.com/pluralsh/console/go/deployment-operator/pkg/harness/stackrun/v1" +) + +func TestTransportLaunchUsesACPAdapterAndClaudeEnvironment(t *testing.T) { + binDir, envPath := t.TempDir(), filepath.Join(t.TempDir(), "env") + writeClaudeACPBinary(t, binDir) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("CLAUDE_ENV_FILE", envPath) + endpoint := "https://api.example" + run := claudeTestRun(console.AgentRunModeWrite, "claude-opus", false) + run.Runtime.Config.Claude.Endpoint = &endpoint + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: run} + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatal(err) + } + var preStarts atomic.Int32 + process, err := transport.launch([]exec.Option{exec.WithHook(stackv1.LifecyclePreStart, func() error { preStarts.Add(1); return nil })}) + if err != nil { + t.Fatal(err) + } + go io.Copy(io.Discard, process.Stdout) + go io.Copy(io.Discard, process.Stderr) + if err := process.Wait(); err != nil { + t.Fatal(err) + } + if preStarts.Load() != 1 { + t.Fatalf("pre starts = %d", preStarts.Load()) + } + content, err := os.ReadFile(envPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{claudeConfigEnv + "=" + filepath.Join(config.WorkDir, claudeConfigDir), claudeExecutableEnv + "=" + nativeClaudeBinary, anthropicAPIKeyEnv + "=api-key", anthropicBaseURLEnv + "=" + endpoint} { + if !strings.Contains(string(content), want) { + t.Fatalf("environment missing %q: %s", want, content) + } + } +} + +func TestTransportProjectsClaudeACP(t *testing.T) { + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: claudeTestRun(console.AgentRunModeAnalyze, "", true)} + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatal(err) + } + if transport.Kind() != toolv1.TransportKindACP || !transport.Capabilities().SessionResume { + t.Fatal("transport does not advertise ACP session resume") + } + settings, err := transport.agent.ResolveSettings(config.Run) + if err != nil { + t.Fatal(err) + } + if settings.Model.Name != "claude-sonnet-4-6" { + t.Fatalf("model = %q", settings.Model.Name) + } + mode, err := transport.agent.modeID(settings.Mode) + if err != nil || mode != defaultModeID { + t.Fatalf("mode = %q, %v", mode, err) + } + mode, err = transport.agent.modeID(console.AgentRunModeWrite) + if err != nil || mode != bypassModeID { + t.Fatalf("write mode = %q, %v", mode, err) + } +} + +func TestACPEnvironmentUsesProxyCredential(t *testing.T) { + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: claudeTestRun(console.AgentRunModeWrite, "claude-sonnet", true)} + agent := NewAgent(config) + agent.consoleURL, agent.consoleToken = "https://console.example", "console-token" + values := testEnvValues(agent.env(config)) + if values[anthropicAuthEnv] != "console-token" || values[anthropicBaseURLEnv] != "https://console.example/ext/ai/anthropic" { + t.Fatalf("proxy environment = %#v", values) + } + if _, exists := values[anthropicAPIKeyEnv]; exists { + t.Fatalf("proxy environment exposed direct API key: %#v", values) + } +} + +func TestTransportTurnRejectsCancelledAndUnsupportedMode(t *testing.T) { + transport, err := NewTransport(NewAgent(toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: claudeTestRun(console.AgentRunModeWrite, "", false)})) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := transport.Turn(ctx, toolv1.TurnRequest{}, nil); !errors.Is(err, context.Canceled) { + t.Fatalf("Turn() error = %v", err) + } + if _, err := transport.Turn(nil, toolv1.TurnRequest{Settings: toolv1.Settings{Mode: console.AgentRunMode("unsupported")}}, nil); err == nil || !strings.Contains(err.Error(), "unsupported claude ACP mode") { + t.Fatalf("Turn() error = %v", err) + } +} + +func writeClaudeACPBinary(t *testing.T, binDir string) { + t.Helper() + path := filepath.Join(binDir, claudeACPBinary) + script := "#!/bin/sh\nprintf 'CLAUDE_CONFIG_DIR=%s\\nCLAUDE_CODE_EXECUTABLE=%s\\nANTHROPIC_API_KEY=%s\\nANTHROPIC_AUTH_TOKEN=%s\\nANTHROPIC_BASE_URL=%s\\n' \"$CLAUDE_CONFIG_DIR\" \"$CLAUDE_CODE_EXECUTABLE\" \"$ANTHROPIC_API_KEY\" \"$ANTHROPIC_AUTH_TOKEN\" \"$ANTHROPIC_BASE_URL\" > \"$CLAUDE_ENV_FILE\"\n" + if err := os.WriteFile(path, []byte(script), 0755); err != nil { + t.Fatal(err) + } +} + +func testEnvValues(env []string) map[string]string { + values := make(map[string]string, len(env)) + for _, item := range env { + key, value, ok := strings.Cut(item, "=") + if ok { + values[key] = value + } + } + return values +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go index 5ca3f9adff..2afd92b832 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go @@ -34,7 +34,12 @@ func New(runtimeType console.AgentRuntimeType, config v1.Config) (v1.Tool, error } return v1.NewRuntime(config, agent, transport) case console.AgentRuntimeTypeClaude: - return claude.New(config), nil + agent := claude.NewAgent(config) + transport, err := claude.NewTransport(agent) + if err != nil { + return nil, err + } + return v1.NewRuntime(config, agent, transport) case console.AgentRuntimeTypeGemini: return gemini.New(config), nil case console.AgentRuntimeTypeCodex: diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go index 43de6298f6..2e711e643f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go @@ -51,8 +51,31 @@ func TestNewComposesCodexRuntime(t *testing.T) { } } +func TestNewComposesClaudeRuntime(t *testing.T) { + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: &agentrunv1.AgentRun{ + Mode: console.AgentRunModeWrite, + Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{ + Claude: &agentrunv1.ClaudeConfig{Model: "claude-sonnet-4-6", Timeout: time.Minute}, + }}, + }} + created, err := New(console.AgentRuntimeTypeClaude, config) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if _, ok := created.(*toolv1.Runtime); !ok { + t.Fatalf("Claude factory returned %T, want *v1.Runtime", created) + } +} + func TestNewRejectsMissingAgentRun(t *testing.T) { if _, err := New(console.AgentRuntimeTypeClaude, toolv1.Config{}); err == nil { t.Fatal("New() error = nil, want missing agent run error") } } + +func TestNewRejectsMissingClaudeConfiguration(t *testing.T) { + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: &agentrunv1.AgentRun{}} + if _, err := New(console.AgentRuntimeTypeClaude, config); err == nil { + t.Fatal("New() error = nil, want missing Claude configuration error") + } +} From 2942c98d1f377abd21ec472d8e5b82fb85e3f315 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Tue, 8 Sep 2026 10:38:26 +0200 Subject: [PATCH 19/46] refactor(tool): simplify Claude configuration and migrate to templates - Replaced procedural settings logic in `agent_config.go` with template-based settings generation. - Removed `SettingsBuilder` and related implementations to streamline configuration. - Introduced `settingsTemplateInput` for templated settings rendering. - Added new `settings.local.json.gotmpl` for dynamic settings generation. - Updated `agent_test.go` to validate templated settings and remove obsolete `SettingsBuilder` tests. - Enhanced `acp_environment.go` and `templates.go` to handle Claude environment setups via templates. - Removed redundant configuration files and implementations (`claude_templates.go`, `MCPConfigBuilder`, etc.). --- .../tool/claude/acp_environment.go | 3 + .../pkg/agentrun-harness/tool/claude/agent.go | 15 ++ .../tool/claude/agent_config.go | 40 +--- .../tool/claude/agent_test.go | 10 + .../tool/claude/claude_templates.go | 197 ------------------ .../agentrun-harness/tool/claude/templates.go | 161 ++++++++++++++ .../templates/settings.local.json.gotmpl | 22 ++ .../tool/claude/templates_test.go | 98 +++++++++ 8 files changed, 318 insertions(+), 228 deletions(-) delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_templates.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/templates.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/templates/settings.local.json.gotmpl create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/templates_test.go diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go index 6b398d3fad..6da9615574 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go @@ -21,15 +21,18 @@ func (agent *Agent) env(config toolv1.Config) []string { fmt.Sprintf("%s=%s", claudeConfigEnv, agent.configPath(config)), fmt.Sprintf("%s=%s", claudeExecutableEnv, nativeClaudeBinary), } + if config.Run.IsProxyEnabled() { return append(env, fmt.Sprintf("%s=%s", anthropicAuthEnv, agent.consoleToken), fmt.Sprintf("%s=%s/ext/ai/anthropic", anthropicBaseURLEnv, agent.consoleURL), ) } + env = append(env, fmt.Sprintf("%s=%s", anthropicAPIKeyEnv, claude.ApiKey)) if claude.Endpoint != nil { env = append(env, fmt.Sprintf("%s=%s", anthropicBaseURLEnv, *claude.Endpoint)) } + return env } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go index 56ed5104fe..c6824dd4fe 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go @@ -67,6 +67,7 @@ func (agent *Agent) Prepare(ctx context.Context, request toolv1.FileSystemReques default: return fmt.Errorf("unsupported claude configuration phase %q", request.Phase) } + if err != nil { return err } @@ -76,6 +77,7 @@ func (agent *Agent) Prepare(ctx context.Context, request toolv1.FileSystemReques if err := agent.contextError(ctx); err != nil { return err } + return defaultTool.ConfigureSkills(agent.skillsPath(config)) } @@ -94,10 +96,12 @@ func (agent *Agent) Configure(ctx context.Context, request toolv1.ConfigureReque if err != nil { return err } + agent.consoleURL = request.ConsoleURL if request.ConsoleToken != "" { agent.consoleToken = request.ConsoleToken } + return agent.writeNativeConfig(config, request.Settings.Model.Name) } @@ -111,10 +115,12 @@ func (agent *Agent) Export(ctx context.Context, request toolv1.ExportRequest) (t if request.OutputDir == "" { return toolv1.ExportResult{}, errors.New("claude export output directory is not set") } + config, err := agent.configWithClaude() if err != nil { return toolv1.ExportResult{}, err } + source := filepath.Join(agent.configPath(config), claudeProjectsDir) if _, err := os.Stat(source); err != nil { if errors.Is(err, os.ErrNotExist) { @@ -122,9 +128,11 @@ func (agent *Agent) Export(ctx context.Context, request toolv1.ExportRequest) (t } return toolv1.ExportResult{}, fmt.Errorf("stat claude projects: %w", err) } + if err := agent.copySessionDirectory(ctx, source, request.OutputDir); err != nil { return toolv1.ExportResult{}, err } + return toolv1.ExportResult{SessionSource: artifacts.SessionSource{ Path: request.OutputDir, ArchivePath: claudeProjectsDir, }}, nil @@ -140,6 +148,7 @@ func (agent *Agent) configWithClaude() (toolv1.Config, error) { if _, err := agent.runConfig(agent.config.Run); err != nil { return toolv1.Config{}, err } + return agent.config, nil } @@ -153,8 +162,10 @@ func (agent *Agent) configForFilesystem(request toolv1.FileSystemRequest) (toolv if agent.config.Run == nil { return toolv1.Config{}, errors.New("agent run is not set") } + config := agent.config config.WorkDir, config.RepositoryDir = request.WorkDir, request.RepositoryDir + return config, nil } @@ -165,6 +176,7 @@ func (*Agent) runConfig(run *agentrunv1.AgentRun) (*agentrunv1.ClaudeConfig, err if run.Runtime == nil || run.Runtime.Config == nil || run.Runtime.Config.Claude == nil { return nil, errors.New("claude runtime configuration is not set") } + return run.Runtime.Config.Claude, nil } @@ -182,12 +194,14 @@ func (agent *Agent) promptPath(config toolv1.Config) string { func (agent *Agent) writeClaudePrompt(config toolv1.Config) error { source := filepath.Join(agent.configPath(config), "prompts", toolv1.SystemPromptFile) content, err := os.ReadFile(source) + if err != nil { return fmt.Errorf("read rendered claude prompt: %w", err) } if err := os.WriteFile(agent.promptPath(config), content, 0644); err != nil { return fmt.Errorf("write claude memory prompt: %w", err) } + return nil } @@ -195,5 +209,6 @@ func (*Agent) contextError(ctx context.Context) error { if ctx == nil { return nil } + return ctx.Err() } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_config.go index 15bd305a47..9e21de8c19 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_config.go @@ -38,36 +38,14 @@ func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error return err } - settings := NewSettingsBuilder(model).WithAvailableModels(model) - settings.WithEnv("BASH_DEFAULT_TIMEOUT_MS", fmt.Sprintf("%d", claude.BashTimeout.Milliseconds())) - settings.WithEnv("BASH_MAX_TIMEOUT_MS", fmt.Sprintf("%d", claude.BashMaxTimeout.Milliseconds())) - if config.Run.Mode == console.AgentRunModeAnalyze || config.Run.Mode == console.AgentRunModeReview { - settings.AllowTools( - "Read", "Grep", "Glob", "Bash(ls:*)", "Bash(cd:*)", "Bash(pwd)", - "Bash(git status)", "Bash(git diff:*)", "Bash(git branch:*)", "Bash(git log:*)", - "Bash(git show:*)", "Bash(git merge-base:*)", "Bash(git rev-parse:*)", - "Bash(head:*)", "Bash(tail:*)", "Bash(cat:*)", "Bash(grep:*)", "Bash(rg:*)", - "Bash(find:*)", "WebFetch", PluralMCPToolsWildcard, CodebaseMemoryMCPToolsWildcard, - ).AllowTools(externalMCPAllowTools(external)...).DenyTools("Edit", "Write", "Bash(rm:*)", "Bash(sudo:*)") - } else { - settings.AllowTools( - "Read", "Write", "Edit", "MultiEdit", "Bash", "WebFetch", - PluralMCPToolsWildcard, CodebaseMemoryMCPToolsWildcard, - ).AllowTools(externalMCPAllowTools(external)...) + settings := &settingsTemplateInput{ + Model: model, + BashDefaultTimeoutMS: fmt.Sprintf("%d", claude.BashTimeout.Milliseconds()), + BashMaxTimeoutMS: fmt.Sprintf("%d", claude.BashMaxTimeout.Milliseconds()), + ExternalMCPServers: external, + ReadOnly: config.Run.Mode == console.AgentRunModeAnalyze || + config.Run.Mode == console.AgentRunModeReview, } - return settings.WriteToFile(filepath.Join(agent.configPath(config), "settings.local.json")) -} - -func externalMCPAllowTools(servers []mcp.Server) []string { - tools := make([]string, 0) - for _, server := range servers { - if server.HasAllowedTools() { - for _, tool := range server.AllowedTools { - tools = append(tools, fmt.Sprintf("mcp__%s__%s", server.Name, tool)) - } - continue - } - tools = append(tools, fmt.Sprintf("mcp__%s__*", server.Name)) - } - return tools + _, err = agent.writeSettings(agent.configPath(config), settings) + return err } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go index 21e1caea3f..bfd877bc4f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go @@ -21,6 +21,7 @@ func TestAgentPrepareConfigureAndExport(t *testing.T) { run.Skills = []agentrunv1.AgentSkill{{Name: "guide", Contents: "inspect changes"}} agent := NewAgent(toolv1.Config{WorkDir: workDir, RepositoryDir: repositoryDir, Run: run}) request := toolv1.FileSystemRequest{Phase: toolv1.ConfigurePhaseInitial, WorkDir: workDir, RepositoryDir: repositoryDir} + if err := agent.Prepare(context.Background(), request); err != nil { t.Fatal(err) } @@ -36,9 +37,11 @@ func TestAgentPrepareConfigureAndExport(t *testing.T) { t.Fatalf("prompt %q = %q", promptPath, prompt) } } + if _, err := os.Stat(filepath.Join(workDir, claudeConfigDir, claudeSkillsDir, "guide", "SKILL.md")); err != nil { t.Fatal(err) } + settings, err := agent.ResolveSettings(run) if err != nil { t.Fatal(err) @@ -46,15 +49,18 @@ func TestAgentPrepareConfigureAndExport(t *testing.T) { if err := agent.Configure(context.Background(), toolv1.ConfigureRequest{Phase: toolv1.ConfigurePhaseInitial, ConsoleURL: "https://console.example", ConsoleToken: "console-token", Settings: settings}); err != nil { t.Fatal(err) } + native, err := os.ReadFile(filepath.Join(workDir, claudeConfigDir, "settings.local.json")) if err != nil { t.Fatal(err) } + for _, want := range []string{`"model": "claude-sonnet-4-6"`, `"availableModels": [`, `"Write"`, `"BASH_DEFAULT_TIMEOUT_MS"`} { if !strings.Contains(string(native), want) { t.Fatalf("native settings missing %q: %s", want, native) } } + if _, err := os.Stat(filepath.Join(workDir, ".mcp.json")); err != nil { t.Fatal(err) } @@ -65,6 +71,7 @@ func TestAgentPrepareConfigureAndExport(t *testing.T) { if err := agent.Configure(context.Background(), toolv1.ConfigureRequest{Phase: toolv1.ConfigurePhaseBabysit}); err != nil { t.Fatal(err) } + afterBabysit, err := os.ReadFile(filepath.Join(workDir, claudeConfigDir, "settings.local.json")) if err != nil { t.Fatal(err) @@ -72,6 +79,7 @@ func TestAgentPrepareConfigureAndExport(t *testing.T) { if string(native) != string(afterBabysit) { t.Fatal("babysit configuration unexpectedly rewrote native settings") } + projectDir := filepath.Join(workDir, claudeConfigDir, claudeProjectsDir, "project") if err := os.MkdirAll(projectDir, 0755); err != nil { t.Fatal(err) @@ -79,6 +87,7 @@ func TestAgentPrepareConfigureAndExport(t *testing.T) { if err := os.WriteFile(filepath.Join(projectDir, "session.jsonl"), []byte("state"), 0644); err != nil { t.Fatal(err) } + outputDir := t.TempDir() result, err := agent.Export(context.Background(), toolv1.ExportRequest{SessionID: "session", OutputDir: outputDir}) if err != nil { @@ -87,6 +96,7 @@ func TestAgentPrepareConfigureAndExport(t *testing.T) { if result.SessionSource.Path != outputDir || result.SessionSource.ArchivePath != claudeProjectsDir { t.Fatalf("session source = %#v", result.SessionSource) } + if content, err := os.ReadFile(filepath.Join(outputDir, "project", "session.jsonl")); err != nil || string(content) != "state" { t.Fatalf("staged session = %q, %v", content, err) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_templates.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_templates.go deleted file mode 100644 index 5a16368d6e..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_templates.go +++ /dev/null @@ -1,197 +0,0 @@ -package claude - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" -) - -// PluralMCPToolsWildcard allows any tool exposed by the in-pod plural MCP server. -const ( - PluralMCPToolsWildcard = "mcp__plural__*" - CodebaseMemoryMCPToolsWildcard = "mcp__codebase-memory-mcp__*" -) - -type SettingsBuilder struct { - settings Settings -} - -type Settings struct { - Model string `json:"model"` - Temperature float64 `json:"temperature"` - EnableAllProjectMcpServers bool `json:"enableAllProjectMcpServers,omitempty"` - Permissions Permissions `json:"permissions"` - Env map[string]string `json:"env,omitempty"` - AvailableModels []string `json:"availableModels,omitempty"` -} - -type Permissions struct { - Allow []string `json:"allow"` - Deny []string `json:"deny"` -} - -func NewSettingsBuilder(model string) *SettingsBuilder { - return &SettingsBuilder{ - settings: Settings{ - Model: model, - Temperature: 0.1, - EnableAllProjectMcpServers: true, - Permissions: Permissions{ - Allow: []string{}, - Deny: []string{}, - }, - Env: make(map[string]string), - }, - } -} - -func (b *SettingsBuilder) WithAvailableModels(models ...string) *SettingsBuilder { - b.settings.AvailableModels = append([]string(nil), models...) - return b -} -func (b *SettingsBuilder) WithModel(model string) *SettingsBuilder { - b.settings.Model = model - return b -} - -func (b *SettingsBuilder) WithTemperature(temp float64) *SettingsBuilder { - b.settings.Temperature = temp - return b -} - -func (b *SettingsBuilder) AllowTools(tools ...string) *SettingsBuilder { - b.settings.Permissions.Allow = append(b.settings.Permissions.Allow, tools...) - return b -} - -func (b *SettingsBuilder) DenyTools(tools ...string) *SettingsBuilder { - b.settings.Permissions.Deny = append(b.settings.Permissions.Deny, tools...) - return b -} - -func (b *SettingsBuilder) WithEnv(key, value string) *SettingsBuilder { - b.settings.Env[key] = value - return b -} - -func (b *SettingsBuilder) Build() Settings { - return b.settings -} - -func (b *SettingsBuilder) WriteToFile(path string) error { - // Create directory if needed - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - - // Marshal with indentation - data, err := json.MarshalIndent(b.settings, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal settings: %w", err) - } - - // Write to file - if err := os.WriteFile(path, data, 0644); err != nil { - return fmt.Errorf("failed to write file: %w", err) - } - - return nil -} - -type MCPConfig struct { - MCPServers map[string]MCPServer `json:"mcpServers"` -} - -type MCPServer struct { - Type string `json:"type,omitempty"` - Command string `json:"command,omitempty"` - Args []string `json:"args,omitempty"` - Env map[string]string `json:"env,omitempty"` - URL string `json:"url,omitempty"` - Headers map[string]string `json:"headers,omitempty"` -} - -type MCPConfigBuilder struct { - cfg MCPConfig -} - -func NewMCPConfigBuilder() *MCPConfigBuilder { - return &MCPConfigBuilder{ - cfg: MCPConfig{ - MCPServers: make(map[string]MCPServer), - }, - } -} - -func (b *MCPConfigBuilder) AddServer(name, command string) *MCPServerBuilder { - return &MCPServerBuilder{ - parent: b, - name: name, - server: MCPServer{Command: command, Env: map[string]string{}}, - } -} - -func (b *MCPConfigBuilder) AddURLServer(name, url string) *MCPServerBuilder { - return &MCPServerBuilder{ - parent: b, - name: name, - server: MCPServer{Type: "http", URL: url, Headers: map[string]string{}}, - } -} - -func (b *MCPConfigBuilder) Build() MCPConfig { - return b.cfg -} - -func (b *MCPConfigBuilder) ToJSON() ([]byte, error) { - return json.MarshalIndent(b.cfg, "", " ") -} - -func (b *MCPConfigBuilder) WriteToFile(path string) error { - // Create directory if needed - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - - // Marshal with indentation - data, err := json.MarshalIndent(b.cfg, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal settings: %w", err) - } - - // Write to file - if err := os.WriteFile(path, data, 0644); err != nil { - return fmt.Errorf("failed to write file: %w", err) - } - - return nil -} - -type MCPServerBuilder struct { - parent *MCPConfigBuilder - name string - server MCPServer -} - -func (sb *MCPServerBuilder) Args(args ...string) *MCPServerBuilder { - sb.server.Args = args - return sb -} - -func (sb *MCPServerBuilder) Env(key, value string) *MCPServerBuilder { - sb.server.Env[key] = value - return sb -} - -func (sb *MCPServerBuilder) Header(key, value string) *MCPServerBuilder { - sb.server.Headers[key] = value - return sb -} - -func (sb *MCPServerBuilder) Done() *MCPConfigBuilder { - sb.parent.cfg.MCPServers[sb.name] = sb.server - return sb.parent -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates.go new file mode 100644 index 0000000000..cf47c2b7ad --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates.go @@ -0,0 +1,161 @@ +package claude + +import ( + _ "embed" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" +) + +//go:embed templates/settings.local.json.gotmpl +var settingsTemplateText string + +const settingsTemplateFileName = "settings.local.json" + +type settingsTemplateInput struct { + Model string + BashDefaultTimeoutMS string + BashMaxTimeoutMS string + ExternalMCPServers []mcp.Server + ReadOnly bool +} + +func settingsTemplate(input *settingsTemplateInput) (string, error) { + quote := func(value string) (string, error) { + quoted, err := json.Marshal(value) + return string(quoted), err + } + + tmpl, err := template.New(settingsTemplateFileName).Funcs(template.FuncMap{ + "quote": quote, + }).Parse(settingsTemplateText) + if err != nil { + return "", err + } + + output := new(strings.Builder) + if err := tmpl.Execute(output, input); err != nil { + return "", err + } + + return output.String(), nil +} + +func (agent *Agent) writeSettings(basePath string, input *settingsTemplateInput) (string, error) { + if err := os.MkdirAll(basePath, 0755); err != nil { + return "", fmt.Errorf("create settings directory: %w", err) + } + + content, err := settingsTemplate(input) + if err != nil { + return "", fmt.Errorf("render settings template: %w", err) + } + + filePath := filepath.Join(basePath, settingsTemplateFileName) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return "", fmt.Errorf("write settings file: %w", err) + } + + return filePath, nil +} + +type MCPConfig struct { + MCPServers map[string]MCPServer `json:"mcpServers"` +} + +type MCPServer struct { + Type string `json:"type,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + +type MCPConfigBuilder struct { + cfg MCPConfig +} + +func NewMCPConfigBuilder() *MCPConfigBuilder { + return &MCPConfigBuilder{ + cfg: MCPConfig{ + MCPServers: make(map[string]MCPServer), + }, + } +} + +func (b *MCPConfigBuilder) AddServer(name, command string) *MCPServerBuilder { + return &MCPServerBuilder{ + parent: b, + name: name, + server: MCPServer{Command: command, Env: map[string]string{}}, + } +} + +func (b *MCPConfigBuilder) AddURLServer(name, url string) *MCPServerBuilder { + return &MCPServerBuilder{ + parent: b, + name: name, + server: MCPServer{Type: "http", URL: url, Headers: map[string]string{}}, + } +} + +func (b *MCPConfigBuilder) Build() MCPConfig { + return b.cfg +} + +func (b *MCPConfigBuilder) ToJSON() ([]byte, error) { + return json.MarshalIndent(b.cfg, "", " ") +} + +func (b *MCPConfigBuilder) WriteToFile(path string) error { + // Create directory if needed + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Marshal with indentation + data, err := json.MarshalIndent(b.cfg, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + + // Write to file + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +type MCPServerBuilder struct { + parent *MCPConfigBuilder + name string + server MCPServer +} + +func (sb *MCPServerBuilder) Args(args ...string) *MCPServerBuilder { + sb.server.Args = args + return sb +} + +func (sb *MCPServerBuilder) Env(key, value string) *MCPServerBuilder { + sb.server.Env[key] = value + return sb +} + +func (sb *MCPServerBuilder) Header(key, value string) *MCPServerBuilder { + sb.server.Headers[key] = value + return sb +} + +func (sb *MCPServerBuilder) Done() *MCPConfigBuilder { + sb.parent.cfg.MCPServers[sb.name] = sb.server + return sb.parent +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates/settings.local.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates/settings.local.json.gotmpl new file mode 100644 index 0000000000..f98597a959 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates/settings.local.json.gotmpl @@ -0,0 +1,22 @@ +{ + "model": {{ quote .Model }}, + "temperature": 0.1, + "enableAllProjectMcpServers": true, + "permissions": { + "allow": [{{ if .ReadOnly }} + "Read", "Grep", "Glob", "Bash(ls:*)", "Bash(cd:*)", "Bash(pwd)", + "Bash(git status)", "Bash(git diff:*)", "Bash(git branch:*)", "Bash(git log:*)", + "Bash(git show:*)", "Bash(git merge-base:*)", "Bash(git rev-parse:*)", + "Bash(head:*)", "Bash(tail:*)", "Bash(cat:*)", "Bash(grep:*)", "Bash(rg:*)", + "Bash(find:*)", "WebFetch", "mcp__plural__*", "mcp__codebase-memory-mcp__*"{{ else }} + "Read", "Write", "Edit", "MultiEdit", "Bash", "WebFetch", + "mcp__plural__*", "mcp__codebase-memory-mcp__*"{{ end }}{{ range .ExternalMCPServers }}{{ $server := . }}{{ if .HasAllowedTools }}{{ range .AllowedTools }}, {{ quote (printf "mcp__%s__%s" $server.Name .) }}{{ end }}{{ else }}, {{ quote (printf "mcp__%s__*" .Name) }}{{ end }}{{ end }} + ], + "deny": [{{ if .ReadOnly }}"Edit", "Write", "Bash(rm:*)", "Bash(sudo:*)"{{ end }}] + }, + "env": { + "BASH_DEFAULT_TIMEOUT_MS": {{ quote .BashDefaultTimeoutMS }}, + "BASH_MAX_TIMEOUT_MS": {{ quote .BashMaxTimeoutMS }} + }, + "availableModels": [{{ quote .Model }}] +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates_test.go new file mode 100644 index 0000000000..ed704cc55f --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates_test.go @@ -0,0 +1,98 @@ +package claude + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" +) + +type renderedSettings struct { + Model string `json:"model"` + Temperature float64 `json:"temperature"` + EnableAllProjectMCPServers bool `json:"enableAllProjectMcpServers"` + Permissions struct { + Allow []string `json:"allow"` + Deny []string `json:"deny"` + } `json:"permissions"` + Env map[string]string `json:"env"` + AvailableModels []string `json:"availableModels"` +} + +func TestSettingsTemplate(t *testing.T) { + model := "vendor\\model\n\"name\a\v" + externalServer := mcp.Server{ + Name: "external\"server", + AllowedTools: []string{"tool\nname"}, + } + wildcardServer := mcp.Server{Name: "wildcard\tserver"} + externalTool := "mcp__external\"server__tool\nname" + wildcardTool := "mcp__wildcard\tserver__*" + tests := []struct { + name string + readOnly bool + wantAllow []string + wantDeny []string + }{ + { + name: "read only", + readOnly: true, + wantAllow: []string{ + "Read", "Grep", "Glob", "Bash(ls:*)", "Bash(cd:*)", "Bash(pwd)", + "Bash(git status)", "Bash(git diff:*)", "Bash(git branch:*)", "Bash(git log:*)", + "Bash(git show:*)", "Bash(git merge-base:*)", "Bash(git rev-parse:*)", + "Bash(head:*)", "Bash(tail:*)", "Bash(cat:*)", "Bash(grep:*)", "Bash(rg:*)", + "Bash(find:*)", "WebFetch", "mcp__plural__*", "mcp__codebase-memory-mcp__*", externalTool, wildcardTool, + }, + wantDeny: []string{"Edit", "Write", "Bash(rm:*)", "Bash(sudo:*)"}, + }, + { + name: "write", + readOnly: false, + wantAllow: []string{ + "Read", "Write", "Edit", "MultiEdit", "Bash", "WebFetch", + "mcp__plural__*", "mcp__codebase-memory-mcp__*", externalTool, wildcardTool, + }, + wantDeny: []string{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + settings := renderSettingsTemplate(t, &settingsTemplateInput{ + Model: model, + BashDefaultTimeoutMS: "1000\n\"milliseconds\"", + BashMaxTimeoutMS: "2000\tmilliseconds", + ExternalMCPServers: []mcp.Server{externalServer, wildcardServer}, + ReadOnly: test.readOnly, + }) + + if settings.Model != model || settings.Temperature != 0.1 || !settings.EnableAllProjectMCPServers { + t.Fatalf("settings = %#v", settings) + } + if !reflect.DeepEqual(settings.Permissions.Allow, test.wantAllow) || !reflect.DeepEqual(settings.Permissions.Deny, test.wantDeny) { + t.Fatalf("permissions = %#v", settings.Permissions) + } + if settings.Env["BASH_DEFAULT_TIMEOUT_MS"] != "1000\n\"milliseconds\"" || settings.Env["BASH_MAX_TIMEOUT_MS"] != "2000\tmilliseconds" { + t.Fatalf("env = %#v", settings.Env) + } + if !reflect.DeepEqual(settings.AvailableModels, []string{model}) { + t.Fatalf("availableModels = %#v", settings.AvailableModels) + } + }) + } +} + +func renderSettingsTemplate(t *testing.T, input *settingsTemplateInput) renderedSettings { + t.Helper() + content, err := settingsTemplate(input) + if err != nil { + t.Fatalf("settingsTemplate() error = %v", err) + } + var settings renderedSettings + if err := json.Unmarshal([]byte(content), &settings); err != nil { + t.Fatalf("parse rendered JSON: %v\n%s", err, content) + } + return settings +} From b5b497bea8e4e4e4c73422a6ec1f859fcb4e6c68 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Tue, 8 Sep 2026 10:53:53 +0200 Subject: [PATCH 20/46] chore(ci): update Codex version to 0.153.4 in deployment workflow --- .github/workflows/deployment-operator-cd-agent-harness.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment-operator-cd-agent-harness.yaml b/.github/workflows/deployment-operator-cd-agent-harness.yaml index b78e442c46..567e04113c 100644 --- a/.github/workflows/deployment-operator-cd-agent-harness.yaml +++ b/.github/workflows/deployment-operator-cd-agent-harness.yaml @@ -185,7 +185,7 @@ jobs: - name: opencode version: 1.18.23 - name: codex - version: 1.10.0 + version: 0.153.4 - name: pi version: 0.84.1 permissions: From 64cb63d9430f54eca217afcd3222ba8a28b1602a Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Tue, 8 Sep 2026 11:33:31 +0200 Subject: [PATCH 21/46] feat(tool): add `IS_SANDBOX` environment variable support for Claude - Introduced `IS_SANDBOX` environment variable to mark sandboxed execution environments. - Updated `acp_environment.go` to conditionally set the sandbox flag for root execution scenarios. - Enhanced `transport_test.go` to validate the inclusion of the new environment variable in test cases. --- .../pkg/agentrun-harness/tool/claude/acp_environment.go | 4 ++++ .../pkg/agentrun-harness/tool/claude/transport_test.go | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go index 6da9615574..963ab69d2d 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go @@ -9,6 +9,7 @@ import ( const ( claudeConfigEnv = "CLAUDE_CONFIG_DIR" claudeExecutableEnv = "CLAUDE_CODE_EXECUTABLE" + claudeSandboxEnv = "IS_SANDBOX" anthropicAPIKeyEnv = "ANTHROPIC_API_KEY" anthropicAuthEnv = "ANTHROPIC_AUTH_TOKEN" anthropicBaseURLEnv = "ANTHROPIC_BASE_URL" @@ -20,6 +21,9 @@ func (agent *Agent) env(config toolv1.Config) []string { env := []string{ fmt.Sprintf("%s=%s", claudeConfigEnv, agent.configPath(config)), fmt.Sprintf("%s=%s", claudeExecutableEnv, nativeClaudeBinary), + // DIND runs the harness as root. The ACP adapter only advertises its + // unattended bypass mode to root inside an explicitly marked sandbox. + fmt.Sprintf("%s=1", claudeSandboxEnv), } if config.Run.IsProxyEnabled() { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go index 60a4e56a6b..a86738b596 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go @@ -46,7 +46,7 @@ func TestTransportLaunchUsesACPAdapterAndClaudeEnvironment(t *testing.T) { if err != nil { t.Fatal(err) } - for _, want := range []string{claudeConfigEnv + "=" + filepath.Join(config.WorkDir, claudeConfigDir), claudeExecutableEnv + "=" + nativeClaudeBinary, anthropicAPIKeyEnv + "=api-key", anthropicBaseURLEnv + "=" + endpoint} { + for _, want := range []string{claudeConfigEnv + "=" + filepath.Join(config.WorkDir, claudeConfigDir), claudeExecutableEnv + "=" + nativeClaudeBinary, claudeSandboxEnv + "=1", anthropicAPIKeyEnv + "=api-key", anthropicBaseURLEnv + "=" + endpoint} { if !strings.Contains(string(content), want) { t.Fatalf("environment missing %q: %s", want, content) } @@ -110,7 +110,7 @@ func TestTransportTurnRejectsCancelledAndUnsupportedMode(t *testing.T) { func writeClaudeACPBinary(t *testing.T, binDir string) { t.Helper() path := filepath.Join(binDir, claudeACPBinary) - script := "#!/bin/sh\nprintf 'CLAUDE_CONFIG_DIR=%s\\nCLAUDE_CODE_EXECUTABLE=%s\\nANTHROPIC_API_KEY=%s\\nANTHROPIC_AUTH_TOKEN=%s\\nANTHROPIC_BASE_URL=%s\\n' \"$CLAUDE_CONFIG_DIR\" \"$CLAUDE_CODE_EXECUTABLE\" \"$ANTHROPIC_API_KEY\" \"$ANTHROPIC_AUTH_TOKEN\" \"$ANTHROPIC_BASE_URL\" > \"$CLAUDE_ENV_FILE\"\n" + script := "#!/bin/sh\nprintf 'CLAUDE_CONFIG_DIR=%s\\nCLAUDE_CODE_EXECUTABLE=%s\\nIS_SANDBOX=%s\\nANTHROPIC_API_KEY=%s\\nANTHROPIC_AUTH_TOKEN=%s\\nANTHROPIC_BASE_URL=%s\\n' \"$CLAUDE_CONFIG_DIR\" \"$CLAUDE_CODE_EXECUTABLE\" \"$IS_SANDBOX\" \"$ANTHROPIC_API_KEY\" \"$ANTHROPIC_AUTH_TOKEN\" \"$ANTHROPIC_BASE_URL\" > \"$CLAUDE_ENV_FILE\"\n" if err := os.WriteFile(path, []byte(script), 0755); err != nil { t.Fatal(err) } From 79b7fe2369bdf43f5a92a8cc5102f9da2db83570 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Wed, 9 Sep 2026 14:48:43 +0200 Subject: [PATCH 22/46] feat(tool): enhance session handling and introduce Gemini agent - **Gemini Agent**: - Added implementation for `Gemini` agent with runtime, configuration, and transport logic (`agent.go`, `transport.go`, `runtime_config.go`). - Introduced session directory staging with new artifacts package (`session_staging.go`, `session_staging_test.go`). - Enhanced session export support for `Gemini` and updated `Export` logic. - **Transport Updates**: - Modified ACP session handling: - Default sessions restored via modern `ResumeSession` method. - Added session restoration and custom usage resolver options (`types.go`, `engine.go`). - Updated transport Capabilities and default engine behavior across tools. - **General Enhancements**: - Abstracted reusable session management into the `artifacts` package. - Updated `Transport` factories, replacing procedural configuration with dynamic options. - Improved capabilities definition for Gemini ACP runtime. - **Tests**: - Comprehensive tests for Gemini session handling and turn logic. - Validated non-blocking FIFOs and symlinks behavior during session staging. - Added Gemini-specific runtime configuration and export tests. - Enhanced ACP tests to align with the updated engine setup. --- .../deployment-operator-cd-agent-harness.yaml | 4 +- .../agent-harness/gemini.Dockerfile | 32 +- .../internal/controller/agentrun_pod.go | 2 +- .../agentrun-harness/tool/acp/client_test.go | 2 +- .../pkg/agentrun-harness/tool/acp/engine.go | 52 ++- .../agentrun-harness/tool/acp/engine_test.go | 191 +++++++++-- .../pkg/agentrun-harness/tool/acp/session.go | 16 +- .../agentrun-harness/tool/acp/session_test.go | 8 +- .../pkg/agentrun-harness/tool/acp/types.go | 82 ++++- .../pkg/agentrun-harness/tool/acp/updates.go | 16 + .../tool/artifacts/session_staging.go | 105 ++++++ .../tool/artifacts/session_staging_test.go | 127 +++++++ .../pkg/agentrun-harness/tool/claude/agent.go | 13 +- .../agentrun-harness/tool/claude/session.go | 68 ---- .../agentrun-harness/tool/claude/transport.go | 4 +- .../pkg/agentrun-harness/tool/codex/agent.go | 20 +- .../agentrun-harness/tool/codex/session.go | 92 ----- .../tool/codex/session_test.go | 35 -- .../agentrun-harness/tool/codex/transport.go | 2 +- .../pkg/agentrun-harness/tool/gemini/agent.go | 171 ++++++++++ .../tool/gemini/agent_config.go | 42 +++ .../tool/gemini/agent_test.go | 107 ++++++ .../agentrun-harness/tool/gemini/artifacts.go | 17 - .../tool/gemini/events/README.md | 2 - .../tool/gemini/events/base.go | 80 ----- .../tool/gemini/events/error.go | 47 --- .../tool/gemini/events/init.go | 21 -- .../tool/gemini/events/message.go | 46 --- .../tool/gemini/events/result.go | 88 ----- .../tool/gemini/events/tool_result.go | 83 ----- .../tool/gemini/events/tool_result_test.go | 36 -- .../tool/gemini/events/tool_use.go | 64 ---- .../agentrun-harness/tool/gemini/gemini.go | 318 ------------------ .../tool/gemini/gemini_args_test.go | 68 ---- .../tool/gemini/runtime_config.go | 40 +++ .../tool/gemini/runtime_config_test.go | 36 ++ .../agentrun-harness/tool/gemini/transport.go | 180 ++++++++++ .../tool/gemini/transport_test.go | 123 +++++++ .../tool/opencode/transport.go | 2 +- .../pkg/agentrun-harness/tool/tool.go | 7 +- .../pkg/agentrun-harness/tool/tool_test.go | 16 + .../pkg/agentrun-harness/usage/usage.go | 1 + 42 files changed, 1316 insertions(+), 1150 deletions(-) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/artifacts/session_staging.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/artifacts/session_staging_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/claude/session.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/session.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/codex/session_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/artifacts.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/README.md delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/base.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/error.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/init.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/message.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/result.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_use.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini_args_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go diff --git a/.github/workflows/deployment-operator-cd-agent-harness.yaml b/.github/workflows/deployment-operator-cd-agent-harness.yaml index 567e04113c..cf24848e56 100644 --- a/.github/workflows/deployment-operator-cd-agent-harness.yaml +++ b/.github/workflows/deployment-operator-cd-agent-harness.yaml @@ -32,7 +32,7 @@ jobs: env: NODE_VERSION: 24.11.1 CLAUDE_VERSION: 2.1.236 - GEMINI_VERSION: 0.44.1 + GEMINI_VERSION: 0.58.0 OPENCODE_VERSION: 1.18.23 CODEX_VERSION: 0.153.4 PI_VERSION: 0.84.1 @@ -181,7 +181,7 @@ jobs: - name: claude version: 2.1.236 - name: gemini - version: 0.44.1 + version: 0.58.0 - name: opencode version: 1.18.23 - name: codex diff --git a/go/deployment-operator/dockerfiles/agent-harness/gemini.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/gemini.Dockerfile index 1ea8a7896a..94f9d81c31 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/gemini.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/gemini.Dockerfile @@ -1,6 +1,6 @@ ARG NODE_IMAGE_TAG=24 ARG NODE_IMAGE=node:${NODE_IMAGE_TAG}-slim -ARG AGENT_VERSION=0.44.1 +ARG AGENT_VERSION=0.58.0 ARG AGENT_HARNESS_BASE_IMAGE_TAG=latest ARG AGENT_HARNESS_BASE_IMAGE_REPO=ghcr.io/pluralsh/agent-harness-base @@ -20,6 +20,36 @@ RUN npm install -g @google/gemini-cli@$AGENT_VERSION # Copy to a fixed, predictable path RUN cp -r $(npm root -g)/@google/gemini-cli /opt/gemini-cli +# Gemini ACP replays loaded-session history asynchronously. Make the session +# load response wait for that replay, so clients can safely begin a new turn. +# Fail the image build if the pinned upstream artifact changes this call site. +RUN node -e "\ + const fs = require('fs'); \ + const path = require('path'); \ + const root = '/opt/gemini-cli/bundle'; \ + const needle = 'session.streamHistory(sessionData.messages);'; \ + const replacement = 'await session.streamHistory(sessionData.messages);'; \ + const files = []; \ + const visit = directory => { \ + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { \ + const file = path.join(directory, entry.name); \ + if (entry.isDirectory()) visit(file); \ + else if (entry.isFile() && file.endsWith('.js')) files.push(file); \ + } \ + }; \ + visit(root); \ + const matches = files.filter(file => fs.readFileSync(file, 'utf8').includes(needle)); \ + if (matches.length !== 3) throw new Error('expected three Gemini history calls, found ' + matches.length); \ + for (const file of matches) { \ + const source = fs.readFileSync(file, 'utf8'); \ + if (source.split(needle).length - 1 !== 1) throw new Error('unexpected Gemini history call count in ' + file); \ + fs.writeFileSync(file, source.replace(needle, replacement)); \ + const patched = fs.readFileSync(file, 'utf8'); \ + if (patched.split(replacement).length - 1 !== 1 || patched.replace(replacement, '').includes(needle)) { \ + throw new Error('Gemini history patch verification failed in ' + file); \ + } \ + }" + # Resolve the actual bin entry point from package.json and save it RUN node -e "\ const pkg = require('/opt/gemini-cli/package.json'); \ diff --git a/go/deployment-operator/internal/controller/agentrun_pod.go b/go/deployment-operator/internal/controller/agentrun_pod.go index b75b5c7619..e652deb4ab 100644 --- a/go/deployment-operator/internal/controller/agentrun_pod.go +++ b/go/deployment-operator/internal/controller/agentrun_pod.go @@ -122,7 +122,7 @@ var ( // Check .github/workflows/deployment-operator-cd-agent-harness.yaml to see images being published. defaultContainerVersions = map[console.AgentRuntimeType]string{ console.AgentRuntimeTypeClaude: "%s-claude-2.1.236", - console.AgentRuntimeTypeGemini: "%s-gemini-0.44.1", + console.AgentRuntimeTypeGemini: "%s-gemini-0.58.0", console.AgentRuntimeTypeOpencode: "%s-opencode-1.18.23", console.AgentRuntimeTypeCodex: "%s-codex-0.153.4", console.AgentRuntimeTypePi: "%s-pi-0.84.1", diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go index fc2ec51985..5052046b00 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -14,7 +14,7 @@ import acpsdk "github.com/coder/acp-go-sdk" func newTestClient(t *testing.T) (*client, string) { t.Helper() - engine := NewEngine(Config{}) + engine := NewEngine() return &client{turn: newTurn(engine, &testSink{}, "session-1")}, t.TempDir() } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go index c38d2b0923..93baab1bbd 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go @@ -23,14 +23,17 @@ const defaultStopTimeout = 15 * time.Second // Engine owns one provider-neutral ACP protocol implementation. It does not // launch processes or retain provider configuration. type Engine struct { - stopTimeout time.Duration - costs *usage.Usage + stopTimeout time.Duration + costs *usage.Usage + restoreSession SessionRestorer + usageResolver UsageResolver } func (engine *Engine) setSessionConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption, settings SessionSettings) error { if err := engine.setModelConfig(ctx, connection, sessionID, options, settings.ModelID, settings.Reasoning); err != nil { return err } + return engine.setModeConfig(ctx, connection, sessionID, modes, options, settings.ModeID) } @@ -38,13 +41,16 @@ func (engine *Engine) setModelConfig(ctx context.Context, connection *acpsdk.Cli if model == "" { return engine.setReasoningConfig(ctx, connection, sessionID, options, reasoning) } + found, err := engine.setConfigOption(ctx, connection, sessionID, options, "model", model) if err != nil { return err } + if !found { klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a model config option") } + return engine.setReasoningConfig(ctx, connection, sessionID, options, reasoning) } @@ -52,13 +58,16 @@ func (engine *Engine) setReasoningConfig(ctx context.Context, connection *acpsdk if reasoning == "" { return nil } + found, err := engine.setConfigOption(ctx, connection, sessionID, options, "reasoning_effort", reasoning) if err != nil { return err } + if !found { klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a reasoning effort config option") } + return nil } @@ -66,19 +75,23 @@ func (engine *Engine) setModeConfig(ctx context.Context, connection *acpsdk.Clie if mode == "" { return nil } + if engine.modeAvailable(modes, mode) { if _, err := connection.SetSessionMode(ctx, acpsdk.SetSessionModeRequest{SessionId: acpsdk.SessionId(sessionID), ModeId: acpsdk.SessionModeId(mode)}); err != nil { return fmt.Errorf("acp session/set_mode: %w", err) } return nil } + found, err := engine.setConfigOption(ctx, connection, sessionID, options, "mode", mode) if err != nil { return err } + if !found { klog.V(log.LogLevelDebug).InfoS("ACP agent did not advertise a mode config option", "mode", mode) } + return nil } @@ -86,11 +99,13 @@ func (*Engine) modeAvailable(modes *acpsdk.SessionModeState, mode string) bool { if modes == nil { return false } + for _, available := range modes.AvailableModes { if string(available.Id) == mode { return true } } + return false } @@ -99,21 +114,26 @@ func (engine *Engine) setConfigOption(ctx context.Context, connection *acpsdk.Cl if option.Select == nil || string(option.Select.Id) != configID { continue } + wanted := acpsdk.SessionConfigValueId(value) if option.Select.CurrentValue == wanted { return true, nil } + if !engine.configOptionContains(option.Select.Options, wanted) { return true, fmt.Errorf("acp %s %q is not advertised", configID, value) } _, err := connection.SetSessionConfigOption(ctx, acpsdk.SetSessionConfigOptionRequest{ValueId: &acpsdk.SetSessionConfigOptionValueId{ ConfigId: option.Select.Id, SessionId: acpsdk.SessionId(sessionID), Value: wanted, }}) + if err != nil { return true, fmt.Errorf("acp session/set_config_option %s: %w", configID, err) } + return true, nil } + return false, nil } @@ -152,6 +172,7 @@ func (engine *Engine) Turn(ctx context.Context, process *exec.StdioProcess, requ } return Result{SessionID: request.SessionID}, errors.New("acp working directory is not set") } + if sink == nil { if process != nil { _ = process.Stop() @@ -159,6 +180,7 @@ func (engine *Engine) Turn(ctx context.Context, process *exec.StdioProcess, requ } return Result{}, errors.New("acp turn sink is not set") } + if process == nil || process.Stdin == nil || process.Stdout == nil { if process != nil { _ = process.Stop() @@ -166,23 +188,33 @@ func (engine *Engine) Turn(ctx context.Context, process *exec.StdioProcess, requ } return Result{}, errors.New("acp process is incomplete") } + if ctx == nil { ctx = context.Background() } + attempt := newSessionAttempt(engine, ctx, process, request, sink) defer attempt.close() err := attempt.run(request.Prompt) + return Result{SessionID: attempt.sessionID}, err } -// NewEngine creates an ACP engine with a bounded process shutdown grace -// period. -func NewEngine(config Config) *Engine { - if config.StopTimeout <= 0 { - config.StopTimeout = defaultStopTimeout +// NewEngine creates an ACP engine with bounded shutdown, session restoration, +// and standard prompt usage defaults. +func NewEngine(options ...Option) *Engine { + engine := &Engine{ + stopTimeout: defaultStopTimeout, + costs: usage.New(nil), + restoreSession: ResumeSession, + usageResolver: defaultUsageResolver, } - return &Engine{ - stopTimeout: config.StopTimeout, - costs: usage.New(nil), + + for _, option := range options { + if option != nil { + option(engine) + } } + + return engine } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go index f267514fbd..fa51e8a897 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -17,25 +17,28 @@ import ( ) type testState struct { - mu sync.Mutex - sessionID string - newSessions []acpsdk.NewSessionRequest - resumedSessions []acpsdk.ResumeSessionRequest - prompts []string - initializations []acpsdk.InitializeRequest - setConfig []acpsdk.SetSessionConfigOptionRequest - setModes []acpsdk.SetSessionModeRequest - cancels []acpsdk.CancelNotification - newSessionUpdates []acpsdk.SessionNotification - promptUpdates []acpsdk.SessionUpdate - configOptions []acpsdk.SessionConfigOption - modes *acpsdk.SessionModeState - responseUsage *acpsdk.Usage - stopReason acpsdk.StopReason - promptStarted chan struct{} - promptRelease chan struct{} - promptOnce sync.Once - protocolVersion int + mu sync.Mutex + sessionID string + newSessions []acpsdk.NewSessionRequest + resumedSessions []acpsdk.ResumeSessionRequest + loadedSessions []acpsdk.LoadSessionRequest + loadSessionUpdates []acpsdk.SessionNotification + prompts []string + initializations []acpsdk.InitializeRequest + setConfig []acpsdk.SetSessionConfigOptionRequest + setModes []acpsdk.SetSessionModeRequest + cancels []acpsdk.CancelNotification + newSessionUpdates []acpsdk.SessionNotification + promptUpdates []acpsdk.SessionUpdate + configOptions []acpsdk.SessionConfigOption + modes *acpsdk.SessionModeState + responseUsage *acpsdk.Usage + responseMeta map[string]any + stopReason acpsdk.StopReason + promptStarted chan struct{} + promptRelease chan struct{} + promptOnce sync.Once + protocolVersion int } type testAgent struct { @@ -124,7 +127,7 @@ func (agent *testAgent) Prompt(ctx context.Context, request acpsdk.PromptRequest if stopReason == "" { stopReason = acpsdk.StopReasonEndTurn } - return acpsdk.PromptResponse{StopReason: stopReason, Usage: usageValue}, nil + return acpsdk.PromptResponse{StopReason: stopReason, Usage: usageValue, Meta: agent.state.responseMeta}, nil } func (agent *testAgent) ResumeSession(_ context.Context, request acpsdk.ResumeSessionRequest) (acpsdk.ResumeSessionResponse, error) { @@ -136,6 +139,21 @@ func (agent *testAgent) ResumeSession(_ context.Context, request acpsdk.ResumeSe return acpsdk.ResumeSessionResponse{ConfigOptions: options, Modes: modes}, nil } +func (agent *testAgent) LoadSession(ctx context.Context, request acpsdk.LoadSessionRequest) (acpsdk.LoadSessionResponse, error) { + agent.state.mu.Lock() + agent.state.loadedSessions = append(agent.state.loadedSessions, request) + updates := append([]acpsdk.SessionNotification(nil), agent.state.loadSessionUpdates...) + options := append([]acpsdk.SessionConfigOption(nil), agent.state.configOptions...) + modes := agent.state.modes + agent.state.mu.Unlock() + for _, update := range updates { + if err := agent.conn.SessionUpdate(ctx, update); err != nil { + return acpsdk.LoadSessionResponse{}, err + } + } + return acpsdk.LoadSessionResponse{ConfigOptions: options, Modes: modes}, nil +} + func (agent *testAgent) SetSessionConfigOption(_ context.Context, request acpsdk.SetSessionConfigOptionRequest) (acpsdk.SetSessionConfigOptionResponse, error) { agent.state.mu.Lock() agent.state.setConfig = append(agent.state.setConfig, request) @@ -297,6 +315,20 @@ func newTestAgentProcess(state *testState, stdinCloseEnds bool) (*testState, *ex return state, stdio, process } +func TestNewEngineOptionsPreserveDefaultsAndApplyOverrides(t *testing.T) { + standard := &acpsdk.Usage{InputTokens: 3} + defaults := NewEngine(WithStopTimeout(0), WithSessionRestorer(nil), WithUsageResolver(nil)) + if defaults.stopTimeout != defaultStopTimeout || defaults.restoreSession == nil || defaults.usageResolver(acpsdk.PromptResponse{Usage: standard}) != standard { + t.Fatalf("default engine = %#v", defaults) + } + + resolver := func(acpsdk.PromptResponse) *acpsdk.Usage { return &acpsdk.Usage{InputTokens: 5} } + configured := NewEngine(WithStopTimeout(time.Second), WithSessionRestorer(LoadSession), WithUsageResolver(resolver)) + if configured.stopTimeout != time.Second || configured.restoreSession == nil || configured.usageResolver(acpsdk.PromptResponse{}).InputTokens != 5 { + t.Fatalf("configured engine = %#v", configured) + } +} + func (state *testState) snapshot() (newCount, resumeCount, promptCount, configCount, modeCount, cancelCount int, prompts []string) { state.mu.Lock() defer state.mu.Unlock() @@ -323,7 +355,7 @@ func (process *testProcess) killCount() int { func TestEngineTurnCreatesAndResumesSession(t *testing.T) { state := newTestState() - engine := NewEngine(Config{StopTimeout: time.Second}) + engine := NewEngine(WithStopTimeout(time.Second)) firstSink := &testSink{} _, firstProcess, _ := newTestAgentProcess(state, true) first, err := engine.Turn(context.Background(), firstProcess, Request{Cwd: t.TempDir(), Prompt: "first"}, firstSink) @@ -345,13 +377,67 @@ func TestEngineTurnCreatesAndResumesSession(t *testing.T) { } } +func TestEngineTurnLoadsSessionWhenConfigured(t *testing.T) { + state := newTestState() + state.configOptions = []acpsdk.SessionConfigOption{{Select: &acpsdk.SessionConfigOptionSelect{ + Id: "model", CurrentValue: "default", Options: acpsdk.SessionConfigSelectOptions{ + Ungrouped: &acpsdk.SessionConfigSelectOptionsUngrouped{{Value: "default"}, {Value: "configured"}}, + }, + }}} + state.modes = &acpsdk.SessionModeState{AvailableModes: []acpsdk.SessionMode{{Id: "analysis"}}} + engine := NewEngine(WithSessionRestorer(LoadSession)) + _, firstProcess, _ := newTestAgentProcess(state, true) + first, err := engine.Turn(context.Background(), firstProcess, Request{Cwd: t.TempDir(), Prompt: "first"}, &testSink{}) + if err != nil { + t.Fatalf("create turn: %v", err) + } + _, secondProcess, _ := newTestAgentProcess(state, true) + second, err := engine.Turn(context.Background(), secondProcess, Request{ + Cwd: t.TempDir(), Prompt: "second", SessionID: first.SessionID, + Settings: SessionSettings{ModelID: "configured", ModeID: "analysis"}, + }, &testSink{}) + if err != nil { + t.Fatalf("load turn: %v", err) + } + state.mu.Lock() + loads := append([]acpsdk.LoadSessionRequest(nil), state.loadedSessions...) + resumes := len(state.resumedSessions) + configures := append([]acpsdk.SetSessionConfigOptionRequest(nil), state.setConfig...) + modes := append([]acpsdk.SetSessionModeRequest(nil), state.setModes...) + state.mu.Unlock() + if len(loads) != 1 || string(loads[0].SessionId) != first.SessionID || second.SessionID != first.SessionID || resumes != 0 || len(configures) != 1 || len(modes) != 1 { + t.Fatalf("loads = %#v resumes = %d configures = %#v modes = %#v result = %q", loads, resumes, configures, modes, second.SessionID) + } +} + +func TestEngineTurnSuppressesLoadSessionHistory(t *testing.T) { + state := newTestState() + state.loadSessionUpdates = []acpsdk.SessionNotification{ + {SessionId: "session-1", Update: acpsdk.UpdateAgentMessageText("prior assistant")}, + {SessionId: "session-1", Update: acpsdk.StartToolCall("prior-tool", "shell")}, + } + state.promptUpdates = []acpsdk.SessionUpdate{acpsdk.UpdateAgentMessageText("current assistant")} + _, process, _ := newTestAgentProcess(state, true) + sink := &testSink{} + if _, err := NewEngine(WithSessionRestorer(LoadSession)).Turn(context.Background(), process, Request{ + Cwd: t.TempDir(), Prompt: "current", SessionID: "session-1", + }, sink); err != nil { + t.Fatalf("load turn: %v", err) + } + sink.mu.Lock() + defer sink.mu.Unlock() + if len(sink.messages) != 1 || sink.messages[0].Message != "current assistant" { + t.Fatalf("messages = %#v", sink.messages) + } +} + func TestEngineTurnAppliesModelAndModeConfig(t *testing.T) { state := newTestState() state.configOptions = []acpsdk.SessionConfigOption{ {Select: &acpsdk.SessionConfigOptionSelect{Id: "model", CurrentValue: "default", Options: acpsdk.SessionConfigSelectOptions{Ungrouped: &acpsdk.SessionConfigSelectOptionsUngrouped{{Value: "default"}, {Value: "configured"}}}}}, {Select: &acpsdk.SessionConfigOptionSelect{Id: "mode", CurrentValue: "default", Options: acpsdk.SessionConfigSelectOptions{Ungrouped: &acpsdk.SessionConfigSelectOptionsUngrouped{{Value: "default"}, {Value: "analysis"}}}}}, } - engine := NewEngine(Config{}) + engine := NewEngine() _, process, _ := newTestAgentProcess(state, true) _, err := engine.Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "configure", Settings: SessionSettings{ModelID: "configured", ModeID: "analysis"}}, &testSink{}) if err != nil { @@ -370,7 +456,7 @@ func TestEngineTurnAppliesModelAndReasoningEffort(t *testing.T) { {Select: &acpsdk.SessionConfigOptionSelect{Id: "reasoning_effort", CurrentValue: "low", Options: acpsdk.SessionConfigSelectOptions{Ungrouped: &acpsdk.SessionConfigSelectOptionsUngrouped{{Value: "low"}, {Value: "medium"}}}}}, } _, process, _ := newTestAgentProcess(state, true) - _, err := NewEngine(Config{}).Turn(context.Background(), process, Request{ + _, err := NewEngine().Turn(context.Background(), process, Request{ Cwd: t.TempDir(), Prompt: "configure", Settings: SessionSettings{ModelID: "openai/gpt-5.4", Reasoning: "medium"}, }, &testSink{}) if err != nil { @@ -399,7 +485,7 @@ func TestEngineTurnStreamsMessagesToolsUsageAndOrdering(t *testing.T) { state.promptUpdates = append(state.promptUpdates, acpsdk.SessionUpdate{UsageUpdate: &acpsdk.SessionUsageUpdate{Cost: &acpsdk.Cost{Amount: 7}}}) sink := &testSink{} _, process, _ := newTestAgentProcess(state, true) - if _, err := NewEngine(Config{}).Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "stream"}, sink); err != nil { + if _, err := NewEngine().Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "stream"}, sink); err != nil { t.Fatalf("streaming turn: %v", err) } sink.mu.Lock() @@ -449,11 +535,58 @@ func TestEngineTurnStreamsMessagesToolsUsageAndOrdering(t *testing.T) { } } +func TestEngineTurnAdaptsPromptMetadataUsage(t *testing.T) { + state := newTestState() + state.responseMeta = map[string]any{"tokens": float64(12)} + sink := &testSink{} + _, process, _ := newTestAgentProcess(state, true) + adapter := func(response acpsdk.PromptResponse) *acpsdk.Usage { + tokens, ok := response.Meta["tokens"].(float64) + if !ok { + return nil + } + return &acpsdk.Usage{InputTokens: int(tokens), TotalTokens: int(tokens)} + } + if _, err := NewEngine(WithUsageResolver(adapter)).Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "metadata"}, sink); err != nil { + t.Fatalf("metadata usage turn: %v", err) + } + sink.mu.Lock() + defer sink.mu.Unlock() + if len(sink.usages) != 1 || sink.usages[0].InputTokens != 12 || sink.usages[0].TotalTokens != 12 { + t.Fatalf("usage = %#v", sink.usages) + } + if len(sink.messages) != 1 || sink.messages[0].Cost == nil || *sink.messages[0].Cost.Tokens.Input != 12 { + t.Fatalf("messages = %#v", sink.messages) + } +} + +func TestEngineTurnPrefersStandardPromptUsage(t *testing.T) { + state := newTestState() + state.responseUsage = &acpsdk.Usage{InputTokens: 8, TotalTokens: 8} + state.responseMeta = map[string]any{"tokens": float64(12)} + sink := &testSink{} + _, process, _ := newTestAgentProcess(state, true) + resolver := func(response acpsdk.PromptResponse) *acpsdk.Usage { + if response.Usage != nil { + return response.Usage + } + return &acpsdk.Usage{InputTokens: 12, TotalTokens: 12} + } + if _, err := NewEngine(WithUsageResolver(resolver)).Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "standard"}, sink); err != nil { + t.Fatalf("standard usage turn: %v", err) + } + sink.mu.Lock() + defer sink.mu.Unlock() + if len(sink.usages) != 1 || sink.usages[0].InputTokens != 8 || sink.usages[0].TotalTokens != 8 { + t.Fatalf("usage = %#v", sink.usages) + } +} + func TestEngineTurnRejectsMismatchedEarlyBinding(t *testing.T) { state := newTestState() state.newSessionUpdates = []acpsdk.SessionNotification{{SessionId: "other", Update: acpsdk.UpdateAgentMessageText("wrong")}} _, process, _ := newTestAgentProcess(state, true) - _, err := NewEngine(Config{}).Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "mismatch"}, &testSink{}) + _, err := NewEngine().Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "mismatch"}, &testSink{}) if err == nil || !strings.Contains(err.Error(), `belongs to session "other"`) { t.Fatalf("mismatch error = %v", err) } @@ -467,7 +600,7 @@ func TestEngineTurnCancellationKillsUncooperativeProcess(t *testing.T) { startedAt := time.Now() result := make(chan error, 1) go func() { - _, err := NewEngine(Config{StopTimeout: 20 * time.Millisecond}).Turn(ctx, process, Request{Cwd: t.TempDir(), Prompt: "cancel"}, &testSink{}) + _, err := NewEngine(WithStopTimeout(20*time.Millisecond)).Turn(ctx, process, Request{Cwd: t.TempDir(), Prompt: "cancel"}, &testSink{}) result <- err }() select { @@ -500,7 +633,7 @@ func TestEngineTurnIgnoresCleanupKillAfterSuccessfulPrompt(t *testing.T) { state := newTestState() _, process, cleanup := newTestAgentProcess(state, false) cleanup.stopReportsKill = true - _, err := NewEngine(Config{StopTimeout: 10 * time.Millisecond}).Turn(context.Background(), process, Request{ + _, err := NewEngine(WithStopTimeout(10*time.Millisecond)).Turn(context.Background(), process, Request{ Cwd: t.TempDir(), Prompt: "complete", }, &testSink{}) if err != nil { @@ -512,7 +645,7 @@ func TestEngineTurnPreservesPromptStopReasonAfterCleanupKill(t *testing.T) { state := newTestState() state.stopReason = acpsdk.StopReasonMaxTokens _, process, _ := newTestAgentProcess(state, false) - _, err := NewEngine(Config{StopTimeout: 10 * time.Millisecond}).Turn(context.Background(), process, Request{ + _, err := NewEngine(WithStopTimeout(10*time.Millisecond)).Turn(context.Background(), process, Request{ Cwd: t.TempDir(), Prompt: "complete", }, &testSink{}) if err == nil || !strings.Contains(err.Error(), string(acpsdk.StopReasonMaxTokens)) { @@ -526,7 +659,7 @@ func TestEngineTurnPreservesPromptStopReasonAfterCleanupKill(t *testing.T) { func TestSessionAttemptPreservesSpontaneousExit(t *testing.T) { naturalExit := errors.New("agent exited with status 17") attempt := &sessionAttempt{ - engine: NewEngine(Config{StopTimeout: time.Second}), + engine: NewEngine(WithStopTimeout(time.Second)), process: exec.NewStdioProcess(nil, nil, nil, exec.StdioProcessHooks{Wait: func() error { return naturalExit }}), } if err := attempt.waitForExit(); !errors.Is(err, naturalExit) { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go index ee78e5dede..f1c285231e 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go @@ -59,7 +59,7 @@ func (attempt *sessionAttempt) run(prompt string) error { return attempt.promptFailure(err) } - attempt.finishTurn(response.Usage) + attempt.finishTurn(response) if err = attempt.turn.err(); err != nil { return attempt.fail(err, attempt.cancelled()) } @@ -138,13 +138,11 @@ func (attempt *sessionAttempt) createSession(cwd string) (sessionDetails, error) } func (attempt *sessionAttempt) resumeSession(cwd, sessionID string) (sessionDetails, error) { - resumed, err := attempt.connection.ResumeSession(attempt.ctx, acpsdk.ResumeSessionRequest{ - Cwd: cwd, - McpServers: []acpsdk.McpServer{}, - SessionId: acpsdk.SessionId(sessionID), - }) + attempt.turn.setRestoring(true) + defer attempt.turn.setRestoring(false) + resumed, err := attempt.engine.restoreSession(attempt.ctx, attempt.connection, SessionRestoreRequest{Cwd: cwd, SessionID: sessionID}) if err != nil { - return sessionDetails{}, fmt.Errorf("acp session/resume: %w", err) + return sessionDetails{}, fmt.Errorf("acp session restore: %w", err) } attempt.turn.setSessionID(sessionID) attempt.sessionID = sessionID @@ -163,8 +161,8 @@ func (attempt *sessionAttempt) prompt(prompt, sessionID string) (acpsdk.PromptRe }) } -func (attempt *sessionAttempt) finishTurn(usage *acpsdk.Usage) { - attempt.turn.emitAssistant(usage) +func (attempt *sessionAttempt) finishTurn(response acpsdk.PromptResponse) { + attempt.turn.emitAssistant(attempt.engine.usageResolver(response)) } func (attempt *sessionAttempt) close() { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go index a0fcce4e38..56202fc04c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go @@ -22,7 +22,7 @@ func TestEngineTurnReportsInitializeProcessFailure(t *testing.T) { t.Fatalf("start helper: %v", err) } - _, err = NewEngine(Config{}).Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "prompt"}, &testSink{}) + _, err = NewEngine().Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "prompt"}, &testSink{}) if err == nil { t.Fatal("initialize failure succeeded") } @@ -57,7 +57,7 @@ func TestEngineTurnDeliversUpdatesSentBeforeSessionResponse(t *testing.T) { state.promptUpdates = []acpsdk.SessionUpdate{acpsdk.UpdateAgentMessageText("response")} sink := &testSink{} _, process, _ := newTestAgentProcess(state, true) - if _, err := NewEngine(Config{}).Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "prompt"}, sink); err != nil { + if _, err := NewEngine().Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "prompt"}, sink); err != nil { t.Fatalf("early update turn: %v", err) } sink.mu.Lock() @@ -74,7 +74,7 @@ func TestEngineTurnUsesAdvertisedSessionMode(t *testing.T) { CurrentModeId: "default", } _, process, _ := newTestAgentProcess(state, true) - if _, err := NewEngine(Config{}).Turn(context.Background(), process, Request{ + if _, err := NewEngine().Turn(context.Background(), process, Request{ Cwd: t.TempDir(), Prompt: "mode", Settings: SessionSettings{ModeID: "analysis"}, }, &testSink{}); err != nil { t.Fatalf("mode turn: %v", err) @@ -89,7 +89,7 @@ func TestEngineTurnRejectsUnsupportedProtocolVersion(t *testing.T) { state := newTestState() state.protocolVersion = acpsdk.ProtocolVersionNumber + 1 _, process, _ := newTestAgentProcess(state, true) - _, err := NewEngine(Config{}).Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "version"}, &testSink{}) + _, err := NewEngine().Turn(context.Background(), process, Request{Cwd: t.TempDir(), Prompt: "version"}, &testSink{}) if err == nil || !strings.Contains(err.Error(), "protocol version") { t.Fatalf("protocol version error = %v", err) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go index 440fc80e96..00ccde3b01 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go @@ -1,8 +1,11 @@ package acp import ( + "context" "time" + acpsdk "github.com/coder/acp-go-sdk" + console "github.com/pluralsh/console/go/client" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" ) @@ -36,7 +39,80 @@ type Sink interface { Usage(usage.Record) } -// Config controls the ACP process shutdown grace period. -type Config struct { - StopTimeout time.Duration +// SessionRestoreRequest identifies a prior session and its workspace. +type SessionRestoreRequest struct { + Cwd string + SessionID string +} + +// SessionRestoreResponse contains the setup state returned after restoring a +// provider session. +type SessionRestoreResponse struct { + Modes *acpsdk.SessionModeState + ConfigOptions []acpsdk.SessionConfigOption +} + +// SessionRestorer restores a prior session through the provider's supported +// ACP method. ACP versions differ between session/resume and session/load. +type SessionRestorer func(context.Context, *acpsdk.ClientSideConnection, SessionRestoreRequest) (SessionRestoreResponse, error) + +// ResumeSession restores sessions through the modern ACP session/resume +// method. It is the default for providers that support it. +func ResumeSession(ctx context.Context, connection *acpsdk.ClientSideConnection, request SessionRestoreRequest) (SessionRestoreResponse, error) { + response, err := connection.ResumeSession(ctx, acpsdk.ResumeSessionRequest{ + Cwd: request.Cwd, McpServers: []acpsdk.McpServer{}, SessionId: acpsdk.SessionId(request.SessionID), + }) + if err != nil { + return SessionRestoreResponse{}, err + } + return SessionRestoreResponse{Modes: response.Modes, ConfigOptions: response.ConfigOptions}, nil +} + +// LoadSession restores sessions through ACP's session/load method. +func LoadSession(ctx context.Context, connection *acpsdk.ClientSideConnection, request SessionRestoreRequest) (SessionRestoreResponse, error) { + response, err := connection.LoadSession(ctx, acpsdk.LoadSessionRequest{ + Cwd: request.Cwd, McpServers: []acpsdk.McpServer{}, SessionId: acpsdk.SessionId(request.SessionID), + }) + if err != nil { + return SessionRestoreResponse{}, err + } + return SessionRestoreResponse{Modes: response.Modes, ConfigOptions: response.ConfigOptions}, nil +} + +// UsageResolver returns usage from a prompt response. Providers can decode +// their metadata without coupling the provider-neutral engine to a schema. +type UsageResolver func(acpsdk.PromptResponse) *acpsdk.Usage + +func defaultUsageResolver(prompt acpsdk.PromptResponse) *acpsdk.Usage { + return prompt.Usage +} + +// Option configures an Engine after its defaults are established. +type Option func(*Engine) + +// WithStopTimeout sets a positive process shutdown grace period. +func WithStopTimeout(timeout time.Duration) Option { + return func(engine *Engine) { + if timeout > 0 { + engine.stopTimeout = timeout + } + } +} + +// WithSessionRestorer sets a non-nil provider session restoration method. +func WithSessionRestorer(restorer SessionRestorer) Option { + return func(engine *Engine) { + if restorer != nil { + engine.restoreSession = restorer + } + } +} + +// WithUsageResolver sets a non-nil provider prompt usage resolver. +func WithUsageResolver(resolver UsageResolver) Option { + return func(engine *Engine) { + if resolver != nil { + engine.usageResolver = resolver + } + } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go index eabeb928b5..e2dff5e974 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go @@ -24,6 +24,7 @@ type turnState struct { reasoning strings.Builder tools map[string]*toolCall cost float64 + restoring bool } func (turn *turnState) contentText(content acpsdk.ContentBlock) (string, error) { @@ -64,6 +65,18 @@ func (turn *turnState) setSessionID(sessionID string) { turn.mu.Unlock() } +func (turn *turnState) setRestoring(restoring bool) { + turn.mu.Lock() + turn.restoring = restoring + turn.mu.Unlock() +} + +func (turn *turnState) isRestoring() bool { + turn.mu.Lock() + defer turn.mu.Unlock() + return turn.restoring +} + func (turn *turnState) err() error { turn.mu.Lock() defer turn.mu.Unlock() @@ -85,6 +98,9 @@ func (turn *turnState) handle(notification acpsdk.SessionNotification) error { if err := turn.bindNotification(notification.SessionId); err != nil { return err } + if turn.isRestoring() { + return nil + } update := notification.Update switch { case update.AgentMessageChunk != nil: diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/artifacts/session_staging.go b/go/deployment-operator/pkg/agentrun-harness/tool/artifacts/session_staging.go new file mode 100644 index 0000000000..d734c8bac8 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/artifacts/session_staging.go @@ -0,0 +1,105 @@ +package artifacts + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" +) + +// StageSessionDirectory copies a provider-native session directory into a +// disposable artifact staging directory. It preserves directories, symlinks, +// and regular-file permissions, while ignoring special files such as FIFOs. +func StageSessionDirectory(ctx context.Context, source, destination string) (bool, error) { + info, err := os.Stat(source) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("stat session directory %q: %w", source, err) + } + if !info.IsDir() { + return false, fmt.Errorf("session directory %q is not a directory", source) + } + + if err := os.MkdirAll(destination, 0755); err != nil { + return false, fmt.Errorf("create session staging directory %q: %w", destination, err) + } + + err = filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if ctx != nil { + if err := ctx.Err(); err != nil { + return err + } + } + + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(destination, rel) + + if entry.IsDir() { + return os.MkdirAll(target, 0755) + } + if entry.Type()&os.ModeSymlink != 0 { + return stageSessionSymlink(path, target) + } + + entryInfo, err := entry.Info() + if err != nil { + return err + } + if !entryInfo.Mode().IsRegular() { + return nil + } + + return stageSessionFile(path, target, entryInfo.Mode().Perm()) + }) + if err != nil { + return false, fmt.Errorf("stage session directory %q: %w", source, err) + } + + return true, nil +} + +func stageSessionSymlink(source, destination string) error { + link, err := os.Readlink(source) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + return err + } + return os.Symlink(link, destination) +} + +func stageSessionFile(source, destination string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + return err + } + + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + + output, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer func() { _ = output.Close() }() + + if _, err := io.Copy(output, input); err != nil { + return err + } + if err := output.Chmod(mode); err != nil { + return err + } + return output.Close() +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/artifacts/session_staging_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/artifacts/session_staging_test.go new file mode 100644 index 0000000000..0cbc32a8a9 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/artifacts/session_staging_test.go @@ -0,0 +1,127 @@ +package artifacts + +import ( + "context" + "errors" + "os" + stdexec "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestStageSessionDirectory(t *testing.T) { + source := t.TempDir() + nested := filepath.Join(source, "nested") + if err := os.Mkdir(nested, 0755); err != nil { + t.Fatal(err) + } + file := filepath.Join(nested, "session.jsonl") + if err := os.WriteFile(file, []byte("session data"), 0640); err != nil { + t.Fatal(err) + } + if err := os.Chmod(file, 0640); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("nested", "session.jsonl"), filepath.Join(source, "latest")); err != nil { + t.Fatal(err) + } + + destination := filepath.Join(t.TempDir(), "staged") + found, err := StageSessionDirectory(nil, source, destination) + if err != nil { + t.Fatalf("StageSessionDirectory() error = %v", err) + } + if !found { + t.Fatal("StageSessionDirectory() found = false, want true") + } + + assertMode(t, filepath.Join(destination, "nested", "session.jsonl"), 0640) + content, err := os.ReadFile(filepath.Join(destination, "nested", "session.jsonl")) + if err != nil { + t.Fatal(err) + } + if string(content) != "session data" { + t.Fatalf("staged content = %q", content) + } + link, err := os.Readlink(filepath.Join(destination, "latest")) + if err != nil { + t.Fatal(err) + } + if link != filepath.Join("nested", "session.jsonl") { + t.Fatalf("staged symlink target = %q", link) + } +} + +func TestStageSessionDirectoryMissingSource(t *testing.T) { + found, err := StageSessionDirectory(context.Background(), filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "staged")) + if err != nil { + t.Fatalf("StageSessionDirectory() error = %v", err) + } + if found { + t.Fatal("StageSessionDirectory() found = true, want false") + } +} + +func TestStageSessionDirectoryRejectsFile(t *testing.T) { + source := filepath.Join(t.TempDir(), "session.jsonl") + if err := os.WriteFile(source, []byte("session"), 0644); err != nil { + t.Fatal(err) + } + + _, err := StageSessionDirectory(context.Background(), source, filepath.Join(t.TempDir(), "staged")) + if err == nil { + t.Fatal("StageSessionDirectory() error = nil, want non-nil") + } +} + +func TestStageSessionDirectoryHonorsCancellation(t *testing.T) { + source := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := StageSessionDirectory(ctx, source, filepath.Join(t.TempDir(), "staged")) + if !errors.Is(err, context.Canceled) { + t.Fatalf("StageSessionDirectory() error = %v, want context canceled", err) + } +} + +func TestStageSessionDirectorySkipsFIFO(t *testing.T) { + source := t.TempDir() + if err := os.WriteFile(filepath.Join(source, "session.jsonl"), []byte("session"), 0644); err != nil { + t.Fatal(err) + } + fifo := filepath.Join(source, "blocked.pipe") + if err := stdexec.Command("mkfifo", fifo).Run(); err != nil { + t.Skipf("mkfifo is unavailable: %v", err) + } + + done := make(chan error, 1) + destination := t.TempDir() + go func() { + _, err := StageSessionDirectory(context.Background(), source, destination) + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatalf("StageSessionDirectory() error = %v", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("StageSessionDirectory() blocked on FIFO") + } + if _, err := os.Lstat(filepath.Join(destination, "blocked.pipe")); !os.IsNotExist(err) { + t.Fatalf("FIFO was copied, lstat error = %v", err) + } +} + +func assertMode(t *testing.T, path string, want os.FileMode) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("mode for %q = %o, want %o", path, got, want) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go index c6824dd4fe..785b3b883e 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go @@ -122,15 +122,12 @@ func (agent *Agent) Export(ctx context.Context, request toolv1.ExportRequest) (t } source := filepath.Join(agent.configPath(config), claudeProjectsDir) - if _, err := os.Stat(source); err != nil { - if errors.Is(err, os.ErrNotExist) { - return toolv1.ExportResult{}, nil - } - return toolv1.ExportResult{}, fmt.Errorf("stat claude projects: %w", err) + found, err := artifacts.StageSessionDirectory(ctx, source, request.OutputDir) + if err != nil { + return toolv1.ExportResult{}, fmt.Errorf("stage claude projects: %w", err) } - - if err := agent.copySessionDirectory(ctx, source, request.OutputDir); err != nil { - return toolv1.ExportResult{}, err + if !found { + return toolv1.ExportResult{}, nil } return toolv1.ExportResult{SessionSource: artifacts.SessionSource{ diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/session.go deleted file mode 100644 index 631c15c4c3..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/session.go +++ /dev/null @@ -1,68 +0,0 @@ -package claude - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" -) - -func (agent *Agent) copySessionDirectory(ctx context.Context, source, destination string) error { - if err := os.MkdirAll(destination, 0755); err != nil { - return fmt.Errorf("create claude session export: %w", err) - } - return filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if err := agent.contextError(ctx); err != nil { - return err - } - rel, err := filepath.Rel(source, path) - if err != nil { - return err - } - target := filepath.Join(destination, rel) - if entry.IsDir() { - return os.MkdirAll(target, 0755) - } - if entry.Type()&os.ModeSymlink != 0 { - link, err := os.Readlink(path) - if err != nil { - return err - } - return os.Symlink(link, target) - } - info, err := entry.Info() - if err != nil { - return err - } - if !info.Mode().IsRegular() { - return nil - } - return agent.copySessionFile(path, target) - }) -} - -func (*Agent) copySessionFile(source, destination string) error { - if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { - return err - } - input, err := os.Open(source) - if err != nil { - return err - } - defer input.Close() - info, err := input.Stat() - if err != nil { - return err - } - output, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) - if err != nil { - return err - } - defer output.Close() - _, err = io.Copy(output, input) - return err -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go index 9ae82b3218..a3938ed9fd 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go @@ -33,14 +33,14 @@ func NewTransport(agent *Agent) (*Transport, error) { if err != nil { return nil, fmt.Errorf("resolve claude work directory: %w", err) } - return &Transport{agent: agent, engine: acp.NewEngine(acp.Config{}), workDir: workDir}, nil + return &Transport{agent: agent, engine: acp.NewEngine(), workDir: workDir}, nil } func (*Transport) Kind() toolv1.TransportKind { return toolv1.TransportKindACP } func (*Transport) Capabilities() toolv1.TransportCapabilities { - return toolv1.TransportCapabilities{SessionResume: true, ToolCallOutputStreaming: true, UsageReporting: true, FileSystemRead: true, FileSystemWrite: true} + return toolv1.TransportCapabilities{SessionResume: true, ToolCallOutputStreaming: false, UsageReporting: true, FileSystemRead: true, FileSystemWrite: true} } func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest, sink toolv1.TurnSink) (toolv1.TurnResult, error) { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go index 74d4d7379f..51ea161583 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go @@ -2,9 +2,7 @@ package codex import ( "context" - "errors" "fmt" - "os" "path/filepath" "github.com/samber/lo" @@ -18,8 +16,9 @@ import ( // These paths keep Codex's native state, prompt, and skills inside the // workspace owned by this Agent. const ( - codexHomeDir = ".codex" - codexSkillsDir = "skills" + codexHomeDir = ".codex" + codexSessionsDir = "sessions" + codexSkillsDir = "skills" ) // Agent owns Codex settings, shared prompt and skills preparation, native @@ -132,15 +131,12 @@ func (agent *Agent) Export(ctx context.Context, request toolv1.ExportRequest) (t return toolv1.ExportResult{}, err } source := filepath.Join(agent.codexHome(config), codexSessionsDir) - if _, err := os.Stat(source); err != nil { - if errors.Is(err, os.ErrNotExist) { - return toolv1.ExportResult{}, nil - } - return toolv1.ExportResult{}, fmt.Errorf("stat codex sessions: %w", err) + found, err := artifacts.StageSessionDirectory(ctx, source, request.OutputDir) + if err != nil { + return toolv1.ExportResult{}, fmt.Errorf("stage codex sessions: %w", err) } - - if err := agent.copySessionDirectory(ctx, source, request.OutputDir); err != nil { - return toolv1.ExportResult{}, err + if !found { + return toolv1.ExportResult{}, nil } return toolv1.ExportResult{SessionSource: artifacts.SessionSource{ Path: request.OutputDir, diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/session.go deleted file mode 100644 index 26c43ed135..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/session.go +++ /dev/null @@ -1,92 +0,0 @@ -package codex - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" -) - -// Export preserves Codex's provider-owned session subtree under this relative -// path so resumed runs retain their native session files. -const codexSessionsDir = "sessions" - -func (agent *Agent) copySessionDirectory(ctx context.Context, source, destination string) error { - info, err := os.Stat(source) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return fmt.Errorf("stat codex sessions: %w", err) - } - if !info.IsDir() { - return fmt.Errorf("codex sessions path %q is not a directory", source) - } - - if err := os.MkdirAll(destination, 0755); err != nil { - return fmt.Errorf("create codex session export: %w", err) - } - - return filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - - if err := agent.contextError(ctx); err != nil { - return err - } - - rel, err := filepath.Rel(source, path) - if err != nil { - return err - } - target := filepath.Join(destination, rel) - - if entry.IsDir() { - return os.MkdirAll(target, 0755) - } - if entry.Type()&os.ModeSymlink != 0 { - link, err := os.Readlink(path) - if err != nil { - return err - } - return os.Symlink(link, target) - } - info, err := entry.Info() - if err != nil { - return err - } - if !info.Mode().IsRegular() { - return nil - } - return agent.copySessionFile(path, target) - }) -} - -func (agent *Agent) copySessionFile(source, destination string) error { - if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { - return err - } - - input, err := os.Open(source) - if err != nil { - return err - } - defer input.Close() - - info, err := input.Stat() - if err != nil { - return err - } - - output, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) - if err != nil { - return err - } - defer output.Close() - if _, err := io.Copy(output, input); err != nil { - return err - } - return nil -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/session_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/session_test.go deleted file mode 100644 index cc747c5be4..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/session_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package codex - -import ( - "context" - "os" - stdexec "os/exec" - "path/filepath" - "testing" - "time" -) - -func TestAgentSessionExportSkipsFIFO(t *testing.T) { - source := t.TempDir() - if err := os.WriteFile(filepath.Join(source, "session.jsonl"), []byte("session"), 0644); err != nil { - t.Fatal(err) - } - fifo := filepath.Join(source, "blocked.pipe") - if err := stdexec.Command("mkfifo", fifo).Run(); err != nil { - t.Skipf("mkfifo is unavailable: %v", err) - } - destination := t.TempDir() - done := make(chan error, 1) - go func() { done <- (&Agent{}).copySessionDirectory(context.Background(), source, destination) }() - select { - case err := <-done: - if err != nil { - t.Fatalf("copySessionDirectory() error = %v", err) - } - case <-time.After(500 * time.Millisecond): - t.Fatal("copySessionDirectory() blocked on FIFO") - } - if _, err := os.Stat(filepath.Join(destination, "blocked.pipe")); !os.IsNotExist(err) { - t.Fatalf("FIFO was copied, stat error = %v", err) - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go index 0662b8a7c6..a822b53bc4 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go @@ -39,7 +39,7 @@ func NewTransport(agent *Agent) (*Transport, error) { } return &Transport{ agent: agent, - engine: acp.NewEngine(acp.Config{}), + engine: acp.NewEngine(), repositoryDir: repositoryDir, }, nil } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go new file mode 100644 index 0000000000..88fa7f11e3 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go @@ -0,0 +1,171 @@ +package gemini + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +const ( + geminiHomeDir = ".gemini" + geminiSkillsDir = "skills" + geminiChatsDir = "chats" +) + +type Agent struct { + config toolv1.Config +} + +var _ toolv1.Agent = (*Agent)(nil) + +func NewAgent(config toolv1.Config) *Agent { + return &Agent{config: config} +} + +func (*Agent) Type() console.AgentRuntimeType { + return console.AgentRuntimeTypeGemini +} + +func (*Agent) Capabilities() toolv1.AgentCapabilities { + return toolv1.AgentCapabilities{Modes: []console.AgentRunMode{ + console.AgentRunModeAnalyze, + console.AgentRunModeWrite, + console.AgentRunModeReview, + }} +} + +func (agent *Agent) Prepare(ctx context.Context, request toolv1.FileSystemRequest) error { + if err := agent.contextError(ctx); err != nil { + return err + } + config, err := agent.configForFilesystem(request) + if err != nil { + return err + } + + defaultTool := toolv1.DefaultTool{Config: config} + switch request.Phase { + case toolv1.ConfigurePhaseInitial: + err = defaultTool.ConfigureSystemPrompt(console.AgentRuntimeTypeGemini) + case toolv1.ConfigurePhaseBabysit: + err = defaultTool.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeGemini) + default: + return fmt.Errorf("unsupported gemini configuration phase %q", request.Phase) + } + if err != nil { + return err + } + if err := agent.contextError(ctx); err != nil { + return err + } + return defaultTool.ConfigureSkills(agent.skillsPath(config)) +} + +func (agent *Agent) Configure(ctx context.Context, request toolv1.ConfigureRequest) error { + if err := agent.contextError(ctx); err != nil { + return err + } + if request.Phase != toolv1.ConfigurePhaseInitial && request.Phase != toolv1.ConfigurePhaseBabysit { + return fmt.Errorf("unsupported gemini configuration phase %q", request.Phase) + } + if request.Phase == toolv1.ConfigurePhaseBabysit { + return nil + } + + config, err := agent.configWithGemini() + if err != nil { + return err + } + return agent.writeNativeConfig(config, request.Settings.Model.Name) +} + +func (agent *Agent) Export(ctx context.Context, request toolv1.ExportRequest) (toolv1.ExportResult, error) { + if err := agent.contextError(ctx); err != nil { + return toolv1.ExportResult{}, err + } + if request.SessionID == "" { + return toolv1.ExportResult{}, errors.New("gemini session id is not set") + } + if request.OutputDir == "" { + return toolv1.ExportResult{}, errors.New("gemini export output directory is not set") + } + config, err := agent.configWithGemini() + if err != nil { + return toolv1.ExportResult{}, err + } + + source := agent.chatsPath(config) + found, err := artifacts.StageSessionDirectory(ctx, source, request.OutputDir) + if err != nil { + return toolv1.ExportResult{}, fmt.Errorf("stage gemini chats: %w", err) + } + if !found { + return toolv1.ExportResult{}, nil + } + return toolv1.ExportResult{SessionSource: artifacts.SessionSource{ + Path: request.OutputDir, ArchivePath: geminiChatsDir, + }}, nil +} + +func (agent *Agent) configWithGemini() (toolv1.Config, error) { + if agent.config.WorkDir == "" { + return toolv1.Config{}, errors.New("work directory is not set") + } + if agent.config.RepositoryDir == "" { + return toolv1.Config{}, errors.New("repository directory is not set") + } + if _, err := agent.runConfig(agent.config.Run); err != nil { + return toolv1.Config{}, err + } + return agent.config, nil +} + +func (agent *Agent) configForFilesystem(request toolv1.FileSystemRequest) (toolv1.Config, error) { + if request.WorkDir == "" { + return toolv1.Config{}, errors.New("work directory is not set") + } + if request.RepositoryDir == "" { + return toolv1.Config{}, errors.New("repository directory is not set") + } + if agent.config.Run == nil { + return toolv1.Config{}, errors.New("agent run is not set") + } + config := agent.config + config.WorkDir, config.RepositoryDir = request.WorkDir, request.RepositoryDir + return config, nil +} + +func (*Agent) runConfig(run *agentrunv1.AgentRun) (*agentrunv1.GeminiConfig, error) { + if run == nil { + return nil, errors.New("agent run is not set") + } + if run.Runtime == nil || run.Runtime.Config == nil || run.Runtime.Config.Gemini == nil { + return nil, errors.New("gemini runtime configuration is not set") + } + return run.Runtime.Config.Gemini, nil +} + +func (agent *Agent) geminiHome(config toolv1.Config) string { + return filepath.Join(config.WorkDir, geminiHomeDir) +} + +func (agent *Agent) skillsPath(config toolv1.Config) string { + return filepath.Join(agent.geminiHome(config), geminiSkillsDir) +} + +func (agent *Agent) chatsPath(config toolv1.Config) string { + return filepath.Join(agent.geminiHome(config), "tmp", "plural", geminiChatsDir) +} + +func (*Agent) contextError(ctx context.Context) error { + if ctx == nil { + return nil + } + return ctx.Err() +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go new file mode 100644 index 0000000000..1d76100af8 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go @@ -0,0 +1,42 @@ +package gemini + +import ( + "fmt" + "os" + "path/filepath" + + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error { + gemini, err := agent.runConfig(config.Run) + if err != nil { + return err + } + if err := agent.validateMode(config.Run.Mode); err != nil { + return err + } + if model == "" { + model = agent.resolveModel(gemini.Model) + } + + input := &ConfigTemplateInput{ + Model: Model(model), + RepositoryDir: config.RepositoryDir, + AgentRunID: config.Run.ID, + AgentRunMode: config.Run.Mode, + InactivityTimeout: int64(gemini.InactivityTimeout.Seconds()), + GitAccessToken: os.Getenv("GIT_ACCESS_TOKEN"), + } + _, content, err := settings(input) + if err != nil { + return err + } + if err := os.MkdirAll(agent.geminiHome(config), 0755); err != nil { + return fmt.Errorf("create gemini settings directory: %w", err) + } + if err := os.WriteFile(filepath.Join(agent.geminiHome(config), SettingsFileName), []byte(content), 0644); err != nil { + return fmt.Errorf("write gemini settings: %w", err) + } + return nil +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go new file mode 100644 index 0000000000..1341698c2c --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go @@ -0,0 +1,107 @@ +package gemini + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestAgentPrepareWritesPromptAndSkills(t *testing.T) { + useGeminiSystemTemplates(t) + workDir := t.TempDir() + run := geminiTestRun(console.AgentRunModeAnalyze, "", nil) + run.Skills = []agentrunv1.AgentSkill{{Name: "repository", Contents: "Use the repository guidance."}} + agent := NewAgent(toolv1.Config{Run: run}) + request := toolv1.FileSystemRequest{Phase: toolv1.ConfigurePhaseInitial, WorkDir: workDir, RepositoryDir: t.TempDir()} + if err := agent.Prepare(context.Background(), request); err != nil { + t.Fatalf("Prepare() error = %v", err) + } + prompt, err := os.ReadFile(filepath.Join(workDir, geminiHomeDir, toolv1.SystemPromptFile)) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(prompt), "analyze") { + t.Fatalf("prompt = %q", prompt) + } + if _, err := os.Stat(filepath.Join(workDir, geminiHomeDir, geminiSkillsDir, "repository", "SKILL.md")); err != nil { + t.Fatalf("skill: %v", err) + } + request.Phase = toolv1.ConfigurePhaseBabysit + if err := agent.Prepare(context.Background(), request); err != nil { + t.Fatalf("Prepare(babysit) error = %v", err) + } + prompt, err = os.ReadFile(filepath.Join(workDir, geminiHomeDir, toolv1.SystemPromptFile)) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(prompt), "babysit") { + t.Fatalf("babysit prompt = %q", prompt) + } +} + +func TestAgentConfigureWritesSettings(t *testing.T) { + workDir := t.TempDir() + run := geminiTestRun(console.AgentRunModeReview, "", nil) + agent := NewAgent(toolv1.Config{WorkDir: workDir, RepositoryDir: "/repo", Run: run}) + if err := agent.Configure(context.Background(), toolv1.ConfigureRequest{Phase: toolv1.ConfigurePhaseInitial}); err != nil { + t.Fatalf("Configure() error = %v", err) + } + settings, err := os.ReadFile(filepath.Join(workDir, geminiHomeDir, SettingsFileName)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(settings), defaultModel) || strings.Contains(string(settings), "WriteFileTool") { + t.Fatalf("settings = %s", settings) + } +} + +func TestAgentExportStagesChats(t *testing.T) { + workDir := t.TempDir() + chatDir := filepath.Join(workDir, geminiHomeDir, "tmp", "plural", geminiChatsDir) + if err := os.MkdirAll(chatDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(chatDir, "chat.json"), []byte("chat"), 0644); err != nil { + t.Fatal(err) + } + agent := NewAgent(toolv1.Config{WorkDir: workDir, RepositoryDir: t.TempDir(), Run: geminiTestRun(console.AgentRunModeWrite, "", nil)}) + output := t.TempDir() + result, err := agent.Export(context.Background(), toolv1.ExportRequest{SessionID: "session-1", OutputDir: output}) + if err != nil { + t.Fatalf("Export() error = %v", err) + } + if result.SessionSource.Path != output || result.SessionSource.ArchivePath != geminiChatsDir { + t.Fatalf("session source = %#v", result.SessionSource) + } + content, err := os.ReadFile(filepath.Join(output, "chat.json")) + if err != nil || string(content) != "chat" { + t.Fatalf("staged chat = %q, %v", content, err) + } +} + +func geminiTestRun(mode console.AgentRunMode, model string, endpoint *string) *agentrunv1.AgentRun { + return &agentrunv1.AgentRun{Mode: mode, Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{ + Gemini: &agentrunv1.GeminiConfig{APIKey: "api-key", Model: model, Timeout: time.Minute, InactivityTimeout: time.Second, Endpoint: endpoint}, + }}} +} + +func useGeminiSystemTemplates(t *testing.T) { + t.Helper() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "system"), 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"analyze", "write", "review", "babysit"} { + if err := os.WriteFile(filepath.Join(root, "system", name+".md.tmpl"), []byte(name+" {{.Prompt}}"), 0644); err != nil { + t.Fatal(err) + } + } + t.Chdir(root) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/artifacts.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/artifacts.go deleted file mode 100644 index f2784c6d24..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/artifacts.go +++ /dev/null @@ -1,17 +0,0 @@ -package gemini - -import ( - "context" - "path/filepath" - - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" -) - -func (in *Gemini) UploadArtifacts(ctx context.Context) (*artifacts.UploadArtifacts, error) { - chatsPath := filepath.Join(in.providerPath(), "tmp", "plural", "chats") - return in.BuildUploadArtifacts(ctx, artifacts.BuildArtifactsOptions{ - Provider: "gemini", - Source: artifacts.SessionSource{Path: chatsPath, ArchivePath: "chats"}, - SessionID: in.sessionID, - }) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/README.md b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/README.md deleted file mode 100644 index 6e7e6213b7..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/README.md +++ /dev/null @@ -1,2 +0,0 @@ -Types defined in this package reflect those found in: -https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/output/types.ts diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/base.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/base.go deleted file mode 100644 index a03f7cf076..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/base.go +++ /dev/null @@ -1,80 +0,0 @@ -package events - -import ( - "encoding/json" - "fmt" - "time" - - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" - "k8s.io/klog/v2" -) - -type EventType string - -const ( - EventTypeInit EventType = "init" - EventTypeMessage EventType = "message" - EventTypeToolUse EventType = "tool_use" - EventTypeToolResult EventType = "tool_result" - EventTypeError EventType = "error" - EventTypeResult EventType = "result" -) - -type Event interface { - Validate() bool - Process(onMessage v1.MessageCallback) -} - -type EventBase struct { - Type EventType `json:"type"` - Timestamp time.Time `json:"timestamp"` -} - -func (e EventBase) OnMessage(line []byte, onMessage v1.MessageCallback) error { - if onMessage == nil { - klog.V(log.LogLevelDebug).InfoS("ignoring event as message handler is not defined", - "type", e.Type, "line", string(line)) - return nil - } - - switch e.Type { - case EventTypeInit: - return handleEvent[InitEvent](line, onMessage) - case EventTypeMessage: - return handleEvent[MessageEvent](line, onMessage) - case EventTypeToolUse: - return handleEvent[ToolUseEvent](line, onMessage) - case EventTypeToolResult: - return handleEvent[ToolResultEvent](line, onMessage) - case EventTypeError: - return handleEvent[ErrorEvent](line, onMessage) - case EventTypeResult: - return handleEvent[ResultEvent](line, onMessage) - default: - klog.V(log.LogLevelDebug).InfoS("ignoring unknown event", "type", e.Type, "line", string(line)) - } - - return nil -} - -// handleEvent is a generic helper to unmarshal, validate and process an event. -func handleEvent[T any, PT interface { - *T - Event -}](line []byte, onMessage v1.MessageCallback) error { - var t T - pt := PT(&t) - if err := json.Unmarshal(line, pt); err != nil { - return fmt.Errorf("failed to unmarshal %T: %w", pt, err) - } - - if !pt.Validate() { - klog.V(log.LogLevelDebug).InfoS("ignoring invalid event", "event", pt) - return nil - } - - pt.Process(onMessage) - - return nil -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/error.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/error.go deleted file mode 100644 index 95a6ffd929..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/error.go +++ /dev/null @@ -1,47 +0,0 @@ -package events - -import ( - "fmt" - - console "github.com/pluralsh/console/go/client" - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" -) - -type Severity string - -const ( - ErrorSeverityWarning Severity = "warning" - ErrorSeverityError Severity = "error" -) - -func (s Severity) String() string { - switch s { - case ErrorSeverityWarning: - return "Warning" - case ErrorSeverityError: - return "Error" - default: - return "Error" - } -} - -type ErrorEvent struct { - EventBase - Severity Severity `json:"severity"` - Message string `json:"message"` -} - -func (e *ErrorEvent) Validate() bool { - return e.Type == EventTypeError && e.Message != "" -} - -func (e *ErrorEvent) Process(onMessage v1.MessageCallback) { - onMessage(e.Attributes(), "") -} - -func (e *ErrorEvent) Attributes() *console.AgentMessageAttributes { - return &console.AgentMessageAttributes{ - Role: console.AiRoleSystem, - Message: fmt.Sprintf("%s: %s", e.Severity.String(), e.Message), - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/init.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/init.go deleted file mode 100644 index 122f985924..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/init.go +++ /dev/null @@ -1,21 +0,0 @@ -package events - -import ( - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" - "k8s.io/klog/v2" -) - -type InitEvent struct { - EventBase - SessionID string `json:"session_id"` - Model string `json:"model"` -} - -func (e *InitEvent) Validate() bool { - return e.Type == EventTypeInit -} - -func (e *InitEvent) Process(_ v1.MessageCallback) { - klog.V(log.LogLevelDebug).Infof("initialized %s model", e.Model) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/message.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/message.go deleted file mode 100644 index 565275c04c..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/message.go +++ /dev/null @@ -1,46 +0,0 @@ -package events - -import ( - "strings" - - console "github.com/pluralsh/console/go/client" - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" - "k8s.io/klog/v2" -) - -var messageBuilder strings.Builder - -type Role string - -const ( - RoleUser Role = "user" - RoleAssistant Role = "assistant" -) - -func (r Role) Attributes() console.AiRole { - switch r { - case RoleAssistant: - return console.AiRoleAssistant - case RoleUser: - return console.AiRoleUser - default: - return console.AiRoleSystem - } -} - -type MessageEvent struct { - EventBase - Role Role `json:"role"` - Content string `json:"content"` - Delta *bool `json:"delta,omitempty"` -} - -func (e *MessageEvent) Validate() bool { - return e.Type == EventTypeMessage && e.Content != "" && e.Delta != nil && *e.Delta -} - -func (e *MessageEvent) Process(_ v1.MessageCallback) { - messageBuilder.WriteString(e.Content) - klog.V(log.LogLevelDebug).Infof("appended message delta: %s", e.Content) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/result.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/result.go deleted file mode 100644 index 885b1b5dca..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/result.go +++ /dev/null @@ -1,88 +0,0 @@ -package events - -import ( - console "github.com/pluralsh/console/go/client" - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/samber/lo" -) - -type StreamStats struct { - TotalTokens int `json:"total_tokens"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - DurationMs int `json:"duration_ms"` - ToolCalls int `json:"tool_calls"` -} - -func (s *StreamStats) Attributes() *console.AgentMessageCostAttributes { - if s == nil { - return nil - } - - return &console.AgentMessageCostAttributes{ - Total: float64(s.TotalTokens), - Tokens: &console.AgentMessageTokensAttributes{ - Input: lo.ToPtr(float64(s.InputTokens)), - Output: lo.ToPtr(float64(s.OutputTokens)), - }, - } -} - -type Status string - -const ( - StatusSuccess Status = "success" - StatusError Status = "error" -) - -type ResultEvent struct { - EventBase - Status Status `json:"status"` - Error *ResultError `json:"error,omitempty"` - Stats *StreamStats `json:"stats,omitempty"` -} - -func (e *ResultEvent) Validate() bool { - return e.Type == EventTypeResult -} - -func (e *ResultEvent) Process(onMessage v1.MessageCallback) { - costSent := false - - // If there is a message to send, send it first. - if messageBuilder.Len() > 0 { - onMessage(e.Attributes(), "") - costSent = true - } - - // If there was an error, send that as well. - if e.Status == StatusError { - onMessage(e.ErrorAttributes(costSent), "") - } -} - -func (e *ResultEvent) Attributes() *console.AgentMessageAttributes { - return &console.AgentMessageAttributes{ - Message: messageBuilder.String(), - Role: console.AiRoleAssistant, - Cost: e.Stats.Attributes(), - } -} - -func (e *ResultEvent) ErrorAttributes(costSent bool) *console.AgentMessageAttributes { - attrs := &console.AgentMessageAttributes{ - Role: console.AiRoleSystem, - Message: e.Error.Message, - } - - if !costSent { - attrs.Cost = e.Stats.Attributes() - } - - return attrs -} - -type ResultError struct { - Type string `json:"type"` - Message string `json:"message"` -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result.go deleted file mode 100644 index 161dd73f52..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result.go +++ /dev/null @@ -1,83 +0,0 @@ -package events - -import ( - "encoding/json" - - console "github.com/pluralsh/console/go/client" - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" - "github.com/samber/lo" - "k8s.io/klog/v2" -) - -type ToolStatus string - -const ( - ToolStatusSuccess ToolStatus = "success" - ToolStatusError ToolStatus = "error" -) - -func (s ToolStatus) Attributes() *console.AgentMessageToolState { - switch s { - case ToolStatusSuccess: - return lo.ToPtr(console.AgentMessageToolStateCompleted) - case ToolStatusError: - return lo.ToPtr(console.AgentMessageToolStateError) - default: - return lo.ToPtr(console.AgentMessageToolStatePending) - } -} - -type ToolResultEvent struct { - EventBase - ToolID string `json:"tool_id"` - Status ToolStatus `json:"status"` - Output *string `json:"output,omitempty"` - Error *ToolResultError `json:"error,omitempty"` -} - -func (e *ToolResultEvent) Validate() bool { - return e.Type == EventTypeToolResult && e.ToolID != "" -} - -func (e *ToolResultEvent) Process(onMessage v1.MessageCallback) { - onMessage(e.Attributes(), e.ToolID) - klog.V(log.LogLevelDebug).Infof("processed tool result event for %s", e.ToolID) -} - -func (e *ToolResultEvent) Attributes() *console.AgentMessageAttributes { - // Always set output so empty/missing results clear the "running..." placeholder on update. - output := lo.FromPtr(e.Output) - if output == "" && e.Error != nil { - output = e.Error.Message - } - attrs := &console.AgentMessageAttributes{ - Message: "Called tool", - Role: console.AiRoleAssistant, - Metadata: &console.AgentMessageMetadataAttributes{ - Tool: &console.AgentMessageToolAttributes{ - Name: lo.ToPtr(e.ToolID), - State: e.Status.Attributes(), - Output: lo.ToPtr(output), - }, - }, - } - - if toolUse, ok := toolUseCache.Get(e.ToolID); ok { - attrs.Metadata.Tool.Name = lo.ToPtr(toolUse.ToolName) - - input, err := json.Marshal(toolUse.Parameters) - if err != nil { - return attrs - } - - attrs.Metadata.Tool.Input = lo.ToPtr(string(input)) - } - - return attrs -} - -type ToolResultError struct { - Type string `json:"type"` - Message string `json:"message"` -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result_test.go deleted file mode 100644 index e772f56949..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package events - -import ( - "testing" - - console "github.com/pluralsh/console/go/client" - "github.com/stretchr/testify/require" -) - -func TestToolResultAttributesAlwaysSetsOutput(t *testing.T) { - event := &ToolResultEvent{ - EventBase: EventBase{Type: EventTypeToolResult}, - ToolID: "tool_1", - Status: ToolStatusSuccess, - } - - attrs := event.Attributes() - require.NotNil(t, attrs.Metadata) - require.NotNil(t, attrs.Metadata.Tool) - require.Equal(t, console.AgentMessageToolStateCompleted, *attrs.Metadata.Tool.State) - require.NotNil(t, attrs.Metadata.Tool.Output) - require.Equal(t, "", *attrs.Metadata.Tool.Output) -} - -func TestToolResultAttributesUsesErrorMessageWhenOutputMissing(t *testing.T) { - event := &ToolResultEvent{ - EventBase: EventBase{Type: EventTypeToolResult}, - ToolID: "tool_1", - Status: ToolStatusError, - Error: &ToolResultError{Type: "fail", Message: "boom"}, - } - - attrs := event.Attributes() - require.Equal(t, console.AgentMessageToolStateError, *attrs.Metadata.Tool.State) - require.Equal(t, "boom", *attrs.Metadata.Tool.Output) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_use.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_use.go deleted file mode 100644 index 6f567d753e..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_use.go +++ /dev/null @@ -1,64 +0,0 @@ -package events - -import ( - "encoding/json" - - cmap "github.com/orcaman/concurrent-map/v2" - console "github.com/pluralsh/console/go/client" - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" - "github.com/samber/lo" - "k8s.io/klog/v2" -) - -var toolUseCache = cmap.New[ToolUseEvent]() - -type ToolUseEvent struct { - EventBase - ToolName string `json:"tool_name"` - ToolID string `json:"tool_id"` - Parameters map[string]any `json:"parameters,omitempty"` -} - -func (e *ToolUseEvent) Validate() bool { - return e.Type == EventTypeToolUse && e.ToolID != "" && e.ToolName != "" -} - -func (e *ToolUseEvent) Process(onMessage v1.MessageCallback) { - // If any of the tools is called, send the current message and reset the builder. - if messageBuilder.Len() > 0 { - onMessage(e.Attributes(), "") - messageBuilder.Reset() - } - - toolUseCache.Set(e.ToolID, lo.FromPtr(e)) - klog.V(log.LogLevelDebug).Infof("saved tool use in the cache: %s", e.ToolName) - onMessage(e.RunningAttributes(), e.ToolID) -} - -func (e *ToolUseEvent) Attributes() *console.AgentMessageAttributes { - return &console.AgentMessageAttributes{ - Message: messageBuilder.String(), - Role: console.AiRoleAssistant, - } -} - -func (e *ToolUseEvent) RunningAttributes() *console.AgentMessageAttributes { - attrs := &console.AgentMessageAttributes{ - Message: "Called tool", - Role: console.AiRoleAssistant, - Metadata: &console.AgentMessageMetadataAttributes{ - Tool: &console.AgentMessageToolAttributes{ - Name: lo.ToPtr(e.ToolName), - State: lo.ToPtr(console.AgentMessageToolStateRunning), - Output: lo.ToPtr(v1.RunningToolOutput), - }, - }, - } - if len(e.Parameters) > 0 { - if input, err := json.Marshal(e.Parameters); err == nil { - attrs.Metadata.Tool.Input = lo.ToPtr(string(input)) - } - } - return attrs -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini.go deleted file mode 100644 index ec883ce1f5..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini.go +++ /dev/null @@ -1,318 +0,0 @@ -package gemini - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path" - "strings" - - "k8s.io/klog/v2" - - console "github.com/pluralsh/console/go/client" - - "github.com/pluralsh/console/go/deployment-operator/internal/helpers" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events" - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" -) - -// Gemini implements v1.Tool interface. -type Gemini struct { - v1.DefaultTool - - // onMessage is a callback called when a new message is received. - onMessage v1.MessageCallback - - // executable is the Gemini executable used to call CLI. - executable exec.Executable - - // apiKey used to authenticate with the API. - apiKey string - - // model used to generate code. - model Model - - // sessionID is the latest native Gemini session identifier observed in stream events. - sessionID string -} - -func (in *Gemini) BabysitRun(ctx context.Context, bCtx *v1.BabysitContext) bool { - if bCtx == nil { - return false - } - - env := in.env() - if in.Config.Run.Runtime.Config.Gemini.Endpoint != nil { - env = append(env, fmt.Sprintf("GEMINI_API_BASE_URL=%s", *in.Config.Run.Runtime.Config.Gemini.Endpoint)) - } - - in.executable = exec.NewExecutable( - "gemini", - exec.WithArgs(in.args(bCtx.Prompt, true)), - exec.WithDir(in.Config.WorkDir), - exec.WithEnv(env), - exec.WithTimeout(in.Config.Run.Runtime.Config.Gemini.Timeout), - ) - - klog.V(log.LogLevelInfo).InfoS("Gemini executable configured", "timeout", in.Config.Run.Runtime.Config.Gemini.Timeout) - - // Send the initial prompt as a message too - if in.onMessage != nil { - in.onMessage(&console.AgentMessageAttributes{Message: bCtx.Prompt, Role: console.AiRoleUser}, "") - } - - err := in.executable.RunStream(ctx, func(line []byte) { - klog.V(log.LogLevelTrace).InfoS("Gemini stream event", "line", string(line)) - - // This is here to prevent unavoidable log lines being reported as errors. - // TODO: Remove once https://github.com/google-gemini/gemini-cli/issues/15053 is fixed. - trimmed := strings.TrimSpace(string(line)) - if !strings.HasPrefix(trimmed, "{") { - klog.V(log.LogLevelDebug).InfoS("ignoring non-json Gemini stream line", "trimmed", trimmed) - return - } - - event := &events.EventBase{} - if err := json.Unmarshal(line, event); err != nil { - klog.ErrorS(err, "failed to unmarshal Gemini stream event", "line", line) - in.Config.ErrorChan <- err - return - } - in.recordSessionID(line, event.Type) - - if err := event.OnMessage(line, in.onMessage); err != nil { - klog.ErrorS(err, "failed to process Gemini stream event", "line", string(line)) - in.Config.ErrorChan <- err - } - }) - if err != nil { - klog.ErrorS(err, "Gemini execution failed") - in.Config.ErrorChan <- err - return false - } - - return false -} - -// FollowUpRun re-runs the Gemini CLI with the same settings as the initial -// run, using followUpPrompt as the user prompt. Errors are returned to the -// caller and must not be sent on ErrorChan. -func (in *Gemini) FollowUpRun(ctx context.Context, followUpPrompt string) error { - klog.V(log.LogLevelInfo).InfoS( - "follow-up: reprompting gemini", - "prompt_len", len(followUpPrompt), - "resumeSession", in.sessionID != "", - "sessionID", in.sessionID, - ) - - env := in.env() - if in.Config.Run.Runtime.Config.Gemini.Endpoint != nil { - env = append(env, fmt.Sprintf("GEMINI_API_BASE_URL=%s", *in.Config.Run.Runtime.Config.Gemini.Endpoint)) - } - - in.executable = exec.NewExecutable( - "gemini", - exec.WithArgs(in.args(followUpPrompt, true)), - exec.WithDir(in.Config.WorkDir), - exec.WithEnv(env), - exec.WithTimeout(in.Config.Run.Runtime.Config.Gemini.Timeout), - ) - - err := in.executable.RunStream(ctx, func(line []byte) { - klog.V(log.LogLevelTrace).InfoS("Gemini stream event (follow-up)", "line", string(line)) - - trimmed := strings.TrimSpace(string(line)) - if !strings.HasPrefix(trimmed, "{") { - klog.V(log.LogLevelDebug).InfoS("ignoring non-json Gemini stream line", "trimmed", trimmed) - return - } - - event := &events.EventBase{} - if err := json.Unmarshal(line, event); err != nil { - klog.ErrorS(err, "failed to unmarshal Gemini stream event (follow-up)", "line", line) - return - } - in.recordSessionID(line, event.Type) - - if err := event.OnMessage(line, in.onMessage); err != nil { - klog.ErrorS(err, "failed to process Gemini stream event (follow-up)", "line", string(line)) - } - }) - if err != nil { - return fmt.Errorf("gemini follow-up execution failed: %w", err) - } - klog.V(log.LogLevelExtended).InfoS("Gemini follow-up execution finished") - return nil -} - -func (in *Gemini) ConfigureBabysitRun() error { - if err := in.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeGemini); err != nil { - return err - } - - return in.ConfigureSkills(in.skillsPath()) -} - -func (in *Gemini) Run(ctx context.Context, options ...exec.Option) { - go in.start(ctx, options...) -} - -func (in *Gemini) start(ctx context.Context, options ...exec.Option) { - env := in.env() - if in.Config.Run.Runtime.Config.Gemini.Endpoint != nil { - env = append(env, fmt.Sprintf("GEMINI_API_BASE_URL=%s", *in.Config.Run.Runtime.Config.Gemini.Endpoint)) - } - - in.executable = exec.NewExecutable( - "gemini", - append( - options, - exec.WithArgs(in.args("", false)), - exec.WithDir(in.Config.WorkDir), - exec.WithEnv(env), - exec.WithTimeout(in.Config.Run.Runtime.Config.Gemini.Timeout), - )..., - ) - - klog.V(log.LogLevelInfo).InfoS("Gemini executable configured", "timeout", in.Config.Run.Runtime.Config.Gemini.Timeout) - - // Send the initial prompt as a message too - if in.onMessage != nil { - in.onMessage(&console.AgentMessageAttributes{Message: in.Config.Run.Prompt, Role: console.AiRoleUser}, "") - } - - err := in.executable.RunStream(ctx, func(line []byte) { - klog.V(log.LogLevelTrace).InfoS("Gemini stream event", "line", string(line)) - - // This is here to prevent unavoidable log lines being reported as errors. - // TODO: Remove once https://github.com/google-gemini/gemini-cli/issues/15053 is fixed. - trimmed := strings.TrimSpace(string(line)) - if !strings.HasPrefix(trimmed, "{") { - klog.V(log.LogLevelDebug).InfoS("ignoring non-json Gemini stream line", "trimmed", trimmed) - return - } - - event := &events.EventBase{} - if err := json.Unmarshal(line, event); err != nil { - klog.ErrorS(err, "failed to unmarshal Gemini stream event", "line", line) - in.Config.ErrorChan <- err - return - } - in.recordSessionID(line, event.Type) - - if err := event.OnMessage(line, in.onMessage); err != nil { - klog.ErrorS(err, "failed to process Gemini stream event", "line", string(line)) - in.Config.ErrorChan <- err - } - }) - if err != nil { - klog.ErrorS(err, "Gemini execution failed") - in.Config.ErrorChan <- err - return - } - klog.V(log.LogLevelExtended).InfoS("Gemini execution finished") - // FinishedChan is closed by the controller after the babysit loop exits. -} - -func (in *Gemini) args(prompt string, resume bool) []string { - if len(prompt) > 0 { - in.Config.Run.Prompt = prompt - } - - args := []string{"--output-format", "stream-json"} - if in.Config.Run.Mode == console.AgentRunModeWrite { - args = append([]string{"--approval-mode", "yolo"}, args...) - } - if resume && in.sessionID != "" { - return append(args, "--resume", in.sessionID, "--prompt", in.Config.Run.Prompt) - } - return append(args, "--prompt", in.Config.Run.Prompt) -} - -func (in *Gemini) Configure(_, _ string) error { - if err := in.ConfigureSystemPrompt(console.AgentRuntimeTypeGemini); err != nil { - return err - } - if err := in.ConfigureSkills(in.skillsPath()); err != nil { - return err - } - - input := &ConfigTemplateInput{ - RepositoryDir: in.Config.RepositoryDir, - AgentRunID: in.Config.Run.ID, - AgentRunMode: in.Config.Run.Mode, - InactivityTimeout: int64(in.Config.Run.Runtime.Config.Gemini.InactivityTimeout.Seconds()), - Model: in.model, - GitAccessToken: os.Getenv("GIT_ACCESS_TOKEN"), - } - - _, content, err := settings(input) - if err != nil { - return err - } - - if err = helpers.File().Create(in.settingsPath(), content, 0644); err != nil { - return fmt.Errorf("failed configuring Gemini settings file %q: %w", SettingsFileName, err) - } - - klog.V(log.LogLevelExtended).InfoS("Gemini configured", "settings", in.settingsPath(), "inactivityTimeout", in.Config.Run.Runtime.Config.Gemini.InactivityTimeout) - return nil -} - -func (in *Gemini) settingsPath() string { - return path.Join(in.providerPath(), SettingsFileName) -} - -func (in *Gemini) skillsPath() string { - return path.Join(in.providerPath(), "skills") -} - -func (in *Gemini) providerPath() string { - return path.Join(in.Config.WorkDir, ".gemini") -} - -func (in *Gemini) env() []string { - return []string{ - fmt.Sprintf("GEMINI_API_KEY=%s", in.apiKey), - fmt.Sprintf("GEMINI_CLI_TRUST_WORKSPACE=%s", "true"), - fmt.Sprintf("GEMINI_CLI_HOME=%s", in.Config.WorkDir), - } -} - -func (in *Gemini) recordSessionID(line []byte, eventType events.EventType) { - if eventType != events.EventTypeInit { - return - } - initEvent := &events.InitEvent{} - if err := json.Unmarshal(line, initEvent); err != nil || initEvent.SessionID == "" { - return - } - in.sessionID = initEvent.SessionID -} - -func (in *Gemini) OnMessage(f v1.MessageCallback) { - in.onMessage = f -} - -func New(config v1.Config) v1.Tool { - if len(config.WorkDir) == 0 { - klog.Fatalln("working directory is not set") - } - - if len(config.RepositoryDir) == 0 { - klog.Fatalln("repository directory is not set") - } - - if config.Run == nil { - klog.Fatalln("agent run is not set") - } - - return &Gemini{ - DefaultTool: v1.DefaultTool{Config: config}, - apiKey: config.Run.Runtime.Config.Gemini.APIKey, - model: EnsureModel(config.Run.Runtime.Config.Gemini.Model), - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini_args_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini_args_test.go deleted file mode 100644 index 0a613d9178..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini_args_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package gemini - -import ( - "testing" - - console "github.com/pluralsh/console/go/client" - agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" -) - -func TestGeminiArgs(t *testing.T) { - g := &Gemini{ - DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{ - Run: &agentrunv1.AgentRun{Mode: console.AgentRunModeAnalyze, Prompt: "initial"}, - }}, - } - - args := g.args("analyze repo", false) - want := []string{"--output-format", "stream-json", "--prompt", "analyze repo"} - assertArgsEqual(t, want, args) -} - -func TestGeminiArgsWriteMode(t *testing.T) { - g := &Gemini{ - DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{ - Run: &agentrunv1.AgentRun{Mode: console.AgentRunModeWrite, Prompt: "initial"}, - }}, - } - - args := g.args("implement feature", false) - want := []string{ - "--approval-mode", "yolo", - "--output-format", "stream-json", - "--prompt", "implement feature", - } - assertArgsEqual(t, want, args) -} - -func TestGeminiArgsResume(t *testing.T) { - sessionID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - g := &Gemini{ - DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{ - Run: &agentrunv1.AgentRun{Mode: console.AgentRunModeWrite, Prompt: "initial"}, - }}, - sessionID: sessionID, - } - - args := g.args("follow up", true) - want := []string{ - "--approval-mode", "yolo", - "--output-format", "stream-json", - "--resume", sessionID, - "--prompt", "follow up", - } - assertArgsEqual(t, want, args) -} - -func assertArgsEqual(t *testing.T, want, got []string) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("expected %d args, got %d: %v", len(want), len(got), got) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("arg[%d]: expected %q, got %q (full: %v)", i, want[i], got[i], got) - } - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go new file mode 100644 index 0000000000..416d248078 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go @@ -0,0 +1,40 @@ +package gemini + +import ( + "fmt" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +const defaultModel = "gemini-3.5-flash" + +func (*Agent) resolveModel(model string) string { + if model == "" { + return defaultModel + } + return model +} + +func (agent *Agent) ResolveSettings(run *agentrunv1.AgentRun) (toolv1.Settings, error) { + gemini, err := agent.runConfig(run) + if err != nil { + return toolv1.Settings{}, err + } + return toolv1.Settings{ + Mode: run.Mode, + Model: toolv1.ModelSelection{Name: agent.resolveModel(gemini.Model)}, + Timeout: gemini.Timeout, + Proxy: run.IsProxyEnabled(), + }, nil +} + +func (*Agent) validateMode(mode console.AgentRunMode) error { + switch mode { + case console.AgentRunModeAnalyze, console.AgentRunModeWrite, console.AgentRunModeReview: + return nil + default: + return fmt.Errorf("unsupported gemini ACP mode %q", mode) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go new file mode 100644 index 0000000000..efaeb210e6 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go @@ -0,0 +1,36 @@ +package gemini + +import ( + "testing" + "time" + + console "github.com/pluralsh/console/go/client" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestResolveSettingsUsesDefaultModel(t *testing.T) { + run := geminiTestRun(console.AgentRunModeReview, "", nil) + run.Runtime.Config.Gemini.Timeout = 7 * time.Minute + settings, err := NewAgent(toolv1.Config{Run: run}).ResolveSettings(run) + if err != nil { + t.Fatalf("ResolveSettings() error = %v", err) + } + if settings.Model.Provider != nil { + t.Fatalf("provider = %v, want nil", settings.Model.Provider) + } + if settings.Model.Name != defaultModel || settings.Timeout != 7*time.Minute || settings.Proxy { + t.Fatalf("settings = %#v", settings) + } +} + +func TestValidateMode(t *testing.T) { + agent := NewAgent(toolv1.Config{}) + for _, mode := range []console.AgentRunMode{console.AgentRunModeAnalyze, console.AgentRunModeWrite, console.AgentRunModeReview} { + if err := agent.validateMode(mode); err != nil { + t.Fatalf("validateMode(%q) error = %v", mode, err) + } + } + if err := agent.validateMode("unsupported"); err == nil { + t.Fatal("validateMode() error = nil") + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go new file mode 100644 index 0000000000..c61af43af8 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go @@ -0,0 +1,180 @@ +package gemini + +import ( + "context" + "errors" + "fmt" + "math" + "path/filepath" + "strconv" + + acpsdk "github.com/coder/acp-go-sdk" + + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" +) + +const ( + geminiBinary = "gemini" + geminiACPFlag = "--acp" + geminiModelFlag = "--model" + geminiApprovalModeFlag = "--approval-mode=yolo" + geminiAPIKeyEnv = "GEMINI_API_KEY" + geminiAPIBaseURLEnv = "GEMINI_API_BASE_URL" + geminiTrustWorkspaceEnv = "GEMINI_CLI_TRUST_WORKSPACE" + geminiHomeEnv = "GEMINI_CLI_HOME" + geminiTrustWorkspace = "true" +) + +type Transport struct { + agent *Agent + engine *acp.Engine + workDir string +} + +var _ toolv1.Transport = (*Transport)(nil) + +func NewTransport(agent *Agent) (*Transport, error) { + if agent == nil { + return nil, errors.New("gemini agent is not set") + } + config, err := agent.configWithGemini() + + if err != nil { + return nil, err + } + workDir, err := filepath.Abs(config.WorkDir) + + if err != nil { + return nil, fmt.Errorf("resolve gemini work directory: %w", err) + } + + result := &Transport{ + agent: agent, + workDir: workDir, + } + + engine := acp.NewEngine( + acp.WithSessionRestorer(acp.LoadSession), + acp.WithUsageResolver(geminiPromptUsage), + ) + + result.engine = engine + return result, nil +} + +func (*Transport) Kind() toolv1.TransportKind { + return toolv1.TransportKindACP +} + +func (*Transport) Capabilities() toolv1.TransportCapabilities { + return toolv1.TransportCapabilities{ + SessionResume: true, + ToolCallOutputStreaming: false, + UsageReporting: true, + FileSystemRead: true, + FileSystemWrite: true, + } +} + +func geminiPromptUsage(response acpsdk.PromptResponse) *acpsdk.Usage { + if response.Usage != nil { + return response.Usage + } + quota, ok := response.Meta["quota"].(map[string]any) + if !ok { + return nil + } + + tokenCount, ok := quota["token_count"].(map[string]any) + if !ok { + return nil + } + + input, ok := geminiTokenCount(tokenCount["input_tokens"]) + if !ok { + return nil + } + + output, ok := geminiTokenCount(tokenCount["output_tokens"]) + if !ok || input > int(^uint(0)>>1)-output { + return nil + } + + return &acpsdk.Usage{InputTokens: input, OutputTokens: output, TotalTokens: input + output} +} + +func geminiTokenCount(value any) (int, bool) { + tokens, ok := value.(float64) + limit := math.Ldexp(1, strconv.IntSize-1) + + if !ok || math.IsNaN(tokens) || math.IsInf(tokens, 0) || tokens < 0 || tokens >= limit || math.Trunc(tokens) != tokens { + return 0, false + } + + return int(tokens), true +} + +func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest, sink toolv1.TurnSink) (toolv1.TurnResult, error) { + if ctx == nil { + ctx = context.Background() + } + + if err := ctx.Err(); err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + if err := transport.agent.validateMode(request.Settings.Mode); err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + + process, err := transport.launch(request.Options, request.Settings.Mode, request.Settings.Model.Name) + if err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + + result, err := transport.engine.Turn(ctx, process, acp.Request{ + Cwd: transport.workDir, Prompt: request.Prompt, SessionID: request.SessionID, + Settings: acp.SessionSettings{ModelID: request.Settings.Model.Name}, + }, sink) + return toolv1.TurnResult{SessionID: result.SessionID}, err +} + +func (transport *Transport) launch(options []exec.Option, mode console.AgentRunMode, model string) (*exec.StdioProcess, error) { + config := transport.agent.config + gemini, err := transport.agent.runConfig(config.Run) + if err != nil { + return nil, err + } + + args := []string{geminiACPFlag, geminiModelFlag, model} + if mode == console.AgentRunModeWrite { + args = append(args, geminiApprovalModeFlag) + } + + launchOptions := append([]exec.Option(nil), options...) + launchOptions = append(launchOptions, + exec.WithArgs(args), + exec.WithEnv(transport.agent.env(config)), + exec.WithDir(transport.workDir), + exec.WithTimeout(gemini.Timeout), + ) + + return exec.StartWithStdio(context.Background(), geminiBinary, launchOptions...) +} + +func (agent *Agent) env(config toolv1.Config) []string { + gemini := config.Run.Runtime.Config.Gemini + env := []string{ + fmt.Sprintf("%s=%s", geminiAPIKeyEnv, gemini.APIKey), + fmt.Sprintf("%s=%s", geminiTrustWorkspaceEnv, geminiTrustWorkspace), + fmt.Sprintf("%s=%s", geminiHomeEnv, config.WorkDir), + } + + if gemini.Endpoint != nil { + env = append(env, fmt.Sprintf("%s=%s", geminiAPIBaseURLEnv, *gemini.Endpoint)) + } + + return env +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go new file mode 100644 index 0000000000..481c777567 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go @@ -0,0 +1,123 @@ +package gemini + +import ( + "context" + "errors" + "io" + "math" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + + acpsdk "github.com/coder/acp-go-sdk" + console "github.com/pluralsh/console/go/client" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" + stackv1 "github.com/pluralsh/console/go/deployment-operator/pkg/harness/stackrun/v1" +) + +func TestTransportLaunchUsesACPAndGeminiEnvironment(t *testing.T) { + binDir := t.TempDir() + output := filepath.Join(t.TempDir(), "launch") + writeGeminiBinary(t, binDir) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("GEMINI_TEST_OUTPUT", output) + endpoint := "https://api.example" + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: geminiTestRun(console.AgentRunModeWrite, "gemini-custom", &endpoint)} + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatal(err) + } + var preStarts, postStarts atomic.Int32 + process, err := transport.launch([]exec.Option{ + exec.WithHook(stackv1.LifecyclePreStart, func() error { + preStarts.Add(1) + return nil + }), + exec.WithHook(stackv1.LifecyclePostStart, func() error { + postStarts.Add(1) + return nil + }), + }, console.AgentRunModeWrite, "gemini-custom") + if err != nil { + t.Fatal(err) + } + go io.Copy(io.Discard, process.Stdout) + go io.Copy(io.Discard, process.Stderr) + if err := process.Wait(); err != nil { + t.Fatal(err) + } + if preStarts.Load() != 1 || postStarts.Load() != 1 { + t.Fatalf("hooks = %d/%d", preStarts.Load(), postStarts.Load()) + } + content, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"args=--acp --model gemini-custom --approval-mode=yolo", "key=api-key", "endpoint=https://api.example", "trust=true", "home=" + config.WorkDir, "cwd=" + transport.workDir} { + if !strings.Contains(string(content), want) { + t.Fatalf("launch missing %q: %s", want, content) + } + } +} + +func TestTransportCapabilitiesAndPreCancelledTurn(t *testing.T) { + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: geminiTestRun(console.AgentRunModeWrite, "", nil)} + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatal(err) + } + if transport.Kind() != toolv1.TransportKindACP || transport.Capabilities().ToolCallOutputStreaming { + t.Fatalf("transport = %#v", transport.Capabilities()) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = transport.Turn(ctx, toolv1.TurnRequest{}, nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Turn() error = %v", err) + } +} + +func TestGeminiPromptUsageReadsQuotaTokenCount(t *testing.T) { + usage := geminiPromptUsage(acpsdk.PromptResponse{Meta: map[string]any{ + "quota": map[string]any{"token_count": map[string]any{ + "input_tokens": float64(17), "output_tokens": float64(9), + }}, + }}) + if usage == nil || usage.InputTokens != 17 || usage.OutputTokens != 9 || usage.TotalTokens != 26 { + t.Fatalf("usage = %#v", usage) + } + if usage := geminiPromptUsage(acpsdk.PromptResponse{Meta: map[string]any{"quota": map[string]any{"token_count": map[string]any{ + "input_tokens": float64(17.5), "output_tokens": float64(9), + }}}}); usage != nil { + t.Fatalf("usage = %#v, want nil", usage) + } + if _, ok := geminiTokenCount(math.Ldexp(1, strconv.IntSize-1)); ok { + t.Fatal("geminiTokenCount() overflow was accepted") + } +} + +func TestGeminiPromptUsagePrefersStandardUsage(t *testing.T) { + standard := &acpsdk.Usage{InputTokens: 8, OutputTokens: 3, TotalTokens: 11} + usage := geminiPromptUsage(acpsdk.PromptResponse{ + Usage: standard, + Meta: map[string]any{"quota": map[string]any{"token_count": map[string]any{ + "input_tokens": float64(17), "output_tokens": float64(9), + }}}, + }) + if usage != standard { + t.Fatalf("usage = %#v, want standard %#v", usage, standard) + } +} + +func writeGeminiBinary(t *testing.T, binDir string) { + t.Helper() + script := "#!/bin/sh\n" + + "printf 'args=%s\\nkey=%s\\nendpoint=%s\\ntrust=%s\\nhome=%s\\ncwd=%s\\n' \"$*\" \"$GEMINI_API_KEY\" \"$GEMINI_API_BASE_URL\" \"$GEMINI_CLI_TRUST_WORKSPACE\" \"$GEMINI_CLI_HOME\" \"$PWD\" > \"$GEMINI_TEST_OUTPUT\"\n" + if err := os.WriteFile(filepath.Join(binDir, geminiBinary), []byte(script), 0755); err != nil { + t.Fatal(err) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go index 07457b4418..8a1b794418 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go @@ -41,7 +41,7 @@ func NewTransport(agent *Agent) (*Transport, error) { return &Transport{ agent: agent, - engine: acp.NewEngine(acp.Config{}), + engine: acp.NewEngine(), repositoryDir: repositoryDir, }, nil } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go index 2afd92b832..db4fedc089 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go @@ -41,7 +41,12 @@ func New(runtimeType console.AgentRuntimeType, config v1.Config) (v1.Tool, error } return v1.NewRuntime(config, agent, transport) case console.AgentRuntimeTypeGemini: - return gemini.New(config), nil + agent := gemini.NewAgent(config) + transport, err := gemini.NewTransport(agent) + if err != nil { + return nil, err + } + return v1.NewRuntime(config, agent, transport) case console.AgentRuntimeTypeCodex: agent := codex.NewAgent(config) transport, err := codex.NewTransport(agent) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go index 2e711e643f..09f7ed089d 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go @@ -67,6 +67,22 @@ func TestNewComposesClaudeRuntime(t *testing.T) { } } +func TestNewComposesGeminiRuntime(t *testing.T) { + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: &agentrunv1.AgentRun{ + Mode: console.AgentRunModeWrite, + Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{ + Gemini: &agentrunv1.GeminiConfig{Model: "gemini-3.5-flash", Timeout: time.Minute}, + }}, + }} + created, err := New(console.AgentRuntimeTypeGemini, config) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if _, ok := created.(*toolv1.Runtime); !ok { + t.Fatalf("Gemini factory returned %T, want *v1.Runtime", created) + } +} + func TestNewRejectsMissingAgentRun(t *testing.T) { if _, err := New(console.AgentRuntimeTypeClaude, toolv1.Config{}); err == nil { t.Fatal("New() error = nil, want missing agent run error") diff --git a/go/deployment-operator/pkg/agentrun-harness/usage/usage.go b/go/deployment-operator/pkg/agentrun-harness/usage/usage.go index 1ef0d10a7e..e0c1e7002f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/usage/usage.go +++ b/go/deployment-operator/pkg/agentrun-harness/usage/usage.go @@ -38,6 +38,7 @@ func New(existing *console.AgentRunUsage) *Usage { if existing == nil { return u } + if existing.InputTokens != nil { u.inputTokens = *existing.InputTokens } From de41437559c9f760709e7f10cec04556fd8182a6 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Wed, 9 Sep 2026 15:03:35 +0200 Subject: [PATCH 23/46] refactor(tool): replace Gemini model constants with plain strings in configuration and tests - Removed `Model` type and associated constants from `model.go`. - Updated `settings.go` and `settings_template` to handle models as plain strings with JSON-safe quoting. - Adjusted test cases to verify quoting logic for `Model` and `RepositoryDir`. - Enhanced runtime configuration to include `Provider` field with default `Vertex` provider in model selection. - Simplified configuration templates and removed unused `AgentRunID` and token fields. - Updated affected tests to validate explicit model and proxy handling (`runtime_config_test.go`, `settings_test.go`). --- .../pkg/agentrun-harness/tool/codex/agent.go | 1 - .../tool/gemini/agent_config.go | 4 +-- .../pkg/agentrun-harness/tool/gemini/model.go | 19 ----------- .../tool/gemini/runtime_config.go | 3 +- .../tool/gemini/runtime_config_test.go | 24 ++++++++++++-- .../agentrun-harness/tool/gemini/settings.go | 13 +++++--- .../tool/gemini/settings_test.go | 33 ++++++++++++++++--- .../gemini/templates/settings.json.gotmpl | 4 +-- 8 files changed, 65 insertions(+), 36 deletions(-) delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/model.go diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go index 51ea161583..282097f618 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go @@ -108,7 +108,6 @@ func (agent *Agent) Configure(ctx context.Context, request toolv1.ConfigureReque } model := agent.resolveModelForSettings(config, request.Settings) - return agent.writeNativeConfig(config, model) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go index 1d76100af8..9fcc20c134 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go @@ -21,12 +21,10 @@ func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error } input := &ConfigTemplateInput{ - Model: Model(model), + Model: model, RepositoryDir: config.RepositoryDir, - AgentRunID: config.Run.ID, AgentRunMode: config.Run.Mode, InactivityTimeout: int64(gemini.InactivityTimeout.Seconds()), - GitAccessToken: os.Getenv("GIT_ACCESS_TOKEN"), } _, content, err := settings(input) if err != nil { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/model.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/model.go deleted file mode 100644 index a1325b4cfc..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/model.go +++ /dev/null @@ -1,19 +0,0 @@ -package gemini - -type Model string - -const ( - ModelGemini35Flash Model = "gemini-3.5-flash" - ModelGemini31ProPreview Model = "gemini-3.1-pro-preview" - ModelGemini31FlashLite Model = "gemini-3.1-flash-lite" - ModelGemini3ProPreview Model = "gemini-3-pro-preview" - ModelGemini3FlashPreview Model = "gemini-3-flash-preview" -) - -func EnsureModel(model string) Model { - if len(model) == 0 { - return ModelGemini35Flash - } - - return Model(model) -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go index 416d248078..a6dd4c46ca 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go @@ -22,9 +22,10 @@ func (agent *Agent) ResolveSettings(run *agentrunv1.AgentRun) (toolv1.Settings, if err != nil { return toolv1.Settings{}, err } + provider := console.AiProviderVertex return toolv1.Settings{ Mode: run.Mode, - Model: toolv1.ModelSelection{Name: agent.resolveModel(gemini.Model)}, + Model: toolv1.ModelSelection{Provider: &provider, Name: agent.resolveModel(gemini.Model)}, Timeout: gemini.Timeout, Proxy: run.IsProxyEnabled(), }, nil diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go index efaeb210e6..860c6b07c2 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go @@ -15,14 +15,34 @@ func TestResolveSettingsUsesDefaultModel(t *testing.T) { if err != nil { t.Fatalf("ResolveSettings() error = %v", err) } - if settings.Model.Provider != nil { - t.Fatalf("provider = %v, want nil", settings.Model.Provider) + if settings.Model.Provider == nil || *settings.Model.Provider != console.AiProviderVertex { + t.Fatalf("provider = %v, want vertex", settings.Model.Provider) } if settings.Model.Name != defaultModel || settings.Timeout != 7*time.Minute || settings.Proxy { t.Fatalf("settings = %#v", settings) } } +func TestResolveSettingsPreservesExplicitModelAndProxy(t *testing.T) { + const explicitModel = "gemini-custom" + run := geminiTestRun(console.AgentRunModeWrite, explicitModel, nil) + run.Runtime.AiProxy = true + + settings, err := NewAgent(toolv1.Config{Run: run}).ResolveSettings(run) + if err != nil { + t.Fatalf("ResolveSettings() error = %v", err) + } + if settings.Model.Provider == nil || *settings.Model.Provider != console.AiProviderVertex { + t.Fatalf("provider = %v, want vertex", settings.Model.Provider) + } + if settings.Model.Name != explicitModel { + t.Fatalf("model = %q, want %q", settings.Model.Name, explicitModel) + } + if !settings.Proxy { + t.Fatalf("proxy = false, want true") + } +} + func TestValidateMode(t *testing.T) { agent := NewAgent(toolv1.Config{}) for _, mode := range []console.AgentRunMode{console.AgentRunModeAnalyze, console.AgentRunModeWrite, console.AgentRunModeReview} { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go index 33f68a882c..f2ceece898 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go @@ -16,16 +16,21 @@ var settingsTemplate string const SettingsFileName = "settings.json" type ConfigTemplateInput struct { - Model Model + Model string RepositoryDir string - AgentRunID string AgentRunMode console.AgentRunMode InactivityTimeout int64 - GitAccessToken string } func settings(input *ConfigTemplateInput) (fileName, content string, err error) { - tmpl, err := template.New(SettingsFileName).Parse(settingsTemplate) + quote := func(value string) (string, error) { + quoted, err := json.Marshal(value) + return string(quoted), err + } + + tmpl, err := template.New(SettingsFileName).Funcs(template.FuncMap{ + "quote": quote, + }).Parse(settingsTemplate) if err != nil { return "", "", err } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go index 441a532538..8279f72965 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go @@ -12,9 +12,8 @@ import ( //nolint:gocyclo func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { baseInput := &ConfigTemplateInput{ - Model: ModelGemini31FlashLite, + Model: "gemini-3.1-flash-lite", RepositoryDir: "/repo", - AgentRunID: "run-123", } t.Run("plural MCP server uses in-pod remote URL", func(t *testing.T) { @@ -138,15 +137,41 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { } } }) + + t.Run("quotes model and repository directory", func(t *testing.T) { + input := *baseInput + input.Model = "gemini-3.1-\"flash\"" + input.RepositoryDir = "/repo/with \"quotes\"" + + _, content, err := settings(&input) + if err != nil { + t.Fatalf("settings() failed: %v", err) + } + + var out struct { + IncludeDirectories []string `json:"includeDirectories"` + Model struct { + Name string `json:"name"` + } `json:"model"` + } + if err := json.Unmarshal([]byte(content), &out); err != nil { + t.Fatalf("generated content is not valid JSON: %v", err) + } + if out.Model.Name != input.Model { + t.Errorf("model = %q, want %q", out.Model.Name, input.Model) + } + if len(out.IncludeDirectories) != 2 || out.IncludeDirectories[1] != input.RepositoryDir { + t.Errorf("includeDirectories = %#v, want repository %q", out.IncludeDirectories, input.RepositoryDir) + } + }) } func TestSettingsTemplate_ExternalMCPServer(t *testing.T) { t.Setenv(mcp.EnvServers, `[{"name":"linear","url":"https://mcp.linear.app/mcp","allowedTools":["list_issues"],"headers":{"Authorization":"Bearer secret"}}]`) input := &ConfigTemplateInput{ - Model: ModelGemini31FlashLite, + Model: "gemini-3.1-flash-lite", RepositoryDir: "/repo", - AgentRunID: "run-123", AgentRunMode: console.AgentRunModeWrite, } _, content, err := settings(input) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl index 5191aa00bf..f6105d1056 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl @@ -37,10 +37,10 @@ ], "includeDirectories": [ "/plural/contexts", - "{{ .RepositoryDir }}" + {{ quote .RepositoryDir }} ], "model": { - "name": "{{ .Model }}" + "name": {{ quote .Model }} }, "mcpServers": { "plural": { From cd7ab720d4513c75a58c725fa30d5ef4c262bb46 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Wed, 9 Sep 2026 18:57:58 +0200 Subject: [PATCH 24/46] feat(tool): enable conditional filesystem write support based on run mode - Updated `Capabilities` across tools (Claude, Codex, Opencode, Gemini) to conditionally enable `FileSystemWrite` support based on `AgentRunMode`. - Enhanced `Turn` logic to pass filesystem write capability during request handling. - Refactored `acp` package to propagate `FileSystemWrite` flag in requests and enforce directory constraints. - Introduced new `rootRelativePath` method to validate paths against working directories. - Improved error handling in session initialization and file operations with proper cleanup of resources. - Added comprehensive tests to validate filesystem write behavior across modes and safeguard against path constraints or symlink issues. --- .../pkg/agentrun-harness/tool/acp/client.go | 33 +++++-- .../agentrun-harness/tool/acp/client_test.go | 95 +++++++++++++++++-- .../pkg/agentrun-harness/tool/acp/engine.go | 9 +- .../agentrun-harness/tool/acp/engine_test.go | 35 +++++++ .../pkg/agentrun-harness/tool/acp/session.go | 56 +++++++---- .../pkg/agentrun-harness/tool/acp/types.go | 9 +- .../agentrun-harness/tool/claude/transport.go | 19 +++- .../tool/claude/transport_test.go | 3 +- .../agentrun-harness/tool/codex/transport.go | 14 +-- .../tool/codex/transport_test.go | 3 + .../agentrun-harness/tool/gemini/transport.go | 11 ++- .../tool/gemini/transport_test.go | 3 +- .../tool/opencode/transport.go | 14 +-- .../tool/opencode/transport_test.go | 25 +++++ 14 files changed, 267 insertions(+), 62 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go index 8b9150489c..aa2402ce87 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go @@ -19,7 +19,10 @@ const maxTextFileBytes = 16 << 20 var _ acpsdk.Client = (*client)(nil) type client struct { - turn *turnState + turn *turnState + cwd string + root *os.Root + fileSystemWrite bool } func (client *client) ReadTextFile(ctx context.Context, request acpsdk.ReadTextFileRequest) (acpsdk.ReadTextFileResponse, error) { @@ -131,8 +134,12 @@ func (client *client) WriteTextFile(ctx context.Context, request acpsdk.WriteTex if err := client.validateSession(request.SessionId); err != nil { return acpsdk.WriteTextFileResponse{}, err } - if !filepath.IsAbs(request.Path) { - return acpsdk.WriteTextFileResponse{}, fmt.Errorf("acp filesystem path must be absolute: %q", request.Path) + if !client.fileSystemWrite { + return acpsdk.WriteTextFileResponse{}, errors.New("acp filesystem writes are disabled") + } + path, err := client.rootRelativePath(request.Path) + if err != nil { + return acpsdk.WriteTextFileResponse{}, err } if ctx == nil { ctx = context.Background() @@ -140,18 +147,32 @@ func (client *client) WriteTextFile(ctx context.Context, request acpsdk.WriteTex if err := ctx.Err(); err != nil { return acpsdk.WriteTextFileResponse{}, err } - if err := os.MkdirAll(filepath.Dir(request.Path), 0o755); err != nil { - return acpsdk.WriteTextFileResponse{}, fmt.Errorf("mkdir %s: %w", filepath.Dir(request.Path), err) + if err := client.root.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return acpsdk.WriteTextFileResponse{}, fmt.Errorf("mkdir %s: %w", request.Path, err) } if err := ctx.Err(); err != nil { return acpsdk.WriteTextFileResponse{}, err } - if err := os.WriteFile(request.Path, []byte(request.Content), 0o644); err != nil { + if err := client.root.WriteFile(path, []byte(request.Content), 0o644); err != nil { return acpsdk.WriteTextFileResponse{}, fmt.Errorf("write %s: %w", request.Path, err) } return acpsdk.WriteTextFileResponse{}, nil } +func (client *client) rootRelativePath(path string) (string, error) { + if client.root == nil { + return "", errors.New("acp client filesystem root is not set") + } + if !filepath.IsAbs(path) { + return "", fmt.Errorf("acp filesystem path must be absolute: %q", path) + } + relative, err := filepath.Rel(client.cwd, path) + if err != nil || relative == "." || !filepath.IsLocal(relative) { + return "", fmt.Errorf("acp filesystem path is outside the working directory: %q", path) + } + return relative, nil +} + func (client *client) RequestPermission(context.Context, acpsdk.RequestPermissionRequest) (acpsdk.RequestPermissionResponse, error) { return acpsdk.RequestPermissionResponse{}, errors.New("acp permission requests are unavailable in unattended runs") } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go index 5052046b00..ce9eb094c1 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -12,14 +12,20 @@ import ( import acpsdk "github.com/coder/acp-go-sdk" -func newTestClient(t *testing.T) (*client, string) { +func newTestClient(t *testing.T, fileSystemWrite bool) (*client, string) { t.Helper() + directory := t.TempDir() + root, err := os.OpenRoot(directory) + if err != nil { + t.Fatalf("open test root: %v", err) + } + t.Cleanup(func() { _ = root.Close() }) engine := NewEngine() - return &client{turn: newTurn(engine, &testSink{}, "session-1")}, t.TempDir() + return &client{turn: newTurn(engine, &testSink{}, "session-1"), cwd: directory, root: root, fileSystemWrite: fileSystemWrite}, directory } func TestClientReadsAndWritesTextFiles(t *testing.T) { - acpClient, directory := newTestClient(t) + acpClient, directory := newTestClient(t, true) path := filepath.Join(directory, "nested", "file.txt") if _, err := acpClient.WriteTextFile(context.Background(), acpsdk.WriteTextFileRequest{SessionId: "session-1", Path: path, Content: "one\ntwo\nthree\n"}); err != nil { t.Fatalf("write text file: %v", err) @@ -34,8 +40,81 @@ func TestClientReadsAndWritesTextFiles(t *testing.T) { } } +func TestClientRejectsWritesWithoutPermission(t *testing.T) { + acpClient, directory := newTestClient(t, false) + path := filepath.Join(directory, "nested", "file.txt") + + _, err := acpClient.WriteTextFile(context.Background(), acpsdk.WriteTextFileRequest{ + SessionId: "session-1", + Path: path, + Content: "content", + }) + if err == nil { + t.Fatal("write without permission unexpectedly succeeded") + } + if _, err := os.Stat(filepath.Dir(path)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("write parent directory error = %v, want not exist", err) + } +} + +func TestClientRejectsWritesOutsideRoot(t *testing.T) { + acpClient, _ := newTestClient(t, true) + outside := t.TempDir() + path := filepath.Join(outside, "nested", "file.txt") + + _, err := acpClient.WriteTextFile(context.Background(), acpsdk.WriteTextFileRequest{ + SessionId: "session-1", + Path: path, + Content: "content", + }) + if err == nil { + t.Fatal("outside-root write unexpectedly succeeded") + } + if _, err := os.Stat(filepath.Dir(path)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("outside-root parent directory error = %v, want not exist", err) + } +} + +func TestClientRejectsWritesToRootDirectory(t *testing.T) { + acpClient, directory := newTestClient(t, true) + + _, err := acpClient.WriteTextFile(context.Background(), acpsdk.WriteTextFileRequest{ + SessionId: "session-1", + Path: directory, + Content: "content", + }) + if err == nil { + t.Fatal("root-directory write unexpectedly succeeded") + } +} + +func TestClientRejectsWritesThroughSymlinkEscape(t *testing.T) { + acpClient, directory := newTestClient(t, true) + outside := t.TempDir() + target, err := filepath.Rel(directory, outside) + if err != nil { + t.Fatalf("relative symlink target: %v", err) + } + if err := os.Symlink(target, filepath.Join(directory, "escape")); err != nil { + t.Fatalf("create symlink: %v", err) + } + path := filepath.Join(directory, "escape", "file.txt") + + _, err = acpClient.WriteTextFile(context.Background(), acpsdk.WriteTextFileRequest{ + SessionId: "session-1", + Path: path, + Content: "content", + }) + if err == nil { + t.Fatal("symlink escape write unexpectedly succeeded") + } + if _, err := os.Stat(filepath.Join(outside, "file.txt")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("symlink escape file error = %v, want not exist", err) + } +} + func TestClientRejectsRelativeAndForeignSessionPaths(t *testing.T) { - acpClient, directory := newTestClient(t) + acpClient, directory := newTestClient(t, true) for _, request := range []acpsdk.ReadTextFileRequest{ {SessionId: "session-1", Path: "relative.txt"}, {SessionId: "other", Path: filepath.Join(directory, "file.txt")}, @@ -55,7 +134,7 @@ func TestClientRejectsRelativeAndForeignSessionPaths(t *testing.T) { } func TestClientRejectsOversizedAndCanceledReads(t *testing.T) { - acpClient, directory := newTestClient(t) + acpClient, directory := newTestClient(t, true) path := filepath.Join(directory, "large.txt") file, err := os.Create(path) if err != nil { @@ -79,7 +158,7 @@ func TestClientRejectsOversizedAndCanceledReads(t *testing.T) { } func TestClientRejectsFIFOWithoutBlocking(t *testing.T) { - acpClient, directory := newTestClient(t) + acpClient, directory := newTestClient(t, true) path := filepath.Join(directory, "pipe") if err := syscall.Mkfifo(path, 0o600); err != nil { t.Fatalf("create FIFO: %v", err) @@ -102,7 +181,7 @@ func TestClientRejectsFIFOWithoutBlocking(t *testing.T) { } func TestClientRejectsCanceledWritesBeforeFilesystemSideEffects(t *testing.T) { - acpClient, directory := newTestClient(t) + acpClient, directory := newTestClient(t, true) ctx, cancel := context.WithCancel(context.Background()) cancel() path := filepath.Join(directory, "nested", "file.txt") @@ -129,7 +208,7 @@ func (ctx *cancelAfterFirstCheckContext) Err() error { return nil } func TestClientRejectsCanceledWritesBetweenFilesystemSideEffects(t *testing.T) { - acpClient, directory := newTestClient(t) + acpClient, directory := newTestClient(t, true) path := filepath.Join(directory, "nested", "file.txt") ctx := &cancelAfterFirstCheckContext{Context: context.Background()} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go index 93baab1bbd..1a109bf773 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go @@ -193,9 +193,14 @@ func (engine *Engine) Turn(ctx context.Context, process *exec.StdioProcess, requ ctx = context.Background() } - attempt := newSessionAttempt(engine, ctx, process, request, sink) + attempt, err := newSessionAttempt(engine, ctx, process, request, sink) + if err != nil { + _ = process.Stop() + _ = process.Wait() + return Result{SessionID: request.SessionID}, err + } defer attempt.close() - err := attempt.run(request.Prompt) + err = attempt.run(request.Prompt) return Result{SessionID: attempt.sessionID}, err } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go index fa51e8a897..b9afb938e4 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -377,6 +377,41 @@ func TestEngineTurnCreatesAndResumesSession(t *testing.T) { } } +func TestEngineTurnAdvertisesRequestedFilesystemWriteCapability(t *testing.T) { + for _, test := range []struct { + name string + fileSystemWrite bool + }{ + {name: "read only", fileSystemWrite: false}, + {name: "write enabled", fileSystemWrite: true}, + } { + t.Run(test.name, func(t *testing.T) { + state := newTestState() + _, process, _ := newTestAgentProcess(state, true) + + _, err := NewEngine().Turn(context.Background(), process, Request{ + Cwd: t.TempDir(), + Prompt: "capabilities", + FileSystemWrite: test.fileSystemWrite, + }, &testSink{}) + if err != nil { + t.Fatalf("turn: %v", err) + } + + state.mu.Lock() + initializations := append([]acpsdk.InitializeRequest(nil), state.initializations...) + state.mu.Unlock() + if len(initializations) != 1 { + t.Fatalf("initializations = %#v", initializations) + } + capabilities := initializations[0].ClientCapabilities.Fs + if !capabilities.ReadTextFile || capabilities.WriteTextFile != test.fileSystemWrite { + t.Fatalf("filesystem capabilities = %#v", capabilities) + } + }) + } +} + func TestEngineTurnLoadsSessionWhenConfigured(t *testing.T) { state := newTestState() state.configOptions = []acpsdk.SessionConfigOption{{Select: &acpsdk.SessionConfigOptionSelect{ diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go index f1c285231e..e32424f4e1 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go @@ -17,15 +17,17 @@ import ( ) type sessionAttempt struct { - engine *Engine - ctx context.Context - process *exec.StdioProcess - connection *acpsdk.ClientSideConnection - turn *turnState - settings SessionSettings - cwd string - priorSessionID string - sessionID string + engine *Engine + ctx context.Context + process *exec.StdioProcess + connection *acpsdk.ClientSideConnection + turn *turnState + settings SessionSettings + cwd string + root *os.Root + fileSystemWrite bool + priorSessionID string + sessionID string } type sessionDetails struct { @@ -97,7 +99,7 @@ func (attempt *sessionAttempt) initialize() (acpsdk.InitializeResponse, error) { ClientCapabilities: acpsdk.ClientCapabilities{ Fs: acpsdk.FileSystemCapabilities{ ReadTextFile: true, - WriteTextFile: true, + WriteTextFile: attempt.fileSystemWrite, }, Auth: acpsdk.AuthCapabilities{}, }, @@ -166,6 +168,7 @@ func (attempt *sessionAttempt) finishTurn(response acpsdk.PromptResponse) { } func (attempt *sessionAttempt) close() { + _ = attempt.root.Close() // The process is stopped explicitly during the run. This final guard // handles setup failures and keeps test launchers from leaking children. _ = attempt.process.Close() @@ -275,19 +278,30 @@ func (attempt *sessionAttempt) promptResult(reason acpsdk.StopReason) error { } } -func newSessionAttempt(engine *Engine, ctx context.Context, process *exec.StdioProcess, request Request, sink Sink) *sessionAttempt { +func newSessionAttempt(engine *Engine, ctx context.Context, process *exec.StdioProcess, request Request, sink Sink) (*sessionAttempt, error) { + root, err := os.OpenRoot(request.Cwd) + if err != nil { + return nil, fmt.Errorf("open acp working directory: %w", err) + } turn := newTurn(engine, sink, request.SessionID) attempt := &sessionAttempt{ - engine: engine, - ctx: ctx, - process: process, - connection: acpsdk.NewClientSideConnection(&client{turn: turn}, process.Stdin, process.Stdout), - turn: turn, - settings: request.Settings, - cwd: request.Cwd, - priorSessionID: request.SessionID, - sessionID: request.SessionID, + engine: engine, + ctx: ctx, + process: process, + connection: acpsdk.NewClientSideConnection(&client{ + turn: turn, + cwd: request.Cwd, + root: root, + fileSystemWrite: request.FileSystemWrite, + }, process.Stdin, process.Stdout), + turn: turn, + settings: request.Settings, + cwd: request.Cwd, + root: root, + fileSystemWrite: request.FileSystemWrite, + priorSessionID: request.SessionID, + sessionID: request.SessionID, } attempt.connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) - return attempt + return attempt, nil } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go index 00ccde3b01..fdb7a43359 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go @@ -20,10 +20,11 @@ type SessionSettings struct { // Request contains the provider-neutral inputs for one ACP turn. type Request struct { - Cwd string - Prompt string - SessionID string - Settings SessionSettings + Cwd string + Prompt string + SessionID string + Settings SessionSettings + FileSystemWrite bool } // Result contains the latest session state observed by the ACP engine. diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go index a3938ed9fd..df3a98497f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go @@ -6,6 +6,7 @@ import ( "fmt" "path/filepath" + console "github.com/pluralsh/console/go/client" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" @@ -39,8 +40,14 @@ func NewTransport(agent *Agent) (*Transport, error) { func (*Transport) Kind() toolv1.TransportKind { return toolv1.TransportKindACP } -func (*Transport) Capabilities() toolv1.TransportCapabilities { - return toolv1.TransportCapabilities{SessionResume: true, ToolCallOutputStreaming: false, UsageReporting: true, FileSystemRead: true, FileSystemWrite: true} +func (transport *Transport) Capabilities() toolv1.TransportCapabilities { + return toolv1.TransportCapabilities{ + SessionResume: true, + ToolCallOutputStreaming: false, + UsageReporting: true, + FileSystemRead: true, + FileSystemWrite: transport.agent.config.Run.Mode == console.AgentRunModeWrite, + } } func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest, sink toolv1.TurnSink) (toolv1.TurnResult, error) { @@ -58,7 +65,13 @@ func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest if err != nil { return toolv1.TurnResult{SessionID: request.SessionID}, err } - result, err := transport.engine.Turn(ctx, process, acp.Request{Cwd: transport.workDir, Prompt: request.Prompt, SessionID: request.SessionID, Settings: acp.SessionSettings{ModeID: modeID, ModelID: request.Settings.Model.Name}}, sink) + result, err := transport.engine.Turn(ctx, process, acp.Request{ + Cwd: transport.workDir, + Prompt: request.Prompt, + SessionID: request.SessionID, + Settings: acp.SessionSettings{ModeID: modeID, ModelID: request.Settings.Model.Name}, + FileSystemWrite: transport.Capabilities().FileSystemWrite, + }, sink) return toolv1.TurnResult{SessionID: result.SessionID}, err } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go index a86738b596..ca4a743654 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go @@ -59,7 +59,8 @@ func TestTransportProjectsClaudeACP(t *testing.T) { if err != nil { t.Fatal(err) } - if transport.Kind() != toolv1.TransportKindACP || !transport.Capabilities().SessionResume { + if capabilities := transport.Capabilities(); transport.Kind() != toolv1.TransportKindACP || + !capabilities.SessionResume || capabilities.FileSystemWrite { t.Fatal("transport does not advertise ACP session resume") } settings, err := transport.agent.ResolveSettings(config.Run) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go index a822b53bc4..f674e01630 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go @@ -6,6 +6,7 @@ import ( "fmt" "path/filepath" + console "github.com/pluralsh/console/go/client" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" @@ -50,13 +51,13 @@ func (*Transport) Kind() toolv1.TransportKind { } // Capabilities reports the ACP features implemented by Codex. -func (*Transport) Capabilities() toolv1.TransportCapabilities { +func (transport *Transport) Capabilities() toolv1.TransportCapabilities { return toolv1.TransportCapabilities{ SessionResume: true, ToolCallOutputStreaming: true, UsageReporting: true, FileSystemRead: true, - FileSystemWrite: true, + FileSystemWrite: transport.agent.config.Run.Mode == console.AgentRunModeWrite, } } @@ -79,10 +80,11 @@ func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest return toolv1.TurnResult{SessionID: request.SessionID}, err } result, err := transport.engine.Turn(ctx, process, acp.Request{ - Cwd: transport.repositoryDir, - Prompt: request.Prompt, - SessionID: request.SessionID, - Settings: acp.SessionSettings{ModeID: modeID, ModelID: model, Reasoning: reasoning}, + Cwd: transport.repositoryDir, + Prompt: request.Prompt, + SessionID: request.SessionID, + Settings: acp.SessionSettings{ModeID: modeID, ModelID: model, Reasoning: reasoning}, + FileSystemWrite: transport.Capabilities().FileSystemWrite, }, sink) return toolv1.TurnResult{SessionID: result.SessionID}, err } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport_test.go index b7ecb1abff..6fe2a1ccd4 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport_test.go @@ -88,6 +88,9 @@ func TestTransportProjectsCodexACP(t *testing.T) { if transport.Kind() != toolv1.TransportKindACP { t.Fatalf("transport kind = %q, want ACP", transport.Kind()) } + if transport.Capabilities().FileSystemWrite { + t.Fatal("analyze transport unexpectedly advertises filesystem writes") + } model, reasoning, modeID, err := transport.agent.resolveACPSettings(toolv1.Settings{Mode: console.AgentRunModeAnalyze, Model: toolv1.ModelSelection{Name: "openai/gpt-5.4"}}) if err != nil { t.Fatal(err) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go index c61af43af8..b31ef92466 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go @@ -69,13 +69,13 @@ func (*Transport) Kind() toolv1.TransportKind { return toolv1.TransportKindACP } -func (*Transport) Capabilities() toolv1.TransportCapabilities { +func (transport *Transport) Capabilities() toolv1.TransportCapabilities { return toolv1.TransportCapabilities{ SessionResume: true, ToolCallOutputStreaming: false, UsageReporting: true, FileSystemRead: true, - FileSystemWrite: true, + FileSystemWrite: transport.agent.config.Run.Mode == console.AgentRunModeWrite, } } @@ -135,8 +135,11 @@ func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest } result, err := transport.engine.Turn(ctx, process, acp.Request{ - Cwd: transport.workDir, Prompt: request.Prompt, SessionID: request.SessionID, - Settings: acp.SessionSettings{ModelID: request.Settings.Model.Name}, + Cwd: transport.workDir, + Prompt: request.Prompt, + SessionID: request.SessionID, + Settings: acp.SessionSettings{ModelID: request.Settings.Model.Name}, + FileSystemWrite: transport.Capabilities().FileSystemWrite, }, sink) return toolv1.TurnResult{SessionID: result.SessionID}, err } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go index 481c777567..b14b4f3332 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go @@ -70,7 +70,8 @@ func TestTransportCapabilitiesAndPreCancelledTurn(t *testing.T) { if err != nil { t.Fatal(err) } - if transport.Kind() != toolv1.TransportKindACP || transport.Capabilities().ToolCallOutputStreaming { + if transport.Kind() != toolv1.TransportKindACP || transport.Capabilities().ToolCallOutputStreaming || + !transport.Capabilities().FileSystemWrite { t.Fatalf("transport = %#v", transport.Capabilities()) } ctx, cancel := context.WithCancel(context.Background()) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go index 8a1b794418..55293a194e 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go @@ -6,6 +6,7 @@ import ( "fmt" "path/filepath" + console "github.com/pluralsh/console/go/client" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" @@ -52,13 +53,13 @@ func (*Transport) Kind() toolv1.TransportKind { } // Capabilities reports the ACP features implemented by OpenCode. -func (*Transport) Capabilities() toolv1.TransportCapabilities { +func (transport *Transport) Capabilities() toolv1.TransportCapabilities { return toolv1.TransportCapabilities{ SessionResume: true, ToolCallOutputStreaming: true, UsageReporting: true, FileSystemRead: true, - FileSystemWrite: true, + FileSystemWrite: transport.agent.config.Run.Mode == console.AgentRunModeWrite, } } @@ -83,10 +84,11 @@ func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest } result, err := transport.engine.Turn(ctx, process, acp.Request{ - Cwd: transport.repositoryDir, - Prompt: request.Prompt, - SessionID: request.SessionID, - Settings: settings, + Cwd: transport.repositoryDir, + Prompt: request.Prompt, + SessionID: request.SessionID, + Settings: settings, + FileSystemWrite: transport.Capabilities().FileSystemWrite, }, sink) return toolv1.TurnResult{SessionID: result.SessionID}, err diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go index 8a4a67ba52..f9583281a5 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go @@ -18,6 +18,31 @@ import ( stackv1 "github.com/pluralsh/console/go/deployment-operator/pkg/harness/stackrun/v1" ) +func TestTransportCapabilitiesReflectRunMode(t *testing.T) { + for _, mode := range []console.AgentRunMode{ + console.AgentRunModeAnalyze, + console.AgentRunModeReview, + console.AgentRunModeWrite, + } { + t.Run(string(mode), func(t *testing.T) { + run := agentRun("openai", "gpt-5.4", false, false) + run.Mode = mode + transport, err := NewTransport(NewAgent(toolv1.Config{ + WorkDir: t.TempDir(), + RepositoryDir: t.TempDir(), + Run: run, + })) + if err != nil { + t.Fatal(err) + } + wantWrite := mode == console.AgentRunModeWrite + if got := transport.Capabilities().FileSystemWrite; got != wantWrite { + t.Fatalf("FileSystemWrite = %t, want %t", got, wantWrite) + } + }) + } +} + func TestTransportLaunchPreservesLifecycleHooks(t *testing.T) { binDir := t.TempDir() opencodePath := filepath.Join(binDir, "opencode") From 25b12ee242fc11fec7339a9733aac4a27be2e80f Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Wed, 9 Sep 2026 19:19:10 +0200 Subject: [PATCH 25/46] feat(acp): restrict file reads to root directory and prevent symlink escapes - Refactored `openTextFile` to enforce root-relative path validation. - Updated file opening logic to use `root.OpenFile` for secure operations. - Added tests to validate rejection of file reads outside root. - Introduced symlink escape prevention with comprehensive test cases. --- .../pkg/agentrun-harness/tool/acp/client.go | 7 +- .../agentrun-harness/tool/acp/client_test.go | 75 +++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go index aa2402ce87..348011457f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go @@ -54,10 +54,11 @@ func (client *client) ReadTextFile(ctx context.Context, request acpsdk.ReadTextF } func (client *client) openTextFile(path string) (*os.File, error) { - if !filepath.IsAbs(path) { - return nil, fmt.Errorf("acp filesystem path must be absolute: %q", path) + relativePath, err := client.rootRelativePath(path) + if err != nil { + return nil, err } - file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) + file, err := client.root.OpenFile(relativePath, os.O_RDONLY|syscall.O_NONBLOCK, 0) if err != nil { return nil, fmt.Errorf("read %s: %w", path, err) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go index ce9eb094c1..36d79bb75a 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" "syscall" "testing" "time" @@ -113,6 +114,80 @@ func TestClientRejectsWritesThroughSymlinkEscape(t *testing.T) { } } +func TestClientRejectsReadsOutsideRoot(t *testing.T) { + acpClient, _ := newTestClient(t, true) + path := filepath.Join(t.TempDir(), "outside.txt") + if err := os.WriteFile(path, []byte("outside content"), 0o600); err != nil { + t.Fatalf("write outside file: %v", err) + } + + response, err := acpClient.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{ + SessionId: "session-1", + Path: path, + }) + if err == nil { + t.Fatal("outside-root read unexpectedly succeeded") + } + if response.Content == "outside content" { + t.Fatal("outside-root content was returned") + } + if !strings.Contains(err.Error(), path) { + t.Fatalf("outside-root read error = %v, want original path %q", err, path) + } +} + +func TestClientRejectsReadsThroughSymlinkEscapes(t *testing.T) { + acpClient, directory := newTestClient(t, true) + outside := t.TempDir() + outsideFile := filepath.Join(outside, "outside.txt") + if err := os.WriteFile(outsideFile, []byte("outside content"), 0o600); err != nil { + t.Fatalf("write outside file: %v", err) + } + outsideTarget, err := filepath.Rel(directory, outside) + if err != nil { + t.Fatalf("resolve relative outside target: %v", err) + } + for _, test := range []struct { + name string + linkTarget string + linkPath string + path string + }{ + { + name: "file", + linkTarget: filepath.Join(outsideTarget, "outside.txt"), + linkPath: filepath.Join(directory, "outside-file"), + path: filepath.Join(directory, "outside-file"), + }, + { + name: "directory", + linkTarget: outsideTarget, + linkPath: filepath.Join(directory, "outside-directory"), + path: filepath.Join(directory, "outside-directory", "outside.txt"), + }, + } { + t.Run(test.name, func(t *testing.T) { + if err := os.Symlink(test.linkTarget, test.linkPath); err != nil { + t.Fatalf("create symlink: %v", err) + } + + response, err := acpClient.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{ + SessionId: "session-1", + Path: test.path, + }) + if err == nil { + t.Fatal("symlink escape read unexpectedly succeeded") + } + if response.Content == "outside content" { + t.Fatal("symlink escape content was returned") + } + if !strings.Contains(err.Error(), test.path) { + t.Fatalf("symlink escape read error = %v, want original path %q", err, test.path) + } + }) + } +} + func TestClientRejectsRelativeAndForeignSessionPaths(t *testing.T) { acpClient, directory := newTestClient(t, true) for _, request := range []acpsdk.ReadTextFileRequest{ From 015dc43c7561c8791fb1892acb2aaf9e622d6c90 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Thu, 10 Sep 2026 10:56:00 +0200 Subject: [PATCH 26/46] feat(acp): add configurable authentication method support to engine - Introduced `authenticationMethod` field in `Engine` for configurable ACP authentication. - Added `WithAuthenticationMethod` option to set authentication method on the engine. - Updated session logic to authenticate using the specified method before session operations. - Enhanced `NewEngine` to support authentication method configuration. - Modified `Transport` to pass `gemini-api-key` authentication method for Gemini agent. - Added tests to validate authentication behavior, including method configuration, failure cases, and call order. --- .../pkg/agentrun-harness/tool/acp/engine.go | 9 +- .../agentrun-harness/tool/acp/engine_test.go | 169 +++++++++++++++++- .../pkg/agentrun-harness/tool/acp/session.go | 38 ++++ .../pkg/agentrun-harness/tool/acp/types.go | 10 ++ .../agentrun-harness/tool/gemini/transport.go | 2 + 5 files changed, 217 insertions(+), 11 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go index 1a109bf773..35a98e7f54 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go @@ -23,10 +23,11 @@ const defaultStopTimeout = 15 * time.Second // Engine owns one provider-neutral ACP protocol implementation. It does not // launch processes or retain provider configuration. type Engine struct { - stopTimeout time.Duration - costs *usage.Usage - restoreSession SessionRestorer - usageResolver UsageResolver + stopTimeout time.Duration + costs *usage.Usage + restoreSession SessionRestorer + authenticationMethod string + usageResolver UsageResolver } func (engine *Engine) setSessionConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption, settings SessionSettings) error { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go index b9afb938e4..d0d5edb8af 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -22,6 +22,8 @@ type testState struct { newSessions []acpsdk.NewSessionRequest resumedSessions []acpsdk.ResumeSessionRequest loadedSessions []acpsdk.LoadSessionRequest + authentications []acpsdk.AuthenticateRequest + callOrder []string loadSessionUpdates []acpsdk.SessionNotification prompts []string initializations []acpsdk.InitializeRequest @@ -39,6 +41,8 @@ type testState struct { promptRelease chan struct{} promptOnce sync.Once protocolVersion int + authMethods []acpsdk.AuthMethod + authenticateErr error } type testAgent struct { @@ -46,19 +50,29 @@ type testAgent struct { conn *acpsdk.AgentSideConnection } -func (agent *testAgent) Authenticate(context.Context, acpsdk.AuthenticateRequest) (acpsdk.AuthenticateResponse, error) { +func (agent *testAgent) Authenticate(_ context.Context, request acpsdk.AuthenticateRequest) (acpsdk.AuthenticateResponse, error) { + agent.state.mu.Lock() + agent.state.authentications = append(agent.state.authentications, request) + agent.state.callOrder = append(agent.state.callOrder, "authenticate") + err := agent.state.authenticateErr + agent.state.mu.Unlock() + if err != nil { + return acpsdk.AuthenticateResponse{}, err + } return acpsdk.AuthenticateResponse{}, nil } func (agent *testAgent) Initialize(_ context.Context, request acpsdk.InitializeRequest) (acpsdk.InitializeResponse, error) { agent.state.mu.Lock() agent.state.initializations = append(agent.state.initializations, request) + agent.state.callOrder = append(agent.state.callOrder, "initialize") version := agent.state.protocolVersion + authMethods := append([]acpsdk.AuthMethod(nil), agent.state.authMethods...) agent.state.mu.Unlock() if version == 0 { version = acpsdk.ProtocolVersionNumber } - return acpsdk.InitializeResponse{ProtocolVersion: acpsdk.ProtocolVersion(version)}, nil + return acpsdk.InitializeResponse{ProtocolVersion: acpsdk.ProtocolVersion(version), AuthMethods: authMethods}, nil } func (agent *testAgent) Logout(context.Context, acpsdk.LogoutRequest) (acpsdk.LogoutResponse, error) { @@ -83,6 +97,7 @@ func (agent *testAgent) ListSessions(context.Context, acpsdk.ListSessionsRequest func (agent *testAgent) NewSession(ctx context.Context, request acpsdk.NewSessionRequest) (acpsdk.NewSessionResponse, error) { agent.state.mu.Lock() agent.state.newSessions = append(agent.state.newSessions, request) + agent.state.callOrder = append(agent.state.callOrder, "new") updates := append([]acpsdk.SessionNotification(nil), agent.state.newSessionUpdates...) sessionID := agent.state.sessionID options := append([]acpsdk.SessionConfigOption(nil), agent.state.configOptions...) @@ -142,6 +157,7 @@ func (agent *testAgent) ResumeSession(_ context.Context, request acpsdk.ResumeSe func (agent *testAgent) LoadSession(ctx context.Context, request acpsdk.LoadSessionRequest) (acpsdk.LoadSessionResponse, error) { agent.state.mu.Lock() agent.state.loadedSessions = append(agent.state.loadedSessions, request) + agent.state.callOrder = append(agent.state.callOrder, "load") updates := append([]acpsdk.SessionNotification(nil), agent.state.loadSessionUpdates...) options := append([]acpsdk.SessionConfigOption(nil), agent.state.configOptions...) modes := agent.state.modes @@ -318,13 +334,20 @@ func newTestAgentProcess(state *testState, stdinCloseEnds bool) (*testState, *ex func TestNewEngineOptionsPreserveDefaultsAndApplyOverrides(t *testing.T) { standard := &acpsdk.Usage{InputTokens: 3} defaults := NewEngine(WithStopTimeout(0), WithSessionRestorer(nil), WithUsageResolver(nil)) - if defaults.stopTimeout != defaultStopTimeout || defaults.restoreSession == nil || defaults.usageResolver(acpsdk.PromptResponse{Usage: standard}) != standard { + if defaults.stopTimeout != defaultStopTimeout || defaults.restoreSession == nil || + defaults.authenticationMethod != "" || defaults.usageResolver(acpsdk.PromptResponse{Usage: standard}) != standard { t.Fatalf("default engine = %#v", defaults) } resolver := func(acpsdk.PromptResponse) *acpsdk.Usage { return &acpsdk.Usage{InputTokens: 5} } - configured := NewEngine(WithStopTimeout(time.Second), WithSessionRestorer(LoadSession), WithUsageResolver(resolver)) - if configured.stopTimeout != time.Second || configured.restoreSession == nil || configured.usageResolver(acpsdk.PromptResponse{}).InputTokens != 5 { + configured := NewEngine( + WithStopTimeout(time.Second), + WithSessionRestorer(LoadSession), + WithAuthenticationMethod("api-key"), + WithUsageResolver(resolver), + ) + if configured.stopTimeout != time.Second || configured.restoreSession == nil || + configured.authenticationMethod != "api-key" || configured.usageResolver(acpsdk.PromptResponse{}).InputTokens != 5 { t.Fatalf("configured engine = %#v", configured) } } @@ -369,8 +392,19 @@ func TestEngineTurnCreatesAndResumesSession(t *testing.T) { t.Fatalf("resume turn: %v", err) } newCount, resumeCount, promptCount, _, _, _, prompts := state.snapshot() - if newCount != 1 || resumeCount != 1 || promptCount != 2 || second.SessionID != first.SessionID { - t.Fatalf("sessions = new %d resume %d prompts %d result %q", newCount, resumeCount, promptCount, second.SessionID) + state.mu.Lock() + authentications := len(state.authentications) + state.mu.Unlock() + if newCount != 1 || resumeCount != 1 || promptCount != 2 || + authentications != 0 || second.SessionID != first.SessionID { + t.Fatalf( + "sessions = new %d resume %d prompts %d authentications %d result %q", + newCount, + resumeCount, + promptCount, + authentications, + second.SessionID, + ) } if strings.Join(prompts, ",") != "first,second" { t.Fatalf("prompts = %v", prompts) @@ -445,6 +479,127 @@ func TestEngineTurnLoadsSessionWhenConfigured(t *testing.T) { } } +func TestEngineTurnAuthenticatesBeforeLoadingConfiguredSession(t *testing.T) { + state := newTestState() + state.authMethods = []acpsdk.AuthMethod{{ + Agent: &acpsdk.AuthMethodAgent{Id: "gemini-api-key", Name: "Gemini API key"}, + }} + _, process, _ := newTestAgentProcess(state, true) + + result, err := NewEngine( + WithAuthenticationMethod("gemini-api-key"), + WithSessionRestorer(LoadSession), + ).Turn(context.Background(), process, Request{ + Cwd: t.TempDir(), + Prompt: "load", + SessionID: "session-1", + }, &testSink{}) + if err != nil { + t.Fatalf("load turn: %v", err) + } + + state.mu.Lock() + authentications := append([]acpsdk.AuthenticateRequest(nil), state.authentications...) + callOrder := append([]string(nil), state.callOrder...) + loads := append([]acpsdk.LoadSessionRequest(nil), state.loadedSessions...) + state.mu.Unlock() + if result.SessionID != "session-1" || len(loads) != 1 || + len(authentications) != 1 || authentications[0].MethodId != "gemini-api-key" { + t.Fatalf("result = %#v loads = %#v authentications = %#v", result, loads, authentications) + } + if strings.Join(callOrder, ",") != "initialize,authenticate,load" { + t.Fatalf("call order = %v", callOrder) + } +} + +func TestEngineTurnAuthenticatesBeforeCreatingConfiguredSession(t *testing.T) { + state := newTestState() + state.authMethods = []acpsdk.AuthMethod{{ + Agent: &acpsdk.AuthMethodAgent{Id: "gemini-api-key", Name: "Gemini API key"}, + }} + _, process, _ := newTestAgentProcess(state, true) + + result, err := NewEngine(WithAuthenticationMethod("gemini-api-key")).Turn( + context.Background(), + process, + Request{Cwd: t.TempDir(), Prompt: "new"}, + &testSink{}, + ) + if err != nil { + t.Fatalf("new turn: %v", err) + } + + state.mu.Lock() + callOrder := append([]string(nil), state.callOrder...) + newSessions := len(state.newSessions) + state.mu.Unlock() + if result.SessionID != "session-1" || newSessions != 1 { + t.Fatalf("result = %#v new sessions = %d", result, newSessions) + } + if strings.Join(callOrder, ",") != "initialize,authenticate,new" { + t.Fatalf("call order = %v", callOrder) + } +} + +func TestEngineTurnStopsProcessWhenAuthenticationFails(t *testing.T) { + state := newTestState() + state.authMethods = []acpsdk.AuthMethod{{ + Agent: &acpsdk.AuthMethodAgent{Id: "gemini-api-key", Name: "Gemini API key"}, + }} + state.authenticateErr = errors.New("authentication failed") + _, process, processFixture := newTestAgentProcess(state, false) + + _, err := NewEngine( + WithAuthenticationMethod("gemini-api-key"), + WithStopTimeout(10*time.Millisecond), + ).Turn( + context.Background(), + process, + Request{Cwd: t.TempDir(), Prompt: "new"}, + &testSink{}, + ) + if err == nil || !strings.Contains(err.Error(), "acp authenticate:") || + !strings.Contains(err.Error(), "authentication failed") { + t.Fatalf("authentication error = %v", err) + } + + if processFixture.killCount() == 0 { + t.Fatal("authentication failure did not stop the process") + } + + state.mu.Lock() + newSessions := len(state.newSessions) + state.mu.Unlock() + if newSessions != 0 { + t.Fatalf("new sessions = %d", newSessions) + } +} + +func TestEngineTurnRejectsUnadvertisedAuthenticationMethod(t *testing.T) { + state := newTestState() + _, process, _ := newTestAgentProcess(state, true) + + _, err := NewEngine( + WithAuthenticationMethod("gemini-api-key"), + WithSessionRestorer(LoadSession), + ).Turn(context.Background(), process, Request{ + Cwd: t.TempDir(), + Prompt: "load", + SessionID: "session-1", + }, &testSink{}) + if err == nil || !strings.Contains(err.Error(), `acp authentication method "gemini-api-key" is not advertised`) { + t.Fatalf("load error = %v", err) + } + + state.mu.Lock() + authentications := len(state.authentications) + loads := len(state.loadedSessions) + state.mu.Unlock() + if authentications != 0 || loads != 0 { + t.Fatalf("authentications = %d loads = %d", authentications, loads) + } +} + func TestEngineTurnSuppressesLoadSessionHistory(t *testing.T) { state := newTestState() state.loadSessionUpdates = []acpsdk.SessionNotification{ diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go index e32424f4e1..ea72b48a23 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go @@ -44,6 +44,9 @@ func (attempt *sessionAttempt) run(prompt string) error { if initialize.ProtocolVersion != acpsdk.ProtocolVersionNumber { return attempt.fail(fmt.Errorf("acp protocol version %d is unsupported", initialize.ProtocolVersion), false) } + if err = attempt.authenticate(initialize.AuthMethods); err != nil { + return attempt.fail(err, attempt.cancelled()) + } details, err := attempt.openSession(attempt.cwd) if err != nil { @@ -81,6 +84,41 @@ func (attempt *sessionAttempt) run(prompt string) error { return nil } +func (attempt *sessionAttempt) authenticate(methods []acpsdk.AuthMethod) error { + methodID := attempt.engine.authenticationMethod + if methodID == "" { + return nil + } + + if !attempt.authenticationMethodAvailable(methods, methodID) { + return fmt.Errorf("acp authentication method %q is not advertised", methodID) + } + + if _, err := attempt.connection.Authenticate( + attempt.ctx, + acpsdk.AuthenticateRequest{MethodId: methodID}, + ); err != nil { + return fmt.Errorf("acp authenticate: %w", err) + } + + return nil +} + +func (attempt *sessionAttempt) authenticationMethodAvailable(methods []acpsdk.AuthMethod, methodID string) bool { + for _, method := range methods { + switch { + case method.Agent != nil && method.Agent.Id == methodID: + return true + case method.EnvVar != nil && method.EnvVar.Id == methodID: + return true + case method.Terminal != nil && method.Terminal.Id == methodID: + return true + } + } + + return false +} + func (attempt *sessionAttempt) configureSession(details sessionDetails) error { err := attempt.engine.setSessionConfig(attempt.ctx, attempt.connection, details.sessionID, details.modes, details.configOptions, attempt.settings) if err != nil && attempt.priorSessionID == "" { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go index fdb7a43359..d12c82be8f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go @@ -109,6 +109,16 @@ func WithSessionRestorer(restorer SessionRestorer) Option { } } +// WithAuthenticationMethod sets the provider-selected ACP authentication +// method. The engine authenticates with this method before opening a session. +func WithAuthenticationMethod(methodID string) Option { + return func(engine *Engine) { + if methodID != "" { + engine.authenticationMethod = methodID + } + } +} + // WithUsageResolver sets a non-nil provider prompt usage resolver. func WithUsageResolver(resolver UsageResolver) Option { return func(engine *Engine) { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go index b31ef92466..7f6c95a58c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go @@ -26,6 +26,7 @@ const ( geminiTrustWorkspaceEnv = "GEMINI_CLI_TRUST_WORKSPACE" geminiHomeEnv = "GEMINI_CLI_HOME" geminiTrustWorkspace = "true" + geminiAPIKeyAuthMethod = "gemini-api-key" ) type Transport struct { @@ -57,6 +58,7 @@ func NewTransport(agent *Agent) (*Transport, error) { } engine := acp.NewEngine( + acp.WithAuthenticationMethod(geminiAPIKeyAuthMethod), acp.WithSessionRestorer(acp.LoadSession), acp.WithUsageResolver(geminiPromptUsage), ) From feba34c62ae9c88786a4311a86ae18b535b37377 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Thu, 10 Sep 2026 11:08:12 +0200 Subject: [PATCH 27/46] refactor(tool): update usage resolver and token count logic in Gemini transport - Renamed `geminiPromptUsage` to `toUsage` and converted it to a method of `Transport`. - Refactored `geminiTokenCount` to `toTokenCount`, making it a method of `Transport`. - Updated `engine` initialization to use the new `toUsage` method for usage resolution in Gemini transport. --- .../pkg/agentrun-harness/tool/gemini/transport.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go index 7f6c95a58c..e86709a6a3 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go @@ -60,7 +60,7 @@ func NewTransport(agent *Agent) (*Transport, error) { engine := acp.NewEngine( acp.WithAuthenticationMethod(geminiAPIKeyAuthMethod), acp.WithSessionRestorer(acp.LoadSession), - acp.WithUsageResolver(geminiPromptUsage), + acp.WithUsageResolver(result.toUsage), ) result.engine = engine @@ -81,7 +81,7 @@ func (transport *Transport) Capabilities() toolv1.TransportCapabilities { } } -func geminiPromptUsage(response acpsdk.PromptResponse) *acpsdk.Usage { +func (transport *Transport) toUsage(response acpsdk.PromptResponse) *acpsdk.Usage { if response.Usage != nil { return response.Usage } @@ -95,12 +95,12 @@ func geminiPromptUsage(response acpsdk.PromptResponse) *acpsdk.Usage { return nil } - input, ok := geminiTokenCount(tokenCount["input_tokens"]) + input, ok := transport.toTokenCount(tokenCount["input_tokens"]) if !ok { return nil } - output, ok := geminiTokenCount(tokenCount["output_tokens"]) + output, ok := transport.toTokenCount(tokenCount["output_tokens"]) if !ok || input > int(^uint(0)>>1)-output { return nil } @@ -108,7 +108,7 @@ func geminiPromptUsage(response acpsdk.PromptResponse) *acpsdk.Usage { return &acpsdk.Usage{InputTokens: input, OutputTokens: output, TotalTokens: input + output} } -func geminiTokenCount(value any) (int, bool) { +func (transport *Transport) toTokenCount(value any) (int, bool) { tokens, ok := value.(float64) limit := math.Ldexp(1, strconv.IntSize-1) From dac9da5d22d8b394def70568efa21e206d23529b Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Thu, 10 Sep 2026 11:32:18 +0200 Subject: [PATCH 28/46] feat(acp): improve tool call handling and session updates - Introduced mutex for tool call state (`toolMu`) to ensure thread-safe access during updates. - Refactored tool call logic with `startToolLocked` and `upsertPermissionTool` methods for enhanced modularity and error handling. - Added `permissionToolCallStart` and `permissionToolCallUpdate` utility methods for streamlined tool call creation and updates. - Improved session handling with stricter error checks and conditionally locking mechanisms. - Enhanced tool call metadata management with `setName` and `displayName` adjustments, providing better clarity for `title` and `kind` fallback behavior. - Updated file client logic to improve read/write behavior and added error coverage for edge cases. - Added new tests to validate tool call fallbacks, session-based permission errors, and redundant calls prevention. --- .../pkg/agentrun-harness/tool/acp/client.go | 33 ++++- .../agentrun-harness/tool/acp/client_test.go | 109 +++++++++++++++ .../agentrun-harness/tool/acp/tool_call.go | 32 +++-- .../tool/acp/tool_call_test.go | 24 ++++ .../pkg/agentrun-harness/tool/acp/updates.go | 131 ++++++++++++++++-- .../tool/gemini/transport_test.go | 16 ++- 6 files changed, 320 insertions(+), 25 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go index 348011457f..e6abd4846e 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go @@ -32,6 +32,7 @@ func (client *client) ReadTextFile(ctx context.Context, request acpsdk.ReadTextF if err := ctx.Err(); err != nil { return acpsdk.ReadTextFileResponse{}, err } + file, err := client.openTextFile(request.Path) if err != nil { return acpsdk.ReadTextFileResponse{}, err @@ -46,35 +47,43 @@ func (client *client) ReadTextFile(ctx context.Context, request acpsdk.ReadTextF if exhausted { return acpsdk.ReadTextFileResponse{}, nil } + content, err := client.readTextFileContent(reader, request.Path, request.Limit) if err != nil { return acpsdk.ReadTextFileResponse{}, err } + return acpsdk.ReadTextFileResponse{Content: content}, nil } func (client *client) openTextFile(path string) (*os.File, error) { relativePath, err := client.rootRelativePath(path) + if err != nil { return nil, err } + file, err := client.root.OpenFile(relativePath, os.O_RDONLY|syscall.O_NONBLOCK, 0) if err != nil { return nil, fmt.Errorf("read %s: %w", path, err) } + info, err := file.Stat() if err != nil { _ = file.Close() return nil, fmt.Errorf("stat %s: %w", path, err) } + if !info.Mode().IsRegular() { _ = file.Close() return nil, fmt.Errorf("acp filesystem path is not a regular file: %q", path) } + if info.Size() > maxTextFileBytes { _ = file.Close() return nil, fmt.Errorf("acp filesystem file exceeds %d-byte read limit: %q", maxTextFileBytes, path) } + return file, nil } @@ -82,7 +91,9 @@ func (client *client) skipTextFileLines(reader *bufio.Reader, line *int, path st if line == nil { return false, nil } + for current := 1; current < max(*line, 1); current++ { + if _, err := reader.ReadString('\n'); err != nil { if errors.Is(err, io.EOF) { return true, nil @@ -90,18 +101,21 @@ func (client *client) skipTextFileLines(reader *bufio.Reader, line *int, path st return false, fmt.Errorf("read %s: %w", path, err) } } + return false, nil } func (client *client) readTextFileContent(reader *bufio.Reader, path string, limit *int) (string, error) { if limit == nil || *limit <= 0 { content, err := io.ReadAll(reader) + if err != nil { return "", fmt.Errorf("read %s: %w", path, err) } if len(content) > maxTextFileBytes { return "", fmt.Errorf("acp filesystem file exceeds %d-byte read limit: %q", maxTextFileBytes, path) } + return string(content), nil } @@ -109,6 +123,7 @@ func (client *client) readTextFileContent(reader *bufio.Reader, path string, lim for len(lines) < *limit { line, err := reader.ReadString('\n') lines = append(lines, strings.TrimSuffix(line, "\n")) + if err != nil { if errors.Is(err, io.EOF) { break @@ -116,6 +131,7 @@ func (client *client) readTextFileContent(reader *bufio.Reader, path string, lim return "", fmt.Errorf("read %s: %w", path, err) } } + return strings.Join(lines, "\n"), nil } @@ -138,6 +154,7 @@ func (client *client) WriteTextFile(ctx context.Context, request acpsdk.WriteTex if !client.fileSystemWrite { return acpsdk.WriteTextFileResponse{}, errors.New("acp filesystem writes are disabled") } + path, err := client.rootRelativePath(request.Path) if err != nil { return acpsdk.WriteTextFileResponse{}, err @@ -145,18 +162,21 @@ func (client *client) WriteTextFile(ctx context.Context, request acpsdk.WriteTex if ctx == nil { ctx = context.Background() } + if err := ctx.Err(); err != nil { return acpsdk.WriteTextFileResponse{}, err } if err := client.root.MkdirAll(filepath.Dir(path), 0o755); err != nil { return acpsdk.WriteTextFileResponse{}, fmt.Errorf("mkdir %s: %w", request.Path, err) } + if err := ctx.Err(); err != nil { return acpsdk.WriteTextFileResponse{}, err } if err := client.root.WriteFile(path, []byte(request.Content), 0o644); err != nil { return acpsdk.WriteTextFileResponse{}, fmt.Errorf("write %s: %w", request.Path, err) } + return acpsdk.WriteTextFileResponse{}, nil } @@ -167,14 +187,23 @@ func (client *client) rootRelativePath(path string) (string, error) { if !filepath.IsAbs(path) { return "", fmt.Errorf("acp filesystem path must be absolute: %q", path) } + relative, err := filepath.Rel(client.cwd, path) if err != nil || relative == "." || !filepath.IsLocal(relative) { return "", fmt.Errorf("acp filesystem path is outside the working directory: %q", path) } + return relative, nil } -func (client *client) RequestPermission(context.Context, acpsdk.RequestPermissionRequest) (acpsdk.RequestPermissionResponse, error) { +func (client *client) RequestPermission(_ context.Context, request acpsdk.RequestPermissionRequest) (acpsdk.RequestPermissionResponse, error) { + if err := client.validateSession(request.SessionId); err != nil { + return acpsdk.RequestPermissionResponse{}, err + } + if err := client.turn.upsertPermissionTool(&request.ToolCall); err != nil { + return acpsdk.RequestPermissionResponse{}, err + } + return acpsdk.RequestPermissionResponse{}, errors.New("acp permission requests are unavailable in unattended runs") } @@ -210,9 +239,11 @@ func (client *client) validateSession(sessionID acpsdk.SessionId) error { if client.turn == nil { return errors.New("acp client is not attached to a turn") } + expected := client.turn.sessionID() if sessionID != acpsdk.SessionId(expected) { return fmt.Errorf("acp request belongs to session %q, expected %q", sessionID, expected) } + return nil } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go index 36d79bb75a..efe9cd42d2 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -13,6 +13,8 @@ import ( import acpsdk "github.com/coder/acp-go-sdk" +import console "github.com/pluralsh/console/go/client" + func newTestClient(t *testing.T, fileSystemWrite bool) (*client, string) { t.Helper() directory := t.TempDir() @@ -58,6 +60,113 @@ func TestClientRejectsWritesWithoutPermission(t *testing.T) { } } +func TestClientRequestPermissionStartsToolCallBeforeDenying(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(), sink, "session-1")} + title := "Run command" + kind := acpsdk.ToolKindExecute + + _, err := acpClient.RequestPermission(context.Background(), acpsdk.RequestPermissionRequest{ + SessionId: "session-1", + ToolCall: acpsdk.ToolCallUpdate{ + ToolCallId: "call-1", + Title: &title, + Kind: &kind, + }, + }) + if err == nil || err.Error() != "acp permission requests are unavailable in unattended runs" { + t.Fatalf("permission error = %v, want unattended permission denial", err) + } + if len(sink.messages) != 1 { + t.Fatalf("permission-start tool messages = %d, want 1", len(sink.messages)) + } + if state := sink.messages[0].Metadata.Tool.State; state == nil || *state != console.AgentMessageToolStatePending { + t.Fatalf("permission-start tool state = %v, want pending", state) + } + + completed := acpsdk.ToolCallStatusCompleted + err = acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", + Status: &completed, + }}, + }) + if err != nil { + t.Fatalf("tool call update after permission denial: %v", err) + } + if len(sink.messages) != 2 { + t.Fatalf("tool call messages = %d, want 2", len(sink.messages)) + } +} + +func TestClientRequestPermissionUpdatesExistingToolCallBeforeDenying(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(), sink, "session-1")} + inProgress := acpsdk.ToolCallStatusInProgress + execute := acpsdk.ToolKindExecute + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ + ToolCallId: "call-1", + Kind: execute, + Status: inProgress, + }}, + }); err != nil { + t.Fatalf("start tool call: %v", err) + } + + read := acpsdk.ToolKindRead + _, err := acpClient.RequestPermission(context.Background(), acpsdk.RequestPermissionRequest{ + SessionId: "session-1", + ToolCall: acpsdk.ToolCallUpdate{ + ToolCallId: "call-1", + Kind: &read, + RawInput: map[string]any{"command": "rm -rf build"}, + }, + }) + if err == nil || err.Error() != "acp permission requests are unavailable in unattended runs" { + t.Fatalf("permission error = %v, want unattended permission denial", err) + } + if len(sink.messages) != 2 { + t.Fatalf("tool call messages = %d, want 2", len(sink.messages)) + } + tool := sink.messages[1].Metadata.Tool + if tool.Name == nil || *tool.Name != string(read) { + t.Fatalf("updated tool name = %v, want %q", tool.Name, read) + } + if tool.Input == nil || *tool.Input != `{"command":"rm -rf build"}` { + t.Fatalf("updated tool input = %v", tool.Input) + } +} + +func TestClientRequestPermissionReturnsSessionAndToolCallErrors(t *testing.T) { + acpClient := &client{turn: newTurn(NewEngine(), &testSink{}, "session-1")} + for _, test := range []struct { + name string + request acpsdk.RequestPermissionRequest + want string + }{ + { + name: "foreign session", + request: acpsdk.RequestPermissionRequest{SessionId: "session-2", ToolCall: acpsdk.ToolCallUpdate{ToolCallId: "call-1"}}, + want: `acp request belongs to session "session-2", expected "session-1"`, + }, + { + name: "missing tool call id", + request: acpsdk.RequestPermissionRequest{SessionId: "session-1"}, + want: "acp tool call has an empty id", + }, + } { + t.Run(test.name, func(t *testing.T) { + _, err := acpClient.RequestPermission(context.Background(), test.request) + if err == nil || err.Error() != test.want { + t.Fatalf("permission error = %v, want %q", err, test.want) + } + }) + } +} + func TestClientRejectsWritesOutsideRoot(t *testing.T) { acpClient, _ := newTestClient(t, true) outside := t.TempDir() diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go index 4b20da965b..d3cac6d269 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go @@ -15,6 +15,8 @@ const runningToolOutput = "running..." type toolCall struct { id string name string + title string + kind acpsdk.ToolKind input string output string state console.AgentMessageToolState @@ -122,6 +124,7 @@ type toolUpdateEvents struct { func (call *toolCall) message() *console.AgentMessageAttributes { name := call.name output := call.output + state := call.state if output == "" && (call.state == console.AgentMessageToolStateRunning || call.state == console.AgentMessageToolStatePending) { output = runningToolOutput } @@ -130,7 +133,7 @@ func (call *toolCall) message() *console.AgentMessageAttributes { Message: "Called tool", Metadata: &console.AgentMessageMetadataAttributes{ Tool: &console.AgentMessageToolAttributes{ - Name: new(name), State: &call.state, Output: new(output), + Name: new(name), State: &state, Output: new(output), }, }, } @@ -141,20 +144,33 @@ func (call *toolCall) message() *console.AgentMessageAttributes { } func (call *toolCall) setName(title string, kind acpsdk.ToolKind) { + call.title = title + call.kind = kind + call.name = call.displayName() +} + +func (call *toolCall) displayName() string { switch { - case title != "": - call.name = title - case kind != "": - call.name = string(kind) + case call.title != "": + return call.title + case call.kind != "": + return string(call.kind) default: - call.name = "tool" + return "tool" } } func (call *toolCall) updateMetadata(update *acpsdk.SessionToolCallUpdate) bool { changed := false - if update.Title != nil && call.name != *update.Title { - call.name = *update.Title + if update.Title != nil { + call.title = *update.Title + } + if update.Kind != nil { + call.kind = *update.Kind + } + name := call.displayName() + if call.name != name { + call.name = name changed = true } if update.RawInput != nil { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go index 7cc178684e..25940b08c6 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go @@ -22,6 +22,26 @@ func TestToolCallPrefersContentAndFormatsRawOutput(t *testing.T) { } } +func TestToolCallUpdatesFallbackNameFromKind(t *testing.T) { + call := &toolCall{} + call.setName("", acpsdk.ToolKindExecute) + read := acpsdk.ToolKindRead + if !call.updateMetadata(&acpsdk.SessionToolCallUpdate{Kind: &read}) { + t.Fatal("kind-only update did not change fallback name") + } + if call.name != string(read) { + t.Fatalf("fallback tool name = %q, want %q", call.name, read) + } + + call.setName("Explicit title", acpsdk.ToolKindExecute) + if call.updateMetadata(&acpsdk.SessionToolCallUpdate{Kind: &read}) { + t.Fatal("kind-only update changed explicit title") + } + if call.name != "Explicit title" { + t.Fatalf("explicit tool name = %q", call.name) + } +} + func TestToolCallMapsAdapterTerminalOutput(t *testing.T) { sink := &testSink{} turn := &turnState{sink: sink, tools: map[string]*toolCall{"call-1": {id: "call-1"}}} @@ -74,6 +94,10 @@ func TestToolCallMessageUsesRunningOutputAndInput(t *testing.T) { if *message.Metadata.Tool.Input != `{"command":"ls"}` { t.Fatalf("tool input = %q", *message.Metadata.Tool.Input) } + call.state = console.AgentMessageToolStateCompleted + if *message.Metadata.Tool.State != console.AgentMessageToolStateRunning { + t.Fatalf("message state = %q, want running snapshot", *message.Metadata.Tool.State) + } } func TestToolCallStatusMapping(t *testing.T) { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go index e2dff5e974..69d5e9f9d3 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go @@ -18,6 +18,7 @@ type turnState struct { engine *Engine sink Sink mu sync.Mutex + toolMu sync.Mutex sessionIDValue string errValue error assistant strings.Builder @@ -31,6 +32,7 @@ func (turn *turnState) contentText(content acpsdk.ContentBlock) (string, error) if content.Text != nil { return content.Text.Text, nil } + return "", errors.New("expected text content") } @@ -38,6 +40,7 @@ func (turn *turnState) normalizeUsage(providerUsage *acpsdk.Usage) (input, outpu input = int64(max(providerUsage.InputTokens, 0)) output = int64(max(providerUsage.OutputTokens, 0)) total = max(int64(max(providerUsage.TotalTokens, 0)), input+output) + if providerUsage.CachedReadTokens != nil { cached += int64(max(*providerUsage.CachedReadTokens, 0)) } @@ -50,6 +53,7 @@ func (turn *turnState) normalizeUsage(providerUsage *acpsdk.Usage) (input, outpu total = input + output + thought } } + return } @@ -87,10 +91,12 @@ func (turn *turnState) setErr(err error) { if err == nil { return } + turn.mu.Lock() if turn.errValue == nil { turn.errValue = err } + turn.mu.Unlock() } @@ -101,6 +107,7 @@ func (turn *turnState) handle(notification acpsdk.SessionNotification) error { if turn.isRestoring() { return nil } + update := notification.Update switch { case update.AgentMessageChunk != nil: @@ -123,6 +130,7 @@ func (turn *turnState) handle(notification acpsdk.SessionNotification) error { // not affect the Console message contract. klog.V(log.LogLevelDebug).InfoS("ignoring optional ACP session update") } + return nil } @@ -142,9 +150,11 @@ func (turn *turnState) bindNotification(sessionID acpsdk.SessionId) error { turn.sessionIDValue = expected } turn.mu.Unlock() + if sessionID == acpsdk.SessionId(expected) { return nil } + return turn.sessionUpdateMismatch(sessionID, expected) } @@ -154,6 +164,7 @@ func (turn *turnState) appendTextChunk(content acpsdk.ContentBlock, target *stri turn.setErr(fmt.Errorf("acp %s content: %w", kind, err)) return err } + turn.mu.Lock() target.WriteString(text) turn.mu.Unlock() @@ -170,40 +181,131 @@ func (turn *turnState) startTool(update *acpsdk.SessionUpdateToolCall) error { if update.ToolCallId == "" { return turn.fail("acp tool call has an empty id") } - id := string(update.ToolCallId) + + turn.toolMu.Lock() + defer turn.toolMu.Unlock() turn.mu.Lock() + message, output, err := turn.startToolLocked(update) + turn.mu.Unlock() + + if err != nil { + return turn.fail(err.Error()) + } + + turn.sink.Message(message, string(update.ToolCallId)) + if output != "" { + turn.sink.ToolCallOutput(string(update.ToolCallId), output) + } + + return nil +} + +func (turn *turnState) startToolLocked(update *acpsdk.SessionUpdateToolCall) (*console.AgentMessageAttributes, string, error) { + id := string(update.ToolCallId) if _, exists := turn.tools[id]; exists { - turn.mu.Unlock() - return turn.fail(fmt.Sprintf("acp tool call %q was started twice", id)) + return nil, "", fmt.Errorf("acp tool call %q was started twice", id) } + call := &toolCall{id: id} call.input = call.formatValue(update.RawInput) call.setName(update.Title, update.Kind) if _, _, err := call.updateStatus(&update.Status); err != nil { - turn.mu.Unlock() - return turn.fail(err.Error()) + return nil, "", err } + toolOutputValue := call.toolOutput(update.Content, update.Meta, update.RawOutput) call.applyOutput(toolOutputValue) turn.tools[id] = call - message := call.message() - output := call.output + return call.message(), call.output, nil +} + +func (turn *turnState) upsertPermissionTool(update *acpsdk.ToolCallUpdate) error { + if update.ToolCallId == "" { + return turn.fail("acp tool call has an empty id") + } + + turn.toolMu.Lock() + defer turn.toolMu.Unlock() + turn.mu.Lock() + + if _, exists := turn.tools[string(update.ToolCallId)]; exists { + events, err := turn.applyToolUpdate(turn.permissionToolCallUpdate(update)) + turn.mu.Unlock() + + if err != nil { + turn.setErr(err) + return err + } + + turn.emitToolUpdate(update.ToolCallId, events) + return nil + } + + message, output, err := turn.startToolLocked(turn.permissionToolCallStart(update)) turn.mu.Unlock() - turn.sink.Message(message, id) + if err != nil { + return turn.fail(err.Error()) + } + + turn.sink.Message(message, string(update.ToolCallId)) if output != "" { - turn.sink.ToolCallOutput(id, output) + turn.sink.ToolCallOutput(string(update.ToolCallId), output) } + return nil } +func (*turnState) permissionToolCallStart(update *acpsdk.ToolCallUpdate) *acpsdk.SessionUpdateToolCall { + toolCall := &acpsdk.SessionUpdateToolCall{ + Meta: update.Meta, + Content: update.Content, + Kind: acpsdk.ToolKindOther, + Locations: update.Locations, + RawInput: update.RawInput, + RawOutput: update.RawOutput, + Status: acpsdk.ToolCallStatusPending, + ToolCallId: update.ToolCallId, + } + + if update.Kind != nil { + toolCall.Kind = *update.Kind + } + if update.Status != nil { + toolCall.Status = *update.Status + } + if update.Title != nil { + toolCall.Title = *update.Title + } + + return toolCall +} + +func (*turnState) permissionToolCallUpdate(update *acpsdk.ToolCallUpdate) *acpsdk.SessionToolCallUpdate { + return &acpsdk.SessionToolCallUpdate{ + Meta: update.Meta, + Content: update.Content, + Kind: update.Kind, + Locations: update.Locations, + RawInput: update.RawInput, + RawOutput: update.RawOutput, + Status: update.Status, + Title: update.Title, + ToolCallId: update.ToolCallId, + } +} + func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { + turn.toolMu.Lock() + defer turn.toolMu.Unlock() turn.mu.Lock() events, err := turn.applyToolUpdate(update) turn.mu.Unlock() + if err != nil { turn.setErr(err) return err } + turn.emitToolUpdate(update.ToolCallId, events) return nil } @@ -211,28 +313,33 @@ func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (toolUpdateEvents, error) { id := string(update.ToolCallId) call, exists := turn.tools[id] + if !exists { return toolUpdateEvents{}, fmt.Errorf("acp tool call update %q arrived before tool_call", id) } metadataChanged := call.updateMetadata(update) previousOutput := call.output output := call.toolOutput(update.Content, update.Meta, update.RawOutput) + if output.text != "" { call.applyOutput(output) } streamOutput := call.output != previousOutput && (previousOutput == "" || strings.HasPrefix(call.output, previousOutput)) terminal, statusChanged, err := call.updateStatus(update.Status) + if err != nil { return toolUpdateEvents{}, err } metadataChanged = metadataChanged || statusChanged message := (*console.AgentMessageAttributes)(nil) + if terminal { message = call.message() delete(turn.tools, id) } else if metadataChanged { message = call.message() } + return toolUpdateEvents{ message: message, output: call.output, @@ -246,6 +353,7 @@ func (call *toolCall) applyOutput(output toolOutputValue) { call.appendOutput(output.text) return } + call.addOutput(output.text) } @@ -272,6 +380,7 @@ func (turn *turnState) emitAssistant(responseUsage *acpsdk.Usage) { Reasoning: &console.AgentMessageReasoningAttributes{Text: &reasoning}, } } + if responseUsage != nil { input, output, total, cached, thought := turn.normalizeUsage(responseUsage) turn.sink.Usage(usage.Record{ @@ -290,6 +399,7 @@ func (turn *turnState) emitAssistant(responseUsage *acpsdk.Usage) { } else { klog.V(log.LogLevelDebug).InfoS("ACP prompt response omitted optional usage") } + if message.Cost == nil && cost > 0 { message.Cost = &console.AgentMessageCostAttributes{Total: cost} } @@ -299,6 +409,7 @@ func (turn *turnState) emitAssistant(responseUsage *acpsdk.Usage) { } message.Message = "__plrl_ignore__" } + turn.sink.Message(message, "") } @@ -307,10 +418,12 @@ func (turn *turnState) usageUpdate(update *acpsdk.SessionUsageUpdate) { klog.V(log.LogLevelDebug).InfoS("ACP usage update omitted optional cost") return } + delta := turn.engine.costs.RecordCumulativeCost(turn.sessionID(), update.Cost.Amount) if delta <= 0 { return } + turn.sink.Usage(usage.Record{TotalCost: delta}) turn.mu.Lock() turn.cost += delta diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go index b14b4f3332..0de6bab02e 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go @@ -82,8 +82,9 @@ func TestTransportCapabilitiesAndPreCancelledTurn(t *testing.T) { } } -func TestGeminiPromptUsageReadsQuotaTokenCount(t *testing.T) { - usage := geminiPromptUsage(acpsdk.PromptResponse{Meta: map[string]any{ +func TestTransportToUsageReadsQuotaTokenCount(t *testing.T) { + transport := &Transport{} + usage := transport.toUsage(acpsdk.PromptResponse{Meta: map[string]any{ "quota": map[string]any{"token_count": map[string]any{ "input_tokens": float64(17), "output_tokens": float64(9), }}, @@ -91,19 +92,20 @@ func TestGeminiPromptUsageReadsQuotaTokenCount(t *testing.T) { if usage == nil || usage.InputTokens != 17 || usage.OutputTokens != 9 || usage.TotalTokens != 26 { t.Fatalf("usage = %#v", usage) } - if usage := geminiPromptUsage(acpsdk.PromptResponse{Meta: map[string]any{"quota": map[string]any{"token_count": map[string]any{ + if usage := transport.toUsage(acpsdk.PromptResponse{Meta: map[string]any{"quota": map[string]any{"token_count": map[string]any{ "input_tokens": float64(17.5), "output_tokens": float64(9), }}}}); usage != nil { t.Fatalf("usage = %#v, want nil", usage) } - if _, ok := geminiTokenCount(math.Ldexp(1, strconv.IntSize-1)); ok { - t.Fatal("geminiTokenCount() overflow was accepted") + if _, ok := transport.toTokenCount(math.Ldexp(1, strconv.IntSize-1)); ok { + t.Fatal("toTokenCount() overflow was accepted") } } -func TestGeminiPromptUsagePrefersStandardUsage(t *testing.T) { +func TestTransportToUsagePrefersStandardUsage(t *testing.T) { + transport := &Transport{} standard := &acpsdk.Usage{InputTokens: 8, OutputTokens: 3, TotalTokens: 11} - usage := geminiPromptUsage(acpsdk.PromptResponse{ + usage := transport.toUsage(acpsdk.PromptResponse{ Usage: standard, Meta: map[string]any{"quota": map[string]any{"token_count": map[string]any{ "input_tokens": float64(17), "output_tokens": float64(9), From 7cb13cb4fb9df9a122f1d80e1a04997481cbbf0f Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Thu, 10 Sep 2026 11:41:52 +0200 Subject: [PATCH 29/46] fix(acp): handle EOF error gracefully in file read loop - Adjusted file reading logic to correctly terminate loop on EOF without returning an error. - Improved error handling and messaging for read failures to enhance debugging clarity. --- go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go index e6abd4846e..345686dfab 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go @@ -93,11 +93,11 @@ func (client *client) skipTextFileLines(reader *bufio.Reader, line *int, path st } for current := 1; current < max(*line, 1); current++ { - if _, err := reader.ReadString('\n'); err != nil { if errors.Is(err, io.EOF) { return true, nil } + return false, fmt.Errorf("read %s: %w", path, err) } } From df64e268712d20b5de2114b2d29e48bca5481e39 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Thu, 10 Sep 2026 12:29:42 +0200 Subject: [PATCH 30/46] feat(gemini): update to v0.59.0 and adjust session handling - Bumped Gemini version to `0.59.0` across deployment configurations and Dockerfiles. - Disabled `SessionResume` in Gemini transport due to session corruption issues in v0.59.0 (`No previous sessions found`). - Added comments referencing upstream issue for future revision. - Refactored session request handling by introducing `acpRequest` to streamline session creation logic. - Removed unused session loading and history patching logic from Dockerfile. - Added new tests to validate behavior of fresh session initiation in transport logic. --- .../deployment-operator-cd-agent-harness.yaml | 4 +-- .../agent-harness/gemini.Dockerfile | 32 +------------------ .../internal/controller/agentrun_pod.go | 2 +- .../agentrun-harness/tool/gemini/transport.go | 17 ++++++---- .../tool/gemini/transport_test.go | 24 +++++++++++++- 5 files changed, 38 insertions(+), 41 deletions(-) diff --git a/.github/workflows/deployment-operator-cd-agent-harness.yaml b/.github/workflows/deployment-operator-cd-agent-harness.yaml index cf24848e56..33de6b8b43 100644 --- a/.github/workflows/deployment-operator-cd-agent-harness.yaml +++ b/.github/workflows/deployment-operator-cd-agent-harness.yaml @@ -32,7 +32,7 @@ jobs: env: NODE_VERSION: 24.11.1 CLAUDE_VERSION: 2.1.236 - GEMINI_VERSION: 0.58.0 + GEMINI_VERSION: 0.59.0 OPENCODE_VERSION: 1.18.23 CODEX_VERSION: 0.153.4 PI_VERSION: 0.84.1 @@ -181,7 +181,7 @@ jobs: - name: claude version: 2.1.236 - name: gemini - version: 0.58.0 + version: 0.59.0 - name: opencode version: 1.18.23 - name: codex diff --git a/go/deployment-operator/dockerfiles/agent-harness/gemini.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/gemini.Dockerfile index 94f9d81c31..da9c69dbf6 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/gemini.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/gemini.Dockerfile @@ -1,6 +1,6 @@ ARG NODE_IMAGE_TAG=24 ARG NODE_IMAGE=node:${NODE_IMAGE_TAG}-slim -ARG AGENT_VERSION=0.58.0 +ARG AGENT_VERSION=0.59.0 ARG AGENT_HARNESS_BASE_IMAGE_TAG=latest ARG AGENT_HARNESS_BASE_IMAGE_REPO=ghcr.io/pluralsh/agent-harness-base @@ -20,36 +20,6 @@ RUN npm install -g @google/gemini-cli@$AGENT_VERSION # Copy to a fixed, predictable path RUN cp -r $(npm root -g)/@google/gemini-cli /opt/gemini-cli -# Gemini ACP replays loaded-session history asynchronously. Make the session -# load response wait for that replay, so clients can safely begin a new turn. -# Fail the image build if the pinned upstream artifact changes this call site. -RUN node -e "\ - const fs = require('fs'); \ - const path = require('path'); \ - const root = '/opt/gemini-cli/bundle'; \ - const needle = 'session.streamHistory(sessionData.messages);'; \ - const replacement = 'await session.streamHistory(sessionData.messages);'; \ - const files = []; \ - const visit = directory => { \ - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { \ - const file = path.join(directory, entry.name); \ - if (entry.isDirectory()) visit(file); \ - else if (entry.isFile() && file.endsWith('.js')) files.push(file); \ - } \ - }; \ - visit(root); \ - const matches = files.filter(file => fs.readFileSync(file, 'utf8').includes(needle)); \ - if (matches.length !== 3) throw new Error('expected three Gemini history calls, found ' + matches.length); \ - for (const file of matches) { \ - const source = fs.readFileSync(file, 'utf8'); \ - if (source.split(needle).length - 1 !== 1) throw new Error('unexpected Gemini history call count in ' + file); \ - fs.writeFileSync(file, source.replace(needle, replacement)); \ - const patched = fs.readFileSync(file, 'utf8'); \ - if (patched.split(replacement).length - 1 !== 1 || patched.replace(replacement, '').includes(needle)) { \ - throw new Error('Gemini history patch verification failed in ' + file); \ - } \ - }" - # Resolve the actual bin entry point from package.json and save it RUN node -e "\ const pkg = require('/opt/gemini-cli/package.json'); \ diff --git a/go/deployment-operator/internal/controller/agentrun_pod.go b/go/deployment-operator/internal/controller/agentrun_pod.go index e652deb4ab..465f4b38ef 100644 --- a/go/deployment-operator/internal/controller/agentrun_pod.go +++ b/go/deployment-operator/internal/controller/agentrun_pod.go @@ -122,7 +122,7 @@ var ( // Check .github/workflows/deployment-operator-cd-agent-harness.yaml to see images being published. defaultContainerVersions = map[console.AgentRuntimeType]string{ console.AgentRuntimeTypeClaude: "%s-claude-2.1.236", - console.AgentRuntimeTypeGemini: "%s-gemini-0.58.0", + console.AgentRuntimeTypeGemini: "%s-gemini-0.59.0", console.AgentRuntimeTypeOpencode: "%s-opencode-1.18.23", console.AgentRuntimeTypeCodex: "%s-codex-0.153.4", console.AgentRuntimeTypePi: "%s-pi-0.84.1", diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go index e86709a6a3..102e34cb73 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go @@ -59,7 +59,6 @@ func NewTransport(agent *Agent) (*Transport, error) { engine := acp.NewEngine( acp.WithAuthenticationMethod(geminiAPIKeyAuthMethod), - acp.WithSessionRestorer(acp.LoadSession), acp.WithUsageResolver(result.toUsage), ) @@ -73,7 +72,10 @@ func (*Transport) Kind() toolv1.TransportKind { func (transport *Transport) Capabilities() toolv1.TransportCapabilities { return toolv1.TransportCapabilities{ - SessionResume: true, + // Gemini CLI v0.59.0 session/load can corrupt same-minute saved sessions + // and fail with "No previous sessions found". Revisit on future upgrades. + // Ref: https://github.com/google-gemini/gemini-cli/issues/28693 + SessionResume: false, ToolCallOutputStreaming: false, UsageReporting: true, FileSystemRead: true, @@ -136,14 +138,17 @@ func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest return toolv1.TurnResult{SessionID: request.SessionID}, err } - result, err := transport.engine.Turn(ctx, process, acp.Request{ + result, err := transport.engine.Turn(ctx, process, transport.acpRequest(request), sink) + return toolv1.TurnResult{SessionID: result.SessionID}, err +} + +func (transport *Transport) acpRequest(request toolv1.TurnRequest) acp.Request { + return acp.Request{ Cwd: transport.workDir, Prompt: request.Prompt, - SessionID: request.SessionID, Settings: acp.SessionSettings{ModelID: request.Settings.Model.Name}, FileSystemWrite: transport.Capabilities().FileSystemWrite, - }, sink) - return toolv1.TurnResult{SessionID: result.SessionID}, err + } } func (transport *Transport) launch(options []exec.Option, mode console.AgentRunMode, model string) (*exec.StdioProcess, error) { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go index 0de6bab02e..d7cf3d17dd 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go @@ -70,7 +70,7 @@ func TestTransportCapabilitiesAndPreCancelledTurn(t *testing.T) { if err != nil { t.Fatal(err) } - if transport.Kind() != toolv1.TransportKindACP || transport.Capabilities().ToolCallOutputStreaming || + if transport.Kind() != toolv1.TransportKindACP || transport.Capabilities().SessionResume || transport.Capabilities().ToolCallOutputStreaming || !transport.Capabilities().FileSystemWrite { t.Fatalf("transport = %#v", transport.Capabilities()) } @@ -82,6 +82,28 @@ func TestTransportCapabilitiesAndPreCancelledTurn(t *testing.T) { } } +func TestTransportACPRequestStartsFreshSession(t *testing.T) { + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: geminiTestRun(console.AgentRunModeWrite, "gemini-custom", nil)} + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatal(err) + } + + request := transport.acpRequest(toolv1.TurnRequest{ + Prompt: "follow up", + SessionID: "prior-session", + Settings: toolv1.Settings{ + Model: toolv1.ModelSelection{Name: "gemini-custom"}, + }, + }) + if request.SessionID != "" { + t.Fatalf("ACP session ID = %q, want empty", request.SessionID) + } + if request.Cwd != transport.workDir || request.Prompt != "follow up" || request.Settings.ModelID != "gemini-custom" || !request.FileSystemWrite { + t.Fatalf("ACP request = %#v", request) + } +} + func TestTransportToUsageReadsQuotaTokenCount(t *testing.T) { transport := &Transport{} usage := transport.toUsage(acpsdk.PromptResponse{Meta: map[string]any{ From 9b517805062e0bdec69bfdb95286de23047a1272 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Thu, 10 Sep 2026 13:31:15 +0200 Subject: [PATCH 31/46] feat(acp): improve tool call recovery and enhance session updates - Added support for recovering `ToolCallUpdate` events arriving before `ToolCall` starts via the `WithToolCallUpdateRecovery` option. - Introduced `recovered` state handling for tool calls, ensuring smooth reconciliation of past terminal states. - Enhanced `ToolCall` metadata and status validation with stricter checks to prevent redundant or invalid updates. - Refactored `turnState` logic to conditionally emit start, output, and terminal tool messages based on recovery scenarios. - Updated engine and transport to propagate tool call recovery logic for Gemini agent. - Added comprehensive tests for tool call recovery, late updates, and reconciliation correctness. --- .../controller/agent_messages_test.go | 45 ++++ .../agentrun-harness/tool/acp/client_test.go | 239 ++++++++++++++++++ .../pkg/agentrun-harness/tool/acp/engine.go | 1 + .../agentrun-harness/tool/acp/engine_test.go | 5 +- .../agentrun-harness/tool/acp/tool_call.go | 71 +++++- .../pkg/agentrun-harness/tool/acp/types.go | 8 + .../pkg/agentrun-harness/tool/acp/updates.go | 101 +++++++- .../agentrun-harness/tool/gemini/transport.go | 3 + 8 files changed, 448 insertions(+), 25 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/controller/agent_messages_test.go b/go/deployment-operator/pkg/agentrun-harness/controller/agent_messages_test.go index c7918c7f45..95234b1f62 100644 --- a/go/deployment-operator/pkg/agentrun-harness/controller/agent_messages_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/controller/agent_messages_test.go @@ -123,6 +123,51 @@ func TestHandleToolOutputStreamsStdout(t *testing.T) { }, "call-1") } +func TestHandleAgentMessageCorrelatesRecoveredTerminalToolCall(t *testing.T) { + t.Parallel() + + m := mocks.NewClientMock(t) + m.On("CreateAgentMessage", mock.Anything, "run-1", mock.MatchedBy(func(attrs gqlclient.AgentMessageAttributes) bool { + return attrs.Metadata != nil && attrs.Metadata.Tool != nil && attrs.Metadata.Tool.State != nil && + *attrs.Metadata.Tool.State == gqlclient.AgentMessageToolStateRunning + })).Return(&gqlclient.CreateAgentMessage_CreateAgentMessage{ID: "msg-1", Message: "Called tool"}, nil).Once() + m.On("AgentMessageOutput", mock.Anything, mock.MatchedBy(func(attrs gqlclient.AgentMessageOutputAttributes) bool { + return attrs.MessageID == "msg-1" && attrs.Stdout != nil && *attrs.Stdout == "done" + })).Return(nil).Once() + m.On("UpdateAgentMessage", mock.Anything, "msg-1", mock.MatchedBy(func(attrs gqlclient.AgentMessageAttributes) bool { + return attrs.Metadata != nil && attrs.Metadata.Tool != nil && attrs.Metadata.Tool.State != nil && + *attrs.Metadata.Tool.State == gqlclient.AgentMessageToolStateCompleted + })).Return(&gqlclient.UpdateAgentMessage_UpdateAgentMessage{ID: "msg-1", Message: "Called tool"}, nil).Once() + + in := &agentRunController{ + agentRunID: "run-1", + consoleClient: m, + toolCallMessageIDs: map[string]string{}, + output: output.New(t.Context(), m).WithSizeLimit(1024).WithFlushInterval(time.Hour), + } + callID := "call-1" + in.handleAgentMessage(t.Context(), &gqlclient.AgentMessageAttributes{ + Role: gqlclient.AiRoleAssistant, + Message: "Called tool", + Metadata: &gqlclient.AgentMessageMetadataAttributes{Tool: &gqlclient.AgentMessageToolAttributes{ + State: lo.ToPtr(gqlclient.AgentMessageToolStateRunning), + Output: lo.ToPtr(v1.RunningToolOutput), + }}, + }, callID) + in.handleToolOutput(callID, "done") + in.handleAgentMessage(t.Context(), &gqlclient.AgentMessageAttributes{ + Role: gqlclient.AiRoleAssistant, + Message: "Called tool", + Metadata: &gqlclient.AgentMessageMetadataAttributes{Tool: &gqlclient.AgentMessageToolAttributes{ + State: lo.ToPtr(gqlclient.AgentMessageToolStateCompleted), + Output: lo.ToPtr("done"), + }}, + }, callID) + + _, tracked := in.toolCallMessageID(callID) + require.False(t, tracked) +} + func TestHandleAgentMessageKeepsOutputOpenWhenTerminalUpdateFails(t *testing.T) { t.Parallel() diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go index efe9cd42d2..503f3b5250 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -100,6 +100,245 @@ func TestClientRequestPermissionStartsToolCallBeforeDenying(t *testing.T) { } } +func TestClientRejectsToolCallUpdateBeforeToolCallByDefault(t *testing.T) { + acpClient := &client{turn: newTurn(NewEngine(), &testSink{}, "session-1")} + completed := acpsdk.ToolCallStatusCompleted + err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", + Status: &completed, + }}, + }) + if err == nil || err.Error() != `acp tool call update "call-1" arrived before tool_call` { + t.Fatalf("tool call update error = %v", err) + } +} + +func TestClientRejectsDuplicateToolCallStarts(t *testing.T) { + acpClient := &client{turn: newTurn(NewEngine(), &testSink{}, "session-1")} + update := acpsdk.SessionUpdateToolCall{ToolCallId: "call-1", Status: acpsdk.ToolCallStatusInProgress} + request := acpsdk.SessionNotification{SessionId: "session-1", Update: acpsdk.SessionUpdate{ToolCall: &update}} + if err := acpClient.SessionUpdate(context.Background(), request); err != nil { + t.Fatalf("start tool call: %v", err) + } + err := acpClient.SessionUpdate(context.Background(), request) + if err == nil || err.Error() != `acp tool call "call-1" was started twice` { + t.Fatalf("duplicate tool call error = %v", err) + } +} + +func TestClientRecoversToolCallUpdateBeforeToolCall(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), sink, "session-1")} + title := "Create pull request" + kind := acpsdk.ToolKindOther + completed := acpsdk.ToolCallStatusCompleted + + err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "mcp_plural_agentPullRequest__call_1028406", + Title: &title, + Kind: &kind, + RawInput: map[string]any{"title": "docs: update README"}, + RawOutput: map[string]any{"formatted_output": "https://github.com/pluralsh/console/pull/1"}, + Status: &completed, + }}, + }) + if err != nil { + t.Fatalf("tool call update before tool call: %v", err) + } + if len(sink.messages) != 2 { + t.Fatalf("tool call messages = %d, want 2", len(sink.messages)) + } + start := sink.messages[0].Metadata.Tool + if start.Name == nil || *start.Name != title || start.State == nil || *start.State != console.AgentMessageToolStateRunning || start.Output == nil || *start.Output != runningToolOutput { + t.Fatalf("recovered tool start = %#v", start) + } + terminal := sink.messages[1].Metadata.Tool + if terminal.Input == nil || *terminal.Input != `{"title":"docs: update README"}` || terminal.Output == nil || *terminal.Output != "https://github.com/pluralsh/console/pull/1" || terminal.State == nil || *terminal.State != console.AgentMessageToolStateCompleted { + t.Fatalf("recovered terminal tool = %#v", terminal) + } + if len(sink.events) != 3 || sink.events[0] != "message:mcp_plural_agentPullRequest__call_1028406:Called tool" || sink.events[1] != "output:mcp_plural_agentPullRequest__call_1028406:https://github.com/pluralsh/console/pull/1" || sink.events[2] != "message:mcp_plural_agentPullRequest__call_1028406:Called tool" { + t.Fatalf("recovered tool event order = %v", sink.events) + } + + pending := acpsdk.ToolCallStatusPending + err = acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ + ToolCallId: "mcp_plural_agentPullRequest__call_1028406", + Title: "Create pull request", + Kind: kind, + Status: pending, + }}, + }) + if err != nil { + t.Fatalf("late tool call: %v", err) + } + if len(sink.messages) != 2 || len(sink.outputs) != 1 { + t.Fatalf("tool call events after reconciliation = %v / %v", sink.messages, sink.outputs) + } +} + +func TestClientRejectsEmptyToolCallUpdateIDWithRecovery(t *testing.T) { + acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), &testSink{}, "session-1")} + err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{}}, + }) + if err == nil || err.Error() != "acp tool call update has an empty id" { + t.Fatalf("empty tool call update error = %v", err) + } +} + +func TestClientRecoversUnspecifiedToolCallStatusAsRunning(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), sink, "session-1")} + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", + }}, + }); err != nil { + t.Fatalf("recover tool call update: %v", err) + } + pending := acpsdk.ToolCallStatusPending + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ + ToolCallId: "call-1", + Status: pending, + }}, + }); err != nil { + t.Fatalf("reconcile tool call: %v", err) + } + if len(sink.messages) != 1 { + t.Fatalf("tool call messages = %d, want 1", len(sink.messages)) + } + if state := sink.messages[0].Metadata.Tool.State; state == nil || *state != console.AgentMessageToolStateRunning { + t.Fatalf("reconciled tool state = %v, want running", state) + } +} + +func TestClientPreservesRecoveredTerminalStatusAcrossLaterToolCallUpdates(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), sink, "session-1")} + completed := acpsdk.ToolCallStatusCompleted + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", + Status: &completed, + }}, + }); err != nil { + t.Fatalf("recover completed tool call update: %v", err) + } + inProgress := acpsdk.ToolCallStatusInProgress + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", + Status: &inProgress, + }}, + }); err != nil { + t.Fatalf("late in-progress tool call update: %v", err) + } + pending := acpsdk.ToolCallStatusPending + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ + ToolCallId: "call-1", + Status: pending, + }}, + }); err != nil { + t.Fatalf("late tool call: %v", err) + } + if len(sink.messages) != 2 { + t.Fatalf("tool call messages = %d, want 2", len(sink.messages)) + } + if state := sink.messages[1].Metadata.Tool.State; state == nil || *state != console.AgentMessageToolStateCompleted { + t.Fatalf("reconciled tool state = %v, want completed", state) + } +} + +func TestClientValidatesStatusesAfterRecoveredTerminalToolCall(t *testing.T) { + unknown := acpsdk.ToolCallStatus("unknown") + for _, test := range []struct { + name string + update acpsdk.SessionUpdate + }{ + { + name: "late update", + update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", + Status: &unknown, + }}, + }, + { + name: "late start", + update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ + ToolCallId: "call-1", + Status: unknown, + }}, + }, + } { + t.Run(test.name, func(t *testing.T) { + acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), &testSink{}, "session-1")} + completed := acpsdk.ToolCallStatusCompleted + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", + Status: &completed, + }}, + }); err != nil { + t.Fatalf("recover completed tool call update: %v", err) + } + err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{SessionId: "session-1", Update: test.update}) + if err == nil || err.Error() != `acp tool call has unknown status "unknown"` { + t.Fatalf("late status error = %v", err) + } + }) + } +} + +func TestClientReconcilesPermissionToolCallAfterRecovery(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), sink, "session-1")} + completed := acpsdk.ToolCallStatusCompleted + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", + Status: &completed, + }}, + }); err != nil { + t.Fatalf("recover tool call update: %v", err) + } + title := "Create pull request" + kind := acpsdk.ToolKindOther + _, err := acpClient.RequestPermission(context.Background(), acpsdk.RequestPermissionRequest{ + SessionId: "session-1", + ToolCall: acpsdk.ToolCallUpdate{ + ToolCallId: "call-1", + Title: &title, + Kind: &kind, + RawInput: map[string]any{"title": "docs: update README"}, + }, + }) + if err == nil || err.Error() != "acp permission requests are unavailable in unattended runs" { + t.Fatalf("permission error = %v, want unattended permission denial", err) + } + if len(sink.messages) != 2 { + t.Fatalf("tool call messages = %d, want 2", len(sink.messages)) + } + if state := sink.messages[1].Metadata.Tool.State; state == nil || *state != console.AgentMessageToolStateCompleted { + t.Fatalf("recovered terminal state = %v, want completed", state) + } +} + func TestClientRequestPermissionUpdatesExistingToolCallBeforeDenying(t *testing.T) { sink := &testSink{} acpClient := &client{turn: newTurn(NewEngine(), sink, "session-1")} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go index 35a98e7f54..2175394fa7 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go @@ -28,6 +28,7 @@ type Engine struct { restoreSession SessionRestorer authenticationMethod string usageResolver UsageResolver + recoverToolUpdates bool } func (engine *Engine) setSessionConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption, settings SessionSettings) error { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go index d0d5edb8af..45f684b0a2 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -335,7 +335,7 @@ func TestNewEngineOptionsPreserveDefaultsAndApplyOverrides(t *testing.T) { standard := &acpsdk.Usage{InputTokens: 3} defaults := NewEngine(WithStopTimeout(0), WithSessionRestorer(nil), WithUsageResolver(nil)) if defaults.stopTimeout != defaultStopTimeout || defaults.restoreSession == nil || - defaults.authenticationMethod != "" || defaults.usageResolver(acpsdk.PromptResponse{Usage: standard}) != standard { + defaults.authenticationMethod != "" || defaults.usageResolver(acpsdk.PromptResponse{Usage: standard}) != standard || defaults.recoverToolUpdates { t.Fatalf("default engine = %#v", defaults) } @@ -345,9 +345,10 @@ func TestNewEngineOptionsPreserveDefaultsAndApplyOverrides(t *testing.T) { WithSessionRestorer(LoadSession), WithAuthenticationMethod("api-key"), WithUsageResolver(resolver), + WithToolCallUpdateRecovery(), ) if configured.stopTimeout != time.Second || configured.restoreSession == nil || - configured.authenticationMethod != "api-key" || configured.usageResolver(acpsdk.PromptResponse{}).InputTokens != 5 { + configured.authenticationMethod != "api-key" || configured.usageResolver(acpsdk.PromptResponse{}).InputTokens != 5 || !configured.recoverToolUpdates { t.Fatalf("configured engine = %#v", configured) } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go index d3cac6d269..26340356cf 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go @@ -13,13 +13,14 @@ import ( const runningToolOutput = "running..." type toolCall struct { - id string - name string - title string - kind acpsdk.ToolKind - input string - output string - state console.AgentMessageToolState + id string + name string + title string + kind acpsdk.ToolKind + input string + output string + state console.AgentMessageToolState + recovered bool } type toolOutputValue struct { @@ -115,6 +116,7 @@ func formattedRawOutput(rawOutput any) (string, bool) { } type toolUpdateEvents struct { + startMessage *console.AgentMessageAttributes message *console.AgentMessageAttributes output string streamOutput bool @@ -184,8 +186,25 @@ func (call *toolCall) updateMetadata(update *acpsdk.SessionToolCallUpdate) bool } func (call *toolCall) updateStatus(status *acpsdk.ToolCallStatus) (bool, bool, error) { + state, terminal, err := call.status(status) + if err != nil || status == nil { + return terminal, false, err + } + changed := call.state != state + if changed { + call.state = state + } + return terminal, changed, nil +} + +func (call *toolCall) validateStatus(status *acpsdk.ToolCallStatus) error { + _, _, err := call.status(status) + return err +} + +func (*toolCall) status(status *acpsdk.ToolCallStatus) (console.AgentMessageToolState, bool, error) { if status == nil { - return false, false, nil + return "", false, nil } var state console.AgentMessageToolState switch *status { @@ -198,12 +217,38 @@ func (call *toolCall) updateStatus(status *acpsdk.ToolCallStatus) (bool, bool, e case acpsdk.ToolCallStatusFailed: state = console.AgentMessageToolStateError default: - return false, false, fmt.Errorf("acp tool call has unknown status %q", *status) + return "", false, fmt.Errorf("acp tool call has unknown status %q", *status) } terminal := state == console.AgentMessageToolStateCompleted || state == console.AgentMessageToolStateError - changed := call.state != state - if changed { - call.state = state + return state, terminal, nil +} + +func (call *toolCall) reconcileStart(update *acpsdk.SessionUpdateToolCall) error { + if err := call.validateStatus(&update.Status); err != nil { + return err } - return terminal, changed, nil + if call.title == "" { + call.title = update.Title + } + if call.kind == "" { + call.kind = update.Kind + } + call.name = call.displayName() + if call.input == "" && update.RawInput != nil { + call.input = call.formatValue(update.RawInput) + } + if call.output == "" { + call.applyOutput(call.toolOutput(update.Content, update.Meta, update.RawOutput)) + } + if !call.isTerminal() && update.Status != acpsdk.ToolCallStatusPending { + if _, _, err := call.updateStatus(&update.Status); err != nil { + return err + } + } + call.recovered = false + return nil +} + +func (call *toolCall) isTerminal() bool { + return call.state == console.AgentMessageToolStateCompleted || call.state == console.AgentMessageToolStateError } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go index d12c82be8f..68f3d66d75 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go @@ -127,3 +127,11 @@ func WithUsageResolver(resolver UsageResolver) Option { } } } + +// WithToolCallUpdateRecovery accepts tool call updates that arrive before +// their corresponding tool call. Providers with ordered updates do not need it. +func WithToolCallUpdateRecovery() Option { + return func(engine *Engine) { + engine.recoverToolUpdates = true + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go index 69d5e9f9d3..7066258db8 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go @@ -192,7 +192,9 @@ func (turn *turnState) startTool(update *acpsdk.SessionUpdateToolCall) error { return turn.fail(err.Error()) } - turn.sink.Message(message, string(update.ToolCallId)) + if message != nil { + turn.sink.Message(message, string(update.ToolCallId)) + } if output != "" { turn.sink.ToolCallOutput(string(update.ToolCallId), output) } @@ -202,7 +204,16 @@ func (turn *turnState) startTool(update *acpsdk.SessionUpdateToolCall) error { func (turn *turnState) startToolLocked(update *acpsdk.SessionUpdateToolCall) (*console.AgentMessageAttributes, string, error) { id := string(update.ToolCallId) - if _, exists := turn.tools[id]; exists { + if call, exists := turn.tools[id]; exists { + if call.recovered { + if err := call.reconcileStart(update); err != nil { + return nil, "", err + } + if call.isTerminal() { + delete(turn.tools, id) + } + return nil, "", nil + } return nil, "", fmt.Errorf("acp tool call %q was started twice", id) } @@ -228,7 +239,24 @@ func (turn *turnState) upsertPermissionTool(update *acpsdk.ToolCallUpdate) error defer turn.toolMu.Unlock() turn.mu.Lock() - if _, exists := turn.tools[string(update.ToolCallId)]; exists { + if call, exists := turn.tools[string(update.ToolCallId)]; exists { + if call.recovered { + message, output, err := turn.startToolLocked(turn.permissionToolCallStart(update)) + turn.mu.Unlock() + + if err != nil { + turn.setErr(err) + return err + } + + if message != nil { + turn.sink.Message(message, string(update.ToolCallId)) + } + if output != "" { + turn.sink.ToolCallOutput(string(update.ToolCallId), output) + } + return nil + } events, err := turn.applyToolUpdate(turn.permissionToolCallUpdate(update)) turn.mu.Unlock() @@ -247,7 +275,9 @@ func (turn *turnState) upsertPermissionTool(update *acpsdk.ToolCallUpdate) error return turn.fail(err.Error()) } - turn.sink.Message(message, string(update.ToolCallId)) + if message != nil { + turn.sink.Message(message, string(update.ToolCallId)) + } if output != "" { turn.sink.ToolCallOutput(string(update.ToolCallId), output) } @@ -295,6 +325,10 @@ func (*turnState) permissionToolCallUpdate(update *acpsdk.ToolCallUpdate) *acpsd } func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { + if update.ToolCallId == "" { + return turn.fail("acp tool call update has an empty id") + } + turn.toolMu.Lock() defer turn.toolMu.Unlock() turn.mu.Lock() @@ -315,6 +349,9 @@ func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (to call, exists := turn.tools[id] if !exists { + if turn.engine != nil && turn.engine.recoverToolUpdates { + return turn.recoverToolUpdate(update) + } return toolUpdateEvents{}, fmt.Errorf("acp tool call update %q arrived before tool_call", id) } metadataChanged := call.updateMetadata(update) @@ -325,17 +362,33 @@ func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (to call.applyOutput(output) } streamOutput := call.output != previousOutput && (previousOutput == "" || strings.HasPrefix(call.output, previousOutput)) - terminal, statusChanged, err := call.updateStatus(update.Status) - - if err != nil { - return toolUpdateEvents{}, err + wasTerminal := call.isTerminal() + terminal := wasTerminal + statusChanged := false + if wasTerminal { + if err := call.validateStatus(update.Status); err != nil { + return toolUpdateEvents{}, err + } + } else { + var err error + terminal, statusChanged, err = call.updateStatus(update.Status) + if err != nil { + return toolUpdateEvents{}, err + } + } + if wasTerminal && call.recovered { + return toolUpdateEvents{}, nil } metadataChanged = metadataChanged || statusChanged message := (*console.AgentMessageAttributes)(nil) if terminal { - message = call.message() - delete(turn.tools, id) + if !wasTerminal || metadataChanged { + message = call.message() + } + if !call.recovered { + delete(turn.tools, id) + } } else if metadataChanged { message = call.message() } @@ -348,6 +401,30 @@ func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (to }, nil } +func (turn *turnState) recoverToolUpdate(update *acpsdk.SessionToolCallUpdate) (toolUpdateEvents, error) { + call := &toolCall{id: string(update.ToolCallId), state: console.AgentMessageToolStateRunning, recovered: true} + call.updateMetadata(update) + startMessage := call.message() + call.applyOutput(call.toolOutput(update.Content, update.Meta, update.RawOutput)) + terminal, _, err := call.updateStatus(update.Status) + if err != nil { + return toolUpdateEvents{}, err + } + turn.tools[call.id] = call + message := (*console.AgentMessageAttributes)(nil) + if terminal { + message = call.message() + } + + return toolUpdateEvents{ + startMessage: startMessage, + message: message, + output: call.output, + streamOutput: call.output != "", + terminal: terminal, + }, nil +} + func (call *toolCall) applyOutput(output toolOutputValue) { if output.delta { call.appendOutput(output.text) @@ -358,6 +435,10 @@ func (call *toolCall) applyOutput(output toolOutputValue) { } func (turn *turnState) emitToolUpdate(id acpsdk.ToolCallId, events toolUpdateEvents) { + if events.startMessage != nil { + turn.sink.Message(events.startMessage, string(id)) + } + if events.streamOutput { turn.sink.ToolCallOutput(string(id), events.output) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go index 102e34cb73..d2439ca85c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go @@ -60,6 +60,9 @@ func NewTransport(agent *Agent) (*Transport, error) { engine := acp.NewEngine( acp.WithAuthenticationMethod(geminiAPIKeyAuthMethod), acp.WithUsageResolver(result.toUsage), + // Gemini CLI v0.59.0 can emit a tool_call_update before its + // corresponding start event. Revisit on future upgrades. + acp.WithToolCallUpdateRecovery(), ) result.engine = engine From 7c7a9d6e1396a049fa2cf88ca62e50c8c49728ac Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Thu, 10 Sep 2026 14:17:52 +0200 Subject: [PATCH 32/46] feat(acp, gemini): enhance tool call input handling and Gemini compatibility - Introduced `WithToolCallStartContentAsInputWithoutRawInput` option for treating start content as tool input when `RawInput` is unavailable. - Updated `toolCall` logic to support conditional input mapping from content. - Enhanced `turnState` to utilize `startContentIsInputWithoutRawInput` for flexible tool updates. - Added Gemini compatibility instructions to system prompts, detailing command substitution restrictions. - Updated tests to validate tool call input-output behaviors with Gemini-specific configurations. --- .../agentrun-harness/tool/acp/client_test.go | 274 +++++++++++++++++- .../pkg/agentrun-harness/tool/acp/engine.go | 6 +- .../agentrun-harness/tool/acp/engine_test.go | 5 +- .../agentrun-harness/tool/acp/tool_call.go | 47 ++- .../tool/acp/tool_call_test.go | 2 +- .../pkg/agentrun-harness/tool/acp/types.go | 8 + .../pkg/agentrun-harness/tool/acp/updates.go | 49 +++- .../pkg/agentrun-harness/tool/gemini/agent.go | 37 ++- .../tool/gemini/agent_test.go | 6 + .../agentrun-harness/tool/gemini/transport.go | 3 + 10 files changed, 411 insertions(+), 26 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go index 503f3b5250..9b8121414c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -128,6 +128,107 @@ func TestClientRejectsDuplicateToolCallStarts(t *testing.T) { } } +func TestClientMapsStartContentToOutputByDefault(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(), sink, "session-1")} + inProgress := acpsdk.ToolCallStatusInProgress + err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ + ToolCallId: "call-1", Status: inProgress, + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock(`{"command":"git status"}`))}, + }}, + }) + if err != nil { + t.Fatalf("start tool call: %v", err) + } + if len(sink.messages) != 1 || sink.messages[0].Metadata.Tool.Input != nil || *sink.messages[0].Metadata.Tool.Output != `{"command":"git status"}` { + t.Fatalf("default tool mapping = %#v", sink.messages) + } + if len(sink.outputs) != 1 || sink.outputs[0] != `call-1:{"command":"git status"}` { + t.Fatalf("default output events = %v", sink.outputs) + } +} + +func TestClientMapsGeminiStartContentToInput(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} + inProgress := acpsdk.ToolCallStatusInProgress + completed := acpsdk.ToolCallStatusCompleted + start := acpsdk.SessionNotification{SessionId: "session-1", Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ + ToolCallId: "call-1", Status: inProgress, + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock(`{"command":"git status"}`))}, + }}} + if err := acpClient.SessionUpdate(context.Background(), start); err != nil { + t.Fatalf("start tool call: %v", err) + } + if len(sink.messages) != 1 || sink.messages[0].Metadata.Tool.Input == nil || *sink.messages[0].Metadata.Tool.Input != `{"command":"git status"}` || *sink.messages[0].Metadata.Tool.Output != runningToolOutput { + t.Fatalf("Gemini start mapping = %#v", sink.messages) + } + if len(sink.outputs) != 0 { + t.Fatalf("Gemini start output events = %v", sink.outputs) + } + completion := acpsdk.SessionNotification{SessionId: "session-1", Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", Status: &completed, + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("clean"))}, + }}} + if err := acpClient.SessionUpdate(context.Background(), completion); err != nil { + t.Fatalf("complete tool call: %v", err) + } + if len(sink.outputs) != 1 || sink.outputs[0] != "call-1:clean" { + t.Fatalf("Gemini completion output events = %v", sink.outputs) + } +} + +func TestClientKeepsStartContentAsOutputWhenRawInputExists(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} + inProgress := acpsdk.ToolCallStatusInProgress + err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ + ToolCallId: "call-1", Status: inProgress, RawInput: map[string]any{"command": "git status"}, + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("displayed output"))}, + }}, + }) + if err != nil { + t.Fatalf("start tool call: %v", err) + } + tool := sink.messages[0].Metadata.Tool + if tool.Input == nil || *tool.Input != `{"command":"git status"}` || tool.Output == nil || *tool.Output != "displayed output" { + t.Fatalf("raw input tool mapping = %#v", tool) + } + if len(sink.outputs) != 1 || sink.outputs[0] != "call-1:displayed output" { + t.Fatalf("raw input output events = %v", sink.outputs) + } +} + +func TestClientKeepsTerminalGeminiStartContentAsOutput(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} + completed := acpsdk.ToolCallStatusCompleted + err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ + ToolCallId: "call-1", Status: completed, + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("actual output"))}, + }}, + }) + if err != nil { + t.Fatalf("start terminal tool call: %v", err) + } + tool := sink.messages[0].Metadata.Tool + if tool.Input != nil || tool.Output == nil || *tool.Output != "actual output" { + t.Fatalf("terminal Gemini start mapping = %#v", tool) + } + if len(sink.outputs) != 0 { + t.Fatalf("terminal Gemini start output events = %v", sink.outputs) + } + if _, exists := acpClient.turn.tools["call-1"]; exists { + t.Fatal("terminal Gemini start remained active") + } +} + func TestClientRecoversToolCallUpdateBeforeToolCall(t *testing.T) { sink := &testSink{} acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), sink, "session-1")} @@ -182,6 +283,119 @@ func TestClientRecoversToolCallUpdateBeforeToolCall(t *testing.T) { } } +func TestClientMapsRecoveredGeminiNonterminalContentToInput(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery(), WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} + inProgress := acpsdk.ToolCallStatusInProgress + err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", Status: &inProgress, + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("Run shell command."))}, + }}, + }) + if err != nil { + t.Fatalf("recover tool call update: %v", err) + } + if len(sink.messages) != 1 || sink.messages[0].Metadata.Tool.Input == nil || *sink.messages[0].Metadata.Tool.Input != "Run shell command." || *sink.messages[0].Metadata.Tool.Output != runningToolOutput { + t.Fatalf("recovered Gemini start mapping = %#v", sink.messages) + } + if len(sink.outputs) != 0 { + t.Fatalf("recovered Gemini output events = %v", sink.outputs) + } +} + +func TestClientReconcilesGeminiRawInputWithoutEmittingDelayedStartContent(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery(), WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} + inProgress := acpsdk.ToolCallStatusInProgress + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", Status: &inProgress, + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("inferred input"))}, + }}, + }); err != nil { + t.Fatalf("recover tool call update: %v", err) + } + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ + ToolCallId: "call-1", Status: inProgress, RawInput: map[string]any{"command": "git status"}, + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("delayed explanation"))}, + }}, + }); err != nil { + t.Fatalf("reconcile tool call: %v", err) + } + if len(sink.messages) != 2 { + t.Fatalf("reconciled Gemini messages = %d, want 2", len(sink.messages)) + } + tool := sink.messages[1].Metadata.Tool + if tool.Input == nil || *tool.Input != `{"command":"git status"}` || tool.Output == nil || *tool.Output != runningToolOutput || tool.State == nil || *tool.State != console.AgentMessageToolStateRunning { + t.Fatalf("reconciled Gemini message = %#v", tool) + } + if len(sink.outputs) != 0 { + t.Fatalf("reconciled Gemini output events = %v", sink.outputs) + } +} + +func TestClientReconcilesTerminalGeminiStartAsMetadataOutput(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery(), WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} + inProgress := acpsdk.ToolCallStatusInProgress + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", Status: &inProgress, + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("inferred input"))}, + }}, + }); err != nil { + t.Fatalf("recover tool call update: %v", err) + } + completed := acpsdk.ToolCallStatusCompleted + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ + ToolCallId: "call-1", Status: completed, + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("actual output"))}, + }}, + }); err != nil { + t.Fatalf("reconcile terminal tool call: %v", err) + } + if len(sink.messages) != 2 { + t.Fatalf("reconciled terminal Gemini messages = %d, want 2", len(sink.messages)) + } + tool := sink.messages[1].Metadata.Tool + if tool.Output == nil || *tool.Output != "actual output" || tool.State == nil || *tool.State != console.AgentMessageToolStateCompleted { + t.Fatalf("reconciled terminal Gemini message = %#v", tool) + } + if len(sink.outputs) != 0 { + t.Fatalf("reconciled terminal Gemini output events = %v", sink.outputs) + } +} + +func TestClientKeepsRecoveredGeminiTerminalContentAsOutput(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery(), WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} + completed := acpsdk.ToolCallStatusCompleted + err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ + ToolCallId: "call-1", Status: &completed, + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("actual output"))}, + }}, + }) + if err != nil { + t.Fatalf("recover terminal tool call update: %v", err) + } + if len(sink.messages) != 2 || sink.messages[0].Metadata.Tool.Input != nil || *sink.messages[1].Metadata.Tool.Output != "actual output" { + t.Fatalf("recovered terminal Gemini mapping = %#v", sink.messages) + } + if len(sink.outputs) != 1 || sink.outputs[0] != "call-1:actual output" { + t.Fatalf("recovered terminal output events = %v", sink.outputs) + } +} + func TestClientRejectsEmptyToolCallUpdateIDWithRecovery(t *testing.T) { acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), &testSink{}, "session-1")} err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ @@ -214,10 +428,10 @@ func TestClientRecoversUnspecifiedToolCallStatusAsRunning(t *testing.T) { }); err != nil { t.Fatalf("reconcile tool call: %v", err) } - if len(sink.messages) != 1 { - t.Fatalf("tool call messages = %d, want 1", len(sink.messages)) + if len(sink.messages) != 2 { + t.Fatalf("tool call messages = %d, want 2", len(sink.messages)) } - if state := sink.messages[0].Metadata.Tool.State; state == nil || *state != console.AgentMessageToolStateRunning { + if state := sink.messages[1].Metadata.Tool.State; state == nil || *state != console.AgentMessageToolStateRunning { t.Fatalf("reconciled tool state = %v, want running", state) } } @@ -379,6 +593,60 @@ func TestClientRequestPermissionUpdatesExistingToolCallBeforeDenying(t *testing. } } +func TestClientMapsGeminiPermissionContentToInputForExistingToolCall(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} + inProgress := acpsdk.ToolCallStatusInProgress + if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ToolCallId: "call-1", Status: inProgress}}, + }); err != nil { + t.Fatalf("start tool call: %v", err) + } + + _, err := acpClient.RequestPermission(context.Background(), acpsdk.RequestPermissionRequest{ + SessionId: "session-1", + ToolCall: acpsdk.ToolCallUpdate{ + ToolCallId: "call-1", + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("Run shell command."))}, + }, + }) + if err == nil || err.Error() != "acp permission requests are unavailable in unattended runs" { + t.Fatalf("permission error = %v, want unattended permission denial", err) + } + if len(sink.messages) != 2 { + t.Fatalf("tool call messages = %d, want 2", len(sink.messages)) + } + tool := sink.messages[1].Metadata.Tool + if tool.Input == nil || *tool.Input != "Run shell command." || tool.Output == nil || *tool.Output != runningToolOutput { + t.Fatalf("Gemini permission mapping = %#v", tool) + } + if len(sink.outputs) != 0 { + t.Fatalf("Gemini permission output events = %v", sink.outputs) + } + + _, err = acpClient.RequestPermission(context.Background(), acpsdk.RequestPermissionRequest{ + SessionId: "session-1", + ToolCall: acpsdk.ToolCallUpdate{ + ToolCallId: "call-1", RawInput: map[string]any{"command": "git status"}, + Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("displayed output"))}, + }, + }) + if err == nil || err.Error() != "acp permission requests are unavailable in unattended runs" { + t.Fatalf("permission error with raw input = %v, want unattended permission denial", err) + } + if len(sink.messages) != 3 { + t.Fatalf("tool call messages with raw input = %d, want 3", len(sink.messages)) + } + tool = sink.messages[2].Metadata.Tool + if tool.Input == nil || *tool.Input != `{"command":"git status"}` || tool.Output == nil || *tool.Output != "displayed output" { + t.Fatalf("Gemini permission raw input mapping = %#v", tool) + } + if len(sink.outputs) != 1 || sink.outputs[0] != "call-1:displayed output" { + t.Fatalf("Gemini permission raw input output events = %v", sink.outputs) + } +} + func TestClientRequestPermissionReturnsSessionAndToolCallErrors(t *testing.T) { acpClient := &client{turn: newTurn(NewEngine(), &testSink{}, "session-1")} for _, test := range []struct { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go index 2175394fa7..8e21127fe4 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go @@ -28,7 +28,11 @@ type Engine struct { restoreSession SessionRestorer authenticationMethod string usageResolver UsageResolver - recoverToolUpdates bool + + // These are workarounds for gemini ACP issues. + // Revisit these when gemini ACP issues are resolved. + recoverToolUpdates bool + startContentIsInputWithoutRawInput bool } func (engine *Engine) setSessionConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption, settings SessionSettings) error { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go index 45f684b0a2..43b5e22adb 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -335,7 +335,7 @@ func TestNewEngineOptionsPreserveDefaultsAndApplyOverrides(t *testing.T) { standard := &acpsdk.Usage{InputTokens: 3} defaults := NewEngine(WithStopTimeout(0), WithSessionRestorer(nil), WithUsageResolver(nil)) if defaults.stopTimeout != defaultStopTimeout || defaults.restoreSession == nil || - defaults.authenticationMethod != "" || defaults.usageResolver(acpsdk.PromptResponse{Usage: standard}) != standard || defaults.recoverToolUpdates { + defaults.authenticationMethod != "" || defaults.usageResolver(acpsdk.PromptResponse{Usage: standard}) != standard || defaults.recoverToolUpdates || defaults.startContentIsInputWithoutRawInput { t.Fatalf("default engine = %#v", defaults) } @@ -346,9 +346,10 @@ func TestNewEngineOptionsPreserveDefaultsAndApplyOverrides(t *testing.T) { WithAuthenticationMethod("api-key"), WithUsageResolver(resolver), WithToolCallUpdateRecovery(), + WithToolCallStartContentAsInputWithoutRawInput(), ) if configured.stopTimeout != time.Second || configured.restoreSession == nil || - configured.authenticationMethod != "api-key" || configured.usageResolver(acpsdk.PromptResponse{}).InputTokens != 5 || !configured.recoverToolUpdates { + configured.authenticationMethod != "api-key" || configured.usageResolver(acpsdk.PromptResponse{}).InputTokens != 5 || !configured.recoverToolUpdates || !configured.startContentIsInputWithoutRawInput { t.Fatalf("configured engine = %#v", configured) } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go index 26340356cf..b38556f2af 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go @@ -86,6 +86,31 @@ func (call *toolCall) toolOutput(content []acpsdk.ToolCallContent, meta map[stri return toolOutputValue{text: output} } +func (call *toolCall) setStartInput(content []acpsdk.ToolCallContent, rawInput any, startContentIsInputWithoutRawInput bool) { + call.input = call.formatValue(rawInput) + if rawInput == nil && startContentIsInputWithoutRawInput { + call.setContentInput(content) + } +} + +func (call *toolCall) setContentInput(content []acpsdk.ToolCallContent) bool { + if input := call.contentOutput(content); input != "" { + if call.input == input { + return false + } + call.input = input + return true + } + return false +} + +func (call *toolCall) startOutput(content []acpsdk.ToolCallContent, meta map[string]any, rawInput, rawOutput any, startContentIsInputWithoutRawInput bool) toolOutputValue { + if rawInput == nil && startContentIsInputWithoutRawInput { + return call.toolOutput(nil, meta, rawOutput) + } + return call.toolOutput(content, meta, rawOutput) +} + func terminalOutput(meta map[string]any) (string, bool, bool) { for _, candidate := range []struct { name string @@ -223,9 +248,9 @@ func (*toolCall) status(status *acpsdk.ToolCallStatus) (console.AgentMessageTool return state, terminal, nil } -func (call *toolCall) reconcileStart(update *acpsdk.SessionUpdateToolCall) error { +func (call *toolCall) reconcileStart(update *acpsdk.SessionUpdateToolCall, startContentIsInputWithoutRawInput bool) (*console.AgentMessageAttributes, error) { if err := call.validateStatus(&update.Status); err != nil { - return err + return nil, err } if call.title == "" { call.title = update.Title @@ -234,19 +259,29 @@ func (call *toolCall) reconcileStart(update *acpsdk.SessionUpdateToolCall) error call.kind = update.Kind } call.name = call.displayName() - if call.input == "" && update.RawInput != nil { + _, terminal, err := call.status(&update.Status) + if err != nil { + return nil, err + } + if update.RawInput != nil { call.input = call.formatValue(update.RawInput) + } else if call.input == "" { + call.setStartInput(update.Content, nil, startContentIsInputWithoutRawInput && !terminal) } if call.output == "" { - call.applyOutput(call.toolOutput(update.Content, update.Meta, update.RawOutput)) + if startContentIsInputWithoutRawInput && !terminal { + call.applyOutput(call.toolOutput(nil, update.Meta, update.RawOutput)) + } else { + call.applyOutput(call.startOutput(update.Content, update.Meta, update.RawInput, update.RawOutput, false)) + } } if !call.isTerminal() && update.Status != acpsdk.ToolCallStatusPending { if _, _, err := call.updateStatus(&update.Status); err != nil { - return err + return nil, err } } call.recovered = false - return nil + return call.message(), nil } func (call *toolCall) isTerminal() bool { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go index 25940b08c6..53e1d7fdbc 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go @@ -61,7 +61,7 @@ func TestToolCallMapsAdapterTerminalOutput(t *testing.T) { for _, update := range updates { events, err := turn.applyToolUpdate(&acpsdk.SessionToolCallUpdate{ ToolCallId: "call-1", Meta: update.meta, RawOutput: update.rawOutput, Status: update.status, - }) + }, false) if err != nil { t.Fatalf("applyToolUpdate() error = %v", err) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go index 68f3d66d75..d92eaef67d 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go @@ -135,3 +135,11 @@ func WithToolCallUpdateRecovery() Option { engine.recoverToolUpdates = true } } + +// WithToolCallStartContentAsInputWithoutRawInput treats textual start content +// as tool input when a provider omits RawInput. +func WithToolCallStartContentAsInputWithoutRawInput() Option { + return func(engine *Engine) { + engine.startContentIsInputWithoutRawInput = true + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go index 7066258db8..5998729871 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go @@ -206,26 +206,35 @@ func (turn *turnState) startToolLocked(update *acpsdk.SessionUpdateToolCall) (*c id := string(update.ToolCallId) if call, exists := turn.tools[id]; exists { if call.recovered { - if err := call.reconcileStart(update); err != nil { + wasTerminal := call.isTerminal() + message, err := call.reconcileStart(update, turn.usesStartContentAsInput()) + if err != nil { return nil, "", err } if call.isTerminal() { delete(turn.tools, id) } + if !wasTerminal { + return message, "", nil + } return nil, "", nil } return nil, "", fmt.Errorf("acp tool call %q was started twice", id) } call := &toolCall{id: id} - call.input = call.formatValue(update.RawInput) call.setName(update.Title, update.Kind) if _, _, err := call.updateStatus(&update.Status); err != nil { return nil, "", err } + contentIsInput := turn.usesStartContentAsInput() && !call.isTerminal() + call.setStartInput(update.Content, update.RawInput, contentIsInput) - toolOutputValue := call.toolOutput(update.Content, update.Meta, update.RawOutput) + toolOutputValue := call.startOutput(update.Content, update.Meta, update.RawInput, update.RawOutput, contentIsInput) call.applyOutput(toolOutputValue) + if call.isTerminal() { + return call.message(), "", nil + } turn.tools[id] = call return call.message(), call.output, nil } @@ -257,7 +266,7 @@ func (turn *turnState) upsertPermissionTool(update *acpsdk.ToolCallUpdate) error } return nil } - events, err := turn.applyToolUpdate(turn.permissionToolCallUpdate(update)) + events, err := turn.applyToolUpdate(turn.permissionToolCallUpdate(update), turn.usesStartContentAsInput()) turn.mu.Unlock() if err != nil { @@ -332,7 +341,7 @@ func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { turn.toolMu.Lock() defer turn.toolMu.Unlock() turn.mu.Lock() - events, err := turn.applyToolUpdate(update) + events, err := turn.applyToolUpdate(update, false) turn.mu.Unlock() if err != nil { @@ -344,7 +353,7 @@ func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { return nil } -func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (toolUpdateEvents, error) { +func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate, contentIsInput bool) (toolUpdateEvents, error) { id := string(update.ToolCallId) call, exists := turn.tools[id] @@ -355,22 +364,29 @@ func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (to return toolUpdateEvents{}, fmt.Errorf("acp tool call update %q arrived before tool_call", id) } metadataChanged := call.updateMetadata(update) + _, terminal, err := call.status(update.Status) + if err != nil { + return toolUpdateEvents{}, err + } previousOutput := call.output output := call.toolOutput(update.Content, update.Meta, update.RawOutput) + if contentIsInput && !terminal && update.RawInput == nil { + metadataChanged = call.setContentInput(update.Content) || metadataChanged + output = call.toolOutput(nil, update.Meta, update.RawOutput) + } if output.text != "" { call.applyOutput(output) } streamOutput := call.output != previousOutput && (previousOutput == "" || strings.HasPrefix(call.output, previousOutput)) wasTerminal := call.isTerminal() - terminal := wasTerminal + terminal = wasTerminal statusChanged := false if wasTerminal { if err := call.validateStatus(update.Status); err != nil { return toolUpdateEvents{}, err } } else { - var err error terminal, statusChanged, err = call.updateStatus(update.Status) if err != nil { return toolUpdateEvents{}, err @@ -404,9 +420,18 @@ func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (to func (turn *turnState) recoverToolUpdate(update *acpsdk.SessionToolCallUpdate) (toolUpdateEvents, error) { call := &toolCall{id: string(update.ToolCallId), state: console.AgentMessageToolStateRunning, recovered: true} call.updateMetadata(update) + _, terminal, err := call.status(update.Status) + if err != nil { + return toolUpdateEvents{}, err + } + output := call.toolOutput(update.Content, update.Meta, update.RawOutput) + if !terminal && turn.usesStartContentAsInput() && update.RawInput == nil { + call.setContentInput(update.Content) + output = call.toolOutput(nil, update.Meta, update.RawOutput) + } startMessage := call.message() - call.applyOutput(call.toolOutput(update.Content, update.Meta, update.RawOutput)) - terminal, _, err := call.updateStatus(update.Status) + call.applyOutput(output) + terminal, _, err = call.updateStatus(update.Status) if err != nil { return toolUpdateEvents{}, err } @@ -448,6 +473,10 @@ func (turn *turnState) emitToolUpdate(id acpsdk.ToolCallId, events toolUpdateEve } } +func (turn *turnState) usesStartContentAsInput() bool { + return turn.engine != nil && turn.engine.startContentIsInputWithoutRawInput +} + func (turn *turnState) emitAssistant(responseUsage *acpsdk.Usage) { turn.mu.Lock() text := turn.assistant.String() diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go index 88fa7f11e3..9670ec789c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "io" + "os" "path/filepath" console "github.com/pluralsh/console/go/client" @@ -13,9 +15,13 @@ import ( ) const ( - geminiHomeDir = ".gemini" - geminiSkillsDir = "skills" - geminiChatsDir = "chats" + geminiHomeDir = ".gemini" + geminiSkillsDir = "skills" + geminiChatsDir = "chats" + geminiCompatibilityInstructions = ` + +Gemini CLI compatibility: do not use command substitution forms such as $(), backticks, <(), or >(), because the CLI blocks them even in yolo mode. Use arithmetic loops, shell builtins, temporary files, or separate commands instead. +` ) type Agent struct { @@ -64,6 +70,9 @@ func (agent *Agent) Prepare(ctx context.Context, request toolv1.FileSystemReques if err := agent.contextError(ctx); err != nil { return err } + if err := agent.appendCompatibilityInstructions(config); err != nil { + return err + } return defaultTool.ConfigureSkills(agent.skillsPath(config)) } @@ -163,6 +172,28 @@ func (agent *Agent) chatsPath(config toolv1.Config) string { return filepath.Join(agent.geminiHome(config), "tmp", "plural", geminiChatsDir) } +func (agent *Agent) appendCompatibilityInstructions(config toolv1.Config) error { + promptPath := filepath.Join(agent.geminiHome(config), toolv1.SystemPromptFile) + prompt, err := os.OpenFile(promptPath, os.O_WRONLY|os.O_APPEND, 0) + if err != nil { + return fmt.Errorf("open Gemini system prompt for compatibility instructions: %w", err) + } + + written, err := io.WriteString(prompt, geminiCompatibilityInstructions) + if err != nil { + _ = prompt.Close() + return fmt.Errorf("append Gemini compatibility instructions: %w", err) + } + if written != len(geminiCompatibilityInstructions) { + _ = prompt.Close() + return fmt.Errorf("append Gemini compatibility instructions: %w", io.ErrShortWrite) + } + if err := prompt.Close(); err != nil { + return fmt.Errorf("close Gemini system prompt: %w", err) + } + return nil +} + func (*Agent) contextError(ctx context.Context) error { if ctx == nil { return nil diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go index 1341698c2c..e6a2376232 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go @@ -30,6 +30,9 @@ func TestAgentPrepareWritesPromptAndSkills(t *testing.T) { if !strings.HasPrefix(string(prompt), "analyze") { t.Fatalf("prompt = %q", prompt) } + if !strings.Contains(string(prompt), "command substitution") || !strings.Contains(string(prompt), "temporary files") { + t.Fatalf("Gemini compatibility instructions missing: %q", prompt) + } if _, err := os.Stat(filepath.Join(workDir, geminiHomeDir, geminiSkillsDir, "repository", "SKILL.md")); err != nil { t.Fatalf("skill: %v", err) } @@ -44,6 +47,9 @@ func TestAgentPrepareWritesPromptAndSkills(t *testing.T) { if !strings.HasPrefix(string(prompt), "babysit") { t.Fatalf("babysit prompt = %q", prompt) } + if !strings.Contains(string(prompt), "command substitution") || !strings.Contains(string(prompt), "temporary files") { + t.Fatalf("Gemini babysit compatibility instructions missing: %q", prompt) + } } func TestAgentConfigureWritesSettings(t *testing.T) { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go index d2439ca85c..f36828980b 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go @@ -63,6 +63,9 @@ func NewTransport(agent *Agent) (*Transport, error) { // Gemini CLI v0.59.0 can emit a tool_call_update before its // corresponding start event. Revisit on future upgrades. acp.WithToolCallUpdateRecovery(), + // Gemini CLI v0.59.0 puts tool input in start content and omits + // rawInput. Revisit on future upgrades. + acp.WithToolCallStartContentAsInputWithoutRawInput(), ) result.engine = engine From fe745436eba171a26daa475bc93a79db4e80fe03 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Thu, 10 Sep 2026 14:59:42 +0200 Subject: [PATCH 33/46] refactor(acp, gemini): simplify tool call and runtime handling - Removed recovery logic for tool calls and redundant content input mappings. - Streamlined `applyToolUpdate` by dropping unused flags and dependencies. - Updated Gemini runtime to utilize streamlined transport with auto-resume support. - Simplified tests by eliminating unnecessary tool call recovery scenarios. --- .../agentrun-harness/tool/acp/client_test.go | 433 +----------------- .../pkg/agentrun-harness/tool/acp/engine.go | 5 - .../agentrun-harness/tool/acp/engine_test.go | 6 +- .../agentrun-harness/tool/acp/tool_call.go | 82 +--- .../tool/acp/tool_call_test.go | 2 +- .../pkg/agentrun-harness/tool/acp/types.go | 16 - .../pkg/agentrun-harness/tool/acp/updates.go | 124 +---- .../tool/gemini/runtime_config.go | 2 +- .../tool/gemini/runtime_config_test.go | 4 +- .../agentrun-harness/tool/gemini/stream.go | 365 +++++++++++++++ .../tool/gemini/stream_test.go | 154 +++++++ .../tool/gemini/testdata/invalid_stream.jsonl | 3 + .../tool/gemini/testdata/malformed.jsonl | 4 + .../tool/gemini/testdata/result_error.jsonl | 3 + .../tool/gemini/testdata/success.jsonl | 12 + .../agentrun-harness/tool/gemini/transport.go | 131 ++---- .../tool/gemini/transport_test.go | 399 ++++++++++++---- 17 files changed, 932 insertions(+), 813 deletions(-) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/invalid_stream.jsonl create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/malformed.jsonl create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/result_error.jsonl create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/success.jsonl diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go index 9b8121414c..ada85fa948 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -115,6 +115,17 @@ func TestClientRejectsToolCallUpdateBeforeToolCallByDefault(t *testing.T) { } } +func TestClientRejectsEmptyToolCallUpdateID(t *testing.T) { + acpClient := &client{turn: newTurn(NewEngine(), &testSink{}, "session-1")} + err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ + SessionId: "session-1", + Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{}}, + }) + if err == nil || err.Error() != "acp tool call update has an empty id" { + t.Fatalf("empty tool call update error = %v", err) + } +} + func TestClientRejectsDuplicateToolCallStarts(t *testing.T) { acpClient := &client{turn: newTurn(NewEngine(), &testSink{}, "session-1")} update := acpsdk.SessionUpdateToolCall{ToolCallId: "call-1", Status: acpsdk.ToolCallStatusInProgress} @@ -150,39 +161,9 @@ func TestClientMapsStartContentToOutputByDefault(t *testing.T) { } } -func TestClientMapsGeminiStartContentToInput(t *testing.T) { - sink := &testSink{} - acpClient := &client{turn: newTurn(NewEngine(WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} - inProgress := acpsdk.ToolCallStatusInProgress - completed := acpsdk.ToolCallStatusCompleted - start := acpsdk.SessionNotification{SessionId: "session-1", Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ - ToolCallId: "call-1", Status: inProgress, - Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock(`{"command":"git status"}`))}, - }}} - if err := acpClient.SessionUpdate(context.Background(), start); err != nil { - t.Fatalf("start tool call: %v", err) - } - if len(sink.messages) != 1 || sink.messages[0].Metadata.Tool.Input == nil || *sink.messages[0].Metadata.Tool.Input != `{"command":"git status"}` || *sink.messages[0].Metadata.Tool.Output != runningToolOutput { - t.Fatalf("Gemini start mapping = %#v", sink.messages) - } - if len(sink.outputs) != 0 { - t.Fatalf("Gemini start output events = %v", sink.outputs) - } - completion := acpsdk.SessionNotification{SessionId: "session-1", Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ - ToolCallId: "call-1", Status: &completed, - Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("clean"))}, - }}} - if err := acpClient.SessionUpdate(context.Background(), completion); err != nil { - t.Fatalf("complete tool call: %v", err) - } - if len(sink.outputs) != 1 || sink.outputs[0] != "call-1:clean" { - t.Fatalf("Gemini completion output events = %v", sink.outputs) - } -} - -func TestClientKeepsStartContentAsOutputWhenRawInputExists(t *testing.T) { +func TestClientMapsRawInputAndStartContent(t *testing.T) { sink := &testSink{} - acpClient := &client{turn: newTurn(NewEngine(WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} + acpClient := &client{turn: newTurn(NewEngine(), sink, "session-1")} inProgress := acpsdk.ToolCallStatusInProgress err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ SessionId: "session-1", @@ -203,9 +184,9 @@ func TestClientKeepsStartContentAsOutputWhenRawInputExists(t *testing.T) { } } -func TestClientKeepsTerminalGeminiStartContentAsOutput(t *testing.T) { +func TestClientKeepsTerminalStartContentAsOutput(t *testing.T) { sink := &testSink{} - acpClient := &client{turn: newTurn(NewEngine(WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} + acpClient := &client{turn: newTurn(NewEngine(), sink, "session-1")} completed := acpsdk.ToolCallStatusCompleted err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ SessionId: "session-1", @@ -219,337 +200,13 @@ func TestClientKeepsTerminalGeminiStartContentAsOutput(t *testing.T) { } tool := sink.messages[0].Metadata.Tool if tool.Input != nil || tool.Output == nil || *tool.Output != "actual output" { - t.Fatalf("terminal Gemini start mapping = %#v", tool) + t.Fatalf("terminal start mapping = %#v", tool) } if len(sink.outputs) != 0 { - t.Fatalf("terminal Gemini start output events = %v", sink.outputs) + t.Fatalf("terminal start output events = %v", sink.outputs) } if _, exists := acpClient.turn.tools["call-1"]; exists { - t.Fatal("terminal Gemini start remained active") - } -} - -func TestClientRecoversToolCallUpdateBeforeToolCall(t *testing.T) { - sink := &testSink{} - acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), sink, "session-1")} - title := "Create pull request" - kind := acpsdk.ToolKindOther - completed := acpsdk.ToolCallStatusCompleted - - err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ - ToolCallId: "mcp_plural_agentPullRequest__call_1028406", - Title: &title, - Kind: &kind, - RawInput: map[string]any{"title": "docs: update README"}, - RawOutput: map[string]any{"formatted_output": "https://github.com/pluralsh/console/pull/1"}, - Status: &completed, - }}, - }) - if err != nil { - t.Fatalf("tool call update before tool call: %v", err) - } - if len(sink.messages) != 2 { - t.Fatalf("tool call messages = %d, want 2", len(sink.messages)) - } - start := sink.messages[0].Metadata.Tool - if start.Name == nil || *start.Name != title || start.State == nil || *start.State != console.AgentMessageToolStateRunning || start.Output == nil || *start.Output != runningToolOutput { - t.Fatalf("recovered tool start = %#v", start) - } - terminal := sink.messages[1].Metadata.Tool - if terminal.Input == nil || *terminal.Input != `{"title":"docs: update README"}` || terminal.Output == nil || *terminal.Output != "https://github.com/pluralsh/console/pull/1" || terminal.State == nil || *terminal.State != console.AgentMessageToolStateCompleted { - t.Fatalf("recovered terminal tool = %#v", terminal) - } - if len(sink.events) != 3 || sink.events[0] != "message:mcp_plural_agentPullRequest__call_1028406:Called tool" || sink.events[1] != "output:mcp_plural_agentPullRequest__call_1028406:https://github.com/pluralsh/console/pull/1" || sink.events[2] != "message:mcp_plural_agentPullRequest__call_1028406:Called tool" { - t.Fatalf("recovered tool event order = %v", sink.events) - } - - pending := acpsdk.ToolCallStatusPending - err = acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ - ToolCallId: "mcp_plural_agentPullRequest__call_1028406", - Title: "Create pull request", - Kind: kind, - Status: pending, - }}, - }) - if err != nil { - t.Fatalf("late tool call: %v", err) - } - if len(sink.messages) != 2 || len(sink.outputs) != 1 { - t.Fatalf("tool call events after reconciliation = %v / %v", sink.messages, sink.outputs) - } -} - -func TestClientMapsRecoveredGeminiNonterminalContentToInput(t *testing.T) { - sink := &testSink{} - acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery(), WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} - inProgress := acpsdk.ToolCallStatusInProgress - err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ - ToolCallId: "call-1", Status: &inProgress, - Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("Run shell command."))}, - }}, - }) - if err != nil { - t.Fatalf("recover tool call update: %v", err) - } - if len(sink.messages) != 1 || sink.messages[0].Metadata.Tool.Input == nil || *sink.messages[0].Metadata.Tool.Input != "Run shell command." || *sink.messages[0].Metadata.Tool.Output != runningToolOutput { - t.Fatalf("recovered Gemini start mapping = %#v", sink.messages) - } - if len(sink.outputs) != 0 { - t.Fatalf("recovered Gemini output events = %v", sink.outputs) - } -} - -func TestClientReconcilesGeminiRawInputWithoutEmittingDelayedStartContent(t *testing.T) { - sink := &testSink{} - acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery(), WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} - inProgress := acpsdk.ToolCallStatusInProgress - if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ - ToolCallId: "call-1", Status: &inProgress, - Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("inferred input"))}, - }}, - }); err != nil { - t.Fatalf("recover tool call update: %v", err) - } - if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ - ToolCallId: "call-1", Status: inProgress, RawInput: map[string]any{"command": "git status"}, - Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("delayed explanation"))}, - }}, - }); err != nil { - t.Fatalf("reconcile tool call: %v", err) - } - if len(sink.messages) != 2 { - t.Fatalf("reconciled Gemini messages = %d, want 2", len(sink.messages)) - } - tool := sink.messages[1].Metadata.Tool - if tool.Input == nil || *tool.Input != `{"command":"git status"}` || tool.Output == nil || *tool.Output != runningToolOutput || tool.State == nil || *tool.State != console.AgentMessageToolStateRunning { - t.Fatalf("reconciled Gemini message = %#v", tool) - } - if len(sink.outputs) != 0 { - t.Fatalf("reconciled Gemini output events = %v", sink.outputs) - } -} - -func TestClientReconcilesTerminalGeminiStartAsMetadataOutput(t *testing.T) { - sink := &testSink{} - acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery(), WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} - inProgress := acpsdk.ToolCallStatusInProgress - if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ - ToolCallId: "call-1", Status: &inProgress, - Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("inferred input"))}, - }}, - }); err != nil { - t.Fatalf("recover tool call update: %v", err) - } - completed := acpsdk.ToolCallStatusCompleted - if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ - ToolCallId: "call-1", Status: completed, - Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("actual output"))}, - }}, - }); err != nil { - t.Fatalf("reconcile terminal tool call: %v", err) - } - if len(sink.messages) != 2 { - t.Fatalf("reconciled terminal Gemini messages = %d, want 2", len(sink.messages)) - } - tool := sink.messages[1].Metadata.Tool - if tool.Output == nil || *tool.Output != "actual output" || tool.State == nil || *tool.State != console.AgentMessageToolStateCompleted { - t.Fatalf("reconciled terminal Gemini message = %#v", tool) - } - if len(sink.outputs) != 0 { - t.Fatalf("reconciled terminal Gemini output events = %v", sink.outputs) - } -} - -func TestClientKeepsRecoveredGeminiTerminalContentAsOutput(t *testing.T) { - sink := &testSink{} - acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery(), WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} - completed := acpsdk.ToolCallStatusCompleted - err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ - ToolCallId: "call-1", Status: &completed, - Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("actual output"))}, - }}, - }) - if err != nil { - t.Fatalf("recover terminal tool call update: %v", err) - } - if len(sink.messages) != 2 || sink.messages[0].Metadata.Tool.Input != nil || *sink.messages[1].Metadata.Tool.Output != "actual output" { - t.Fatalf("recovered terminal Gemini mapping = %#v", sink.messages) - } - if len(sink.outputs) != 1 || sink.outputs[0] != "call-1:actual output" { - t.Fatalf("recovered terminal output events = %v", sink.outputs) - } -} - -func TestClientRejectsEmptyToolCallUpdateIDWithRecovery(t *testing.T) { - acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), &testSink{}, "session-1")} - err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{}}, - }) - if err == nil || err.Error() != "acp tool call update has an empty id" { - t.Fatalf("empty tool call update error = %v", err) - } -} - -func TestClientRecoversUnspecifiedToolCallStatusAsRunning(t *testing.T) { - sink := &testSink{} - acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), sink, "session-1")} - if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ - ToolCallId: "call-1", - }}, - }); err != nil { - t.Fatalf("recover tool call update: %v", err) - } - pending := acpsdk.ToolCallStatusPending - if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ - ToolCallId: "call-1", - Status: pending, - }}, - }); err != nil { - t.Fatalf("reconcile tool call: %v", err) - } - if len(sink.messages) != 2 { - t.Fatalf("tool call messages = %d, want 2", len(sink.messages)) - } - if state := sink.messages[1].Metadata.Tool.State; state == nil || *state != console.AgentMessageToolStateRunning { - t.Fatalf("reconciled tool state = %v, want running", state) - } -} - -func TestClientPreservesRecoveredTerminalStatusAcrossLaterToolCallUpdates(t *testing.T) { - sink := &testSink{} - acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), sink, "session-1")} - completed := acpsdk.ToolCallStatusCompleted - if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ - ToolCallId: "call-1", - Status: &completed, - }}, - }); err != nil { - t.Fatalf("recover completed tool call update: %v", err) - } - inProgress := acpsdk.ToolCallStatusInProgress - if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ - ToolCallId: "call-1", - Status: &inProgress, - }}, - }); err != nil { - t.Fatalf("late in-progress tool call update: %v", err) - } - pending := acpsdk.ToolCallStatusPending - if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ - ToolCallId: "call-1", - Status: pending, - }}, - }); err != nil { - t.Fatalf("late tool call: %v", err) - } - if len(sink.messages) != 2 { - t.Fatalf("tool call messages = %d, want 2", len(sink.messages)) - } - if state := sink.messages[1].Metadata.Tool.State; state == nil || *state != console.AgentMessageToolStateCompleted { - t.Fatalf("reconciled tool state = %v, want completed", state) - } -} - -func TestClientValidatesStatusesAfterRecoveredTerminalToolCall(t *testing.T) { - unknown := acpsdk.ToolCallStatus("unknown") - for _, test := range []struct { - name string - update acpsdk.SessionUpdate - }{ - { - name: "late update", - update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ - ToolCallId: "call-1", - Status: &unknown, - }}, - }, - { - name: "late start", - update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ - ToolCallId: "call-1", - Status: unknown, - }}, - }, - } { - t.Run(test.name, func(t *testing.T) { - acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), &testSink{}, "session-1")} - completed := acpsdk.ToolCallStatusCompleted - if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ - ToolCallId: "call-1", - Status: &completed, - }}, - }); err != nil { - t.Fatalf("recover completed tool call update: %v", err) - } - err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{SessionId: "session-1", Update: test.update}) - if err == nil || err.Error() != `acp tool call has unknown status "unknown"` { - t.Fatalf("late status error = %v", err) - } - }) - } -} - -func TestClientReconcilesPermissionToolCallAfterRecovery(t *testing.T) { - sink := &testSink{} - acpClient := &client{turn: newTurn(NewEngine(WithToolCallUpdateRecovery()), sink, "session-1")} - completed := acpsdk.ToolCallStatusCompleted - if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCallUpdate: &acpsdk.SessionToolCallUpdate{ - ToolCallId: "call-1", - Status: &completed, - }}, - }); err != nil { - t.Fatalf("recover tool call update: %v", err) - } - title := "Create pull request" - kind := acpsdk.ToolKindOther - _, err := acpClient.RequestPermission(context.Background(), acpsdk.RequestPermissionRequest{ - SessionId: "session-1", - ToolCall: acpsdk.ToolCallUpdate{ - ToolCallId: "call-1", - Title: &title, - Kind: &kind, - RawInput: map[string]any{"title": "docs: update README"}, - }, - }) - if err == nil || err.Error() != "acp permission requests are unavailable in unattended runs" { - t.Fatalf("permission error = %v, want unattended permission denial", err) - } - if len(sink.messages) != 2 { - t.Fatalf("tool call messages = %d, want 2", len(sink.messages)) - } - if state := sink.messages[1].Metadata.Tool.State; state == nil || *state != console.AgentMessageToolStateCompleted { - t.Fatalf("recovered terminal state = %v, want completed", state) + t.Fatal("terminal start remained active") } } @@ -593,60 +250,6 @@ func TestClientRequestPermissionUpdatesExistingToolCallBeforeDenying(t *testing. } } -func TestClientMapsGeminiPermissionContentToInputForExistingToolCall(t *testing.T) { - sink := &testSink{} - acpClient := &client{turn: newTurn(NewEngine(WithToolCallStartContentAsInputWithoutRawInput()), sink, "session-1")} - inProgress := acpsdk.ToolCallStatusInProgress - if err := acpClient.SessionUpdate(context.Background(), acpsdk.SessionNotification{ - SessionId: "session-1", - Update: acpsdk.SessionUpdate{ToolCall: &acpsdk.SessionUpdateToolCall{ToolCallId: "call-1", Status: inProgress}}, - }); err != nil { - t.Fatalf("start tool call: %v", err) - } - - _, err := acpClient.RequestPermission(context.Background(), acpsdk.RequestPermissionRequest{ - SessionId: "session-1", - ToolCall: acpsdk.ToolCallUpdate{ - ToolCallId: "call-1", - Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("Run shell command."))}, - }, - }) - if err == nil || err.Error() != "acp permission requests are unavailable in unattended runs" { - t.Fatalf("permission error = %v, want unattended permission denial", err) - } - if len(sink.messages) != 2 { - t.Fatalf("tool call messages = %d, want 2", len(sink.messages)) - } - tool := sink.messages[1].Metadata.Tool - if tool.Input == nil || *tool.Input != "Run shell command." || tool.Output == nil || *tool.Output != runningToolOutput { - t.Fatalf("Gemini permission mapping = %#v", tool) - } - if len(sink.outputs) != 0 { - t.Fatalf("Gemini permission output events = %v", sink.outputs) - } - - _, err = acpClient.RequestPermission(context.Background(), acpsdk.RequestPermissionRequest{ - SessionId: "session-1", - ToolCall: acpsdk.ToolCallUpdate{ - ToolCallId: "call-1", RawInput: map[string]any{"command": "git status"}, - Content: []acpsdk.ToolCallContent{acpsdk.ToolContent(acpsdk.TextBlock("displayed output"))}, - }, - }) - if err == nil || err.Error() != "acp permission requests are unavailable in unattended runs" { - t.Fatalf("permission error with raw input = %v, want unattended permission denial", err) - } - if len(sink.messages) != 3 { - t.Fatalf("tool call messages with raw input = %d, want 3", len(sink.messages)) - } - tool = sink.messages[2].Metadata.Tool - if tool.Input == nil || *tool.Input != `{"command":"git status"}` || tool.Output == nil || *tool.Output != "displayed output" { - t.Fatalf("Gemini permission raw input mapping = %#v", tool) - } - if len(sink.outputs) != 1 || sink.outputs[0] != "call-1:displayed output" { - t.Fatalf("Gemini permission raw input output events = %v", sink.outputs) - } -} - func TestClientRequestPermissionReturnsSessionAndToolCallErrors(t *testing.T) { acpClient := &client{turn: newTurn(NewEngine(), &testSink{}, "session-1")} for _, test := range []struct { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go index 8e21127fe4..35a98e7f54 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go @@ -28,11 +28,6 @@ type Engine struct { restoreSession SessionRestorer authenticationMethod string usageResolver UsageResolver - - // These are workarounds for gemini ACP issues. - // Revisit these when gemini ACP issues are resolved. - recoverToolUpdates bool - startContentIsInputWithoutRawInput bool } func (engine *Engine) setSessionConfig(ctx context.Context, connection *acpsdk.ClientSideConnection, sessionID string, modes *acpsdk.SessionModeState, options []acpsdk.SessionConfigOption, settings SessionSettings) error { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go index 43b5e22adb..d0d5edb8af 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -335,7 +335,7 @@ func TestNewEngineOptionsPreserveDefaultsAndApplyOverrides(t *testing.T) { standard := &acpsdk.Usage{InputTokens: 3} defaults := NewEngine(WithStopTimeout(0), WithSessionRestorer(nil), WithUsageResolver(nil)) if defaults.stopTimeout != defaultStopTimeout || defaults.restoreSession == nil || - defaults.authenticationMethod != "" || defaults.usageResolver(acpsdk.PromptResponse{Usage: standard}) != standard || defaults.recoverToolUpdates || defaults.startContentIsInputWithoutRawInput { + defaults.authenticationMethod != "" || defaults.usageResolver(acpsdk.PromptResponse{Usage: standard}) != standard { t.Fatalf("default engine = %#v", defaults) } @@ -345,11 +345,9 @@ func TestNewEngineOptionsPreserveDefaultsAndApplyOverrides(t *testing.T) { WithSessionRestorer(LoadSession), WithAuthenticationMethod("api-key"), WithUsageResolver(resolver), - WithToolCallUpdateRecovery(), - WithToolCallStartContentAsInputWithoutRawInput(), ) if configured.stopTimeout != time.Second || configured.restoreSession == nil || - configured.authenticationMethod != "api-key" || configured.usageResolver(acpsdk.PromptResponse{}).InputTokens != 5 || !configured.recoverToolUpdates || !configured.startContentIsInputWithoutRawInput { + configured.authenticationMethod != "api-key" || configured.usageResolver(acpsdk.PromptResponse{}).InputTokens != 5 { t.Fatalf("configured engine = %#v", configured) } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go index b38556f2af..cf5f0e6c52 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go @@ -13,14 +13,13 @@ import ( const runningToolOutput = "running..." type toolCall struct { - id string - name string - title string - kind acpsdk.ToolKind - input string - output string - state console.AgentMessageToolState - recovered bool + id string + name string + title string + kind acpsdk.ToolKind + input string + output string + state console.AgentMessageToolState } type toolOutputValue struct { @@ -86,31 +85,6 @@ func (call *toolCall) toolOutput(content []acpsdk.ToolCallContent, meta map[stri return toolOutputValue{text: output} } -func (call *toolCall) setStartInput(content []acpsdk.ToolCallContent, rawInput any, startContentIsInputWithoutRawInput bool) { - call.input = call.formatValue(rawInput) - if rawInput == nil && startContentIsInputWithoutRawInput { - call.setContentInput(content) - } -} - -func (call *toolCall) setContentInput(content []acpsdk.ToolCallContent) bool { - if input := call.contentOutput(content); input != "" { - if call.input == input { - return false - } - call.input = input - return true - } - return false -} - -func (call *toolCall) startOutput(content []acpsdk.ToolCallContent, meta map[string]any, rawInput, rawOutput any, startContentIsInputWithoutRawInput bool) toolOutputValue { - if rawInput == nil && startContentIsInputWithoutRawInput { - return call.toolOutput(nil, meta, rawOutput) - } - return call.toolOutput(content, meta, rawOutput) -} - func terminalOutput(meta map[string]any) (string, bool, bool) { for _, candidate := range []struct { name string @@ -141,7 +115,6 @@ func formattedRawOutput(rawOutput any) (string, bool) { } type toolUpdateEvents struct { - startMessage *console.AgentMessageAttributes message *console.AgentMessageAttributes output string streamOutput bool @@ -222,11 +195,6 @@ func (call *toolCall) updateStatus(status *acpsdk.ToolCallStatus) (bool, bool, e return terminal, changed, nil } -func (call *toolCall) validateStatus(status *acpsdk.ToolCallStatus) error { - _, _, err := call.status(status) - return err -} - func (*toolCall) status(status *acpsdk.ToolCallStatus) (console.AgentMessageToolState, bool, error) { if status == nil { return "", false, nil @@ -248,42 +216,6 @@ func (*toolCall) status(status *acpsdk.ToolCallStatus) (console.AgentMessageTool return state, terminal, nil } -func (call *toolCall) reconcileStart(update *acpsdk.SessionUpdateToolCall, startContentIsInputWithoutRawInput bool) (*console.AgentMessageAttributes, error) { - if err := call.validateStatus(&update.Status); err != nil { - return nil, err - } - if call.title == "" { - call.title = update.Title - } - if call.kind == "" { - call.kind = update.Kind - } - call.name = call.displayName() - _, terminal, err := call.status(&update.Status) - if err != nil { - return nil, err - } - if update.RawInput != nil { - call.input = call.formatValue(update.RawInput) - } else if call.input == "" { - call.setStartInput(update.Content, nil, startContentIsInputWithoutRawInput && !terminal) - } - if call.output == "" { - if startContentIsInputWithoutRawInput && !terminal { - call.applyOutput(call.toolOutput(nil, update.Meta, update.RawOutput)) - } else { - call.applyOutput(call.startOutput(update.Content, update.Meta, update.RawInput, update.RawOutput, false)) - } - } - if !call.isTerminal() && update.Status != acpsdk.ToolCallStatusPending { - if _, _, err := call.updateStatus(&update.Status); err != nil { - return nil, err - } - } - call.recovered = false - return call.message(), nil -} - func (call *toolCall) isTerminal() bool { return call.state == console.AgentMessageToolStateCompleted || call.state == console.AgentMessageToolStateError } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go index 53e1d7fdbc..25940b08c6 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go @@ -61,7 +61,7 @@ func TestToolCallMapsAdapterTerminalOutput(t *testing.T) { for _, update := range updates { events, err := turn.applyToolUpdate(&acpsdk.SessionToolCallUpdate{ ToolCallId: "call-1", Meta: update.meta, RawOutput: update.rawOutput, Status: update.status, - }, false) + }) if err != nil { t.Fatalf("applyToolUpdate() error = %v", err) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go index d92eaef67d..d12c82be8f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go @@ -127,19 +127,3 @@ func WithUsageResolver(resolver UsageResolver) Option { } } } - -// WithToolCallUpdateRecovery accepts tool call updates that arrive before -// their corresponding tool call. Providers with ordered updates do not need it. -func WithToolCallUpdateRecovery() Option { - return func(engine *Engine) { - engine.recoverToolUpdates = true - } -} - -// WithToolCallStartContentAsInputWithoutRawInput treats textual start content -// as tool input when a provider omits RawInput. -func WithToolCallStartContentAsInputWithoutRawInput() Option { - return func(engine *Engine) { - engine.startContentIsInputWithoutRawInput = true - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go index 5998729871..bc80b10324 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go @@ -204,33 +204,18 @@ func (turn *turnState) startTool(update *acpsdk.SessionUpdateToolCall) error { func (turn *turnState) startToolLocked(update *acpsdk.SessionUpdateToolCall) (*console.AgentMessageAttributes, string, error) { id := string(update.ToolCallId) - if call, exists := turn.tools[id]; exists { - if call.recovered { - wasTerminal := call.isTerminal() - message, err := call.reconcileStart(update, turn.usesStartContentAsInput()) - if err != nil { - return nil, "", err - } - if call.isTerminal() { - delete(turn.tools, id) - } - if !wasTerminal { - return message, "", nil - } - return nil, "", nil - } + if _, exists := turn.tools[id]; exists { return nil, "", fmt.Errorf("acp tool call %q was started twice", id) } call := &toolCall{id: id} + call.input = call.formatValue(update.RawInput) call.setName(update.Title, update.Kind) if _, _, err := call.updateStatus(&update.Status); err != nil { return nil, "", err } - contentIsInput := turn.usesStartContentAsInput() && !call.isTerminal() - call.setStartInput(update.Content, update.RawInput, contentIsInput) - toolOutputValue := call.startOutput(update.Content, update.Meta, update.RawInput, update.RawOutput, contentIsInput) + toolOutputValue := call.toolOutput(update.Content, update.Meta, update.RawOutput) call.applyOutput(toolOutputValue) if call.isTerminal() { return call.message(), "", nil @@ -248,25 +233,8 @@ func (turn *turnState) upsertPermissionTool(update *acpsdk.ToolCallUpdate) error defer turn.toolMu.Unlock() turn.mu.Lock() - if call, exists := turn.tools[string(update.ToolCallId)]; exists { - if call.recovered { - message, output, err := turn.startToolLocked(turn.permissionToolCallStart(update)) - turn.mu.Unlock() - - if err != nil { - turn.setErr(err) - return err - } - - if message != nil { - turn.sink.Message(message, string(update.ToolCallId)) - } - if output != "" { - turn.sink.ToolCallOutput(string(update.ToolCallId), output) - } - return nil - } - events, err := turn.applyToolUpdate(turn.permissionToolCallUpdate(update), turn.usesStartContentAsInput()) + if _, exists := turn.tools[string(update.ToolCallId)]; exists { + events, err := turn.applyToolUpdate(turn.permissionToolCallUpdate(update)) turn.mu.Unlock() if err != nil { @@ -341,7 +309,7 @@ func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { turn.toolMu.Lock() defer turn.toolMu.Unlock() turn.mu.Lock() - events, err := turn.applyToolUpdate(update, false) + events, err := turn.applyToolUpdate(update) turn.mu.Unlock() if err != nil { @@ -353,58 +321,31 @@ func (turn *turnState) updateTool(update *acpsdk.SessionToolCallUpdate) error { return nil } -func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate, contentIsInput bool) (toolUpdateEvents, error) { +func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate) (toolUpdateEvents, error) { id := string(update.ToolCallId) call, exists := turn.tools[id] if !exists { - if turn.engine != nil && turn.engine.recoverToolUpdates { - return turn.recoverToolUpdate(update) - } return toolUpdateEvents{}, fmt.Errorf("acp tool call update %q arrived before tool_call", id) } metadataChanged := call.updateMetadata(update) - _, terminal, err := call.status(update.Status) - if err != nil { - return toolUpdateEvents{}, err - } previousOutput := call.output output := call.toolOutput(update.Content, update.Meta, update.RawOutput) - if contentIsInput && !terminal && update.RawInput == nil { - metadataChanged = call.setContentInput(update.Content) || metadataChanged - output = call.toolOutput(nil, update.Meta, update.RawOutput) - } if output.text != "" { call.applyOutput(output) } streamOutput := call.output != previousOutput && (previousOutput == "" || strings.HasPrefix(call.output, previousOutput)) - wasTerminal := call.isTerminal() - terminal = wasTerminal - statusChanged := false - if wasTerminal { - if err := call.validateStatus(update.Status); err != nil { - return toolUpdateEvents{}, err - } - } else { - terminal, statusChanged, err = call.updateStatus(update.Status) - if err != nil { - return toolUpdateEvents{}, err - } - } - if wasTerminal && call.recovered { - return toolUpdateEvents{}, nil + terminal, statusChanged, err := call.updateStatus(update.Status) + if err != nil { + return toolUpdateEvents{}, err } metadataChanged = metadataChanged || statusChanged message := (*console.AgentMessageAttributes)(nil) if terminal { - if !wasTerminal || metadataChanged { - message = call.message() - } - if !call.recovered { - delete(turn.tools, id) - } + message = call.message() + delete(turn.tools, id) } else if metadataChanged { message = call.message() } @@ -417,39 +358,6 @@ func (turn *turnState) applyToolUpdate(update *acpsdk.SessionToolCallUpdate, con }, nil } -func (turn *turnState) recoverToolUpdate(update *acpsdk.SessionToolCallUpdate) (toolUpdateEvents, error) { - call := &toolCall{id: string(update.ToolCallId), state: console.AgentMessageToolStateRunning, recovered: true} - call.updateMetadata(update) - _, terminal, err := call.status(update.Status) - if err != nil { - return toolUpdateEvents{}, err - } - output := call.toolOutput(update.Content, update.Meta, update.RawOutput) - if !terminal && turn.usesStartContentAsInput() && update.RawInput == nil { - call.setContentInput(update.Content) - output = call.toolOutput(nil, update.Meta, update.RawOutput) - } - startMessage := call.message() - call.applyOutput(output) - terminal, _, err = call.updateStatus(update.Status) - if err != nil { - return toolUpdateEvents{}, err - } - turn.tools[call.id] = call - message := (*console.AgentMessageAttributes)(nil) - if terminal { - message = call.message() - } - - return toolUpdateEvents{ - startMessage: startMessage, - message: message, - output: call.output, - streamOutput: call.output != "", - terminal: terminal, - }, nil -} - func (call *toolCall) applyOutput(output toolOutputValue) { if output.delta { call.appendOutput(output.text) @@ -460,10 +368,6 @@ func (call *toolCall) applyOutput(output toolOutputValue) { } func (turn *turnState) emitToolUpdate(id acpsdk.ToolCallId, events toolUpdateEvents) { - if events.startMessage != nil { - turn.sink.Message(events.startMessage, string(id)) - } - if events.streamOutput { turn.sink.ToolCallOutput(string(id), events.output) } @@ -473,10 +377,6 @@ func (turn *turnState) emitToolUpdate(id acpsdk.ToolCallId, events toolUpdateEve } } -func (turn *turnState) usesStartContentAsInput() bool { - return turn.engine != nil && turn.engine.startContentIsInputWithoutRawInput -} - func (turn *turnState) emitAssistant(responseUsage *acpsdk.Usage) { turn.mu.Lock() text := turn.assistant.String() diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go index a6dd4c46ca..160a3f12cb 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go @@ -36,6 +36,6 @@ func (*Agent) validateMode(mode console.AgentRunMode) error { case console.AgentRunModeAnalyze, console.AgentRunModeWrite, console.AgentRunModeReview: return nil default: - return fmt.Errorf("unsupported gemini ACP mode %q", mode) + return fmt.Errorf("unsupported gemini run mode %q", mode) } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go index 860c6b07c2..bb5b20f6ca 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go @@ -50,7 +50,7 @@ func TestValidateMode(t *testing.T) { t.Fatalf("validateMode(%q) error = %v", mode, err) } } - if err := agent.validateMode("unsupported"); err == nil { - t.Fatal("validateMode() error = nil") + if err := agent.validateMode("unsupported"); err == nil || err.Error() != `unsupported gemini run mode "unsupported"` { + t.Fatalf("validateMode() error = %v", err) } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go new file mode 100644 index 0000000000..ba9524589c --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go @@ -0,0 +1,365 @@ +package gemini + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + + console "github.com/pluralsh/console/go/client" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" +) + +type streamEventType string + +const ( + streamEventInit streamEventType = "init" + streamEventMessage streamEventType = "message" + streamEventToolUse streamEventType = "tool_use" + streamEventToolResult streamEventType = "tool_result" + streamEventError streamEventType = "error" + streamEventResult streamEventType = "result" + + streamRoleUser = "user" + streamRoleAssistant = "assistant" + + streamSeverityWarning = "warning" + streamSeverityError = "error" + + streamStatusSuccess = "success" + streamStatusError = "error" +) + +type streamEvent struct { + Type streamEventType `json:"type"` +} + +type streamInitEvent struct { + SessionID string `json:"session_id"` + Model string `json:"model"` +} + +type streamMessageEvent struct { + Role string `json:"role"` + Content *string `json:"content"` + Delta *bool `json:"delta,omitempty"` +} + +type streamToolUseEvent struct { + ToolName string `json:"tool_name"` + ToolID string `json:"tool_id"` + Parameters json.RawMessage `json:"parameters"` +} + +type streamToolResultEvent struct { + ToolID string `json:"tool_id"` + Status string `json:"status"` + Output *string `json:"output,omitempty"` + Error *streamResultError `json:"error,omitempty"` +} + +type streamErrorEvent struct { + Severity string `json:"severity"` + Message string `json:"message"` +} + +type streamResultEvent struct { + Status string `json:"status"` + Error *streamResultError `json:"error,omitempty"` + Stats *streamStats `json:"stats"` +} + +type streamResultError struct { + Type string `json:"type"` + Message string `json:"message"` +} + +type streamStats struct { + TotalTokens int64 `json:"total_tokens"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CachedTokens int64 `json:"cached"` + DurationMS int64 `json:"duration_ms"` + ToolCalls int64 `json:"tool_calls"` +} + +type streamToolCall struct { + name string + input string +} + +type streamTurn struct { + sink toolv1.TurnSink + sessionID string + err error + streamErrorMessage string + assistant strings.Builder + tools map[string]streamToolCall +} + +func newStreamTurn(sessionID string, sink toolv1.TurnSink) *streamTurn { + return &streamTurn{ + sink: sink, + sessionID: sessionID, + tools: make(map[string]streamToolCall), + } +} + +func (turn *streamTurn) consume(line []byte) { + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 || trimmed[0] != '{' { + return + } + + base := streamEvent{} + if err := json.Unmarshal(trimmed, &base); err != nil { + turn.recordError(fmt.Errorf("decode gemini stream event: %w", err)) + return + } + + var err error + switch base.Type { + case streamEventInit: + err = turn.handleInit(trimmed) + case streamEventMessage: + err = turn.handleMessage(trimmed) + case streamEventToolUse: + err = turn.handleToolUse(trimmed) + case streamEventToolResult: + err = turn.handleToolResult(trimmed) + case streamEventError: + err = turn.handleError(trimmed) + case streamEventResult: + err = turn.handleResult(trimmed) + default: + return + } + turn.recordError(err) +} + +func (turn *streamTurn) handleInit(line []byte) error { + event := streamInitEvent{} + if err := json.Unmarshal(line, &event); err != nil { + return fmt.Errorf("decode gemini init event: %w", err) + } + if event.SessionID == "" || event.Model == "" { + return errors.New("invalid gemini init event: session id and model are required") + } + + turn.sessionID = event.SessionID + turn.sink.Session(event.SessionID) + return nil +} + +func (turn *streamTurn) handleMessage(line []byte) error { + event := streamMessageEvent{} + if err := json.Unmarshal(line, &event); err != nil { + return fmt.Errorf("decode gemini message event: %w", err) + } + if event.Content == nil { + return errors.New("invalid gemini message event: content is required") + } + + switch event.Role { + case streamRoleUser: + return nil + case streamRoleAssistant: + if event.Delta == nil || !*event.Delta { + return errors.New("invalid gemini message event: assistant message must be a delta") + } + turn.assistant.WriteString(*event.Content) + return nil + default: + return fmt.Errorf("invalid gemini message event: unsupported role %q", event.Role) + } +} + +func (turn *streamTurn) handleToolUse(line []byte) error { + event := streamToolUseEvent{} + if err := json.Unmarshal(line, &event); err != nil { + return fmt.Errorf("decode gemini tool use event: %w", err) + } + if event.ToolID == "" || event.ToolName == "" { + return errors.New("invalid gemini tool use event: tool id and name are required") + } + if _, exists := turn.tools[event.ToolID]; exists { + return fmt.Errorf("invalid gemini tool use event: tool %q was started twice", event.ToolID) + } + input, err := event.input() + if err != nil { + return err + } + + turn.flushAssistant(nil) + turn.tools[event.ToolID] = streamToolCall{name: event.ToolName, input: input} + state := console.AgentMessageToolStateRunning + output := toolv1.RunningToolOutput + turn.sink.Message(&console.AgentMessageAttributes{ + Role: console.AiRoleAssistant, + Message: "Called tool", + Metadata: &console.AgentMessageMetadataAttributes{ + Tool: &console.AgentMessageToolAttributes{ + Name: &event.ToolName, State: &state, Input: &input, Output: &output, + }, + }, + }, event.ToolID) + return nil +} + +func (turn *streamTurn) handleToolResult(line []byte) error { + event := streamToolResultEvent{} + if err := json.Unmarshal(line, &event); err != nil { + return fmt.Errorf("decode gemini tool result event: %w", err) + } + if event.ToolID == "" { + return errors.New("invalid gemini tool result event: tool id is required") + } + call, exists := turn.tools[event.ToolID] + if !exists { + return fmt.Errorf("invalid gemini tool result event: tool %q was not started", event.ToolID) + } + + state := console.AgentMessageToolStateCompleted + switch event.Status { + case streamStatusSuccess: + case streamStatusError: + state = console.AgentMessageToolStateError + if event.Error == nil || event.Error.Message == "" { + return fmt.Errorf("invalid gemini tool result event: tool %q error is required", event.ToolID) + } + default: + return fmt.Errorf("invalid gemini tool result event: unsupported status %q", event.Status) + } + + output := "" + if event.Output != nil { + output = *event.Output + } else if event.Error != nil { + output = event.Error.Message + } + turn.sink.Message(&console.AgentMessageAttributes{ + Role: console.AiRoleAssistant, + Message: "Called tool", + Metadata: &console.AgentMessageMetadataAttributes{ + Tool: &console.AgentMessageToolAttributes{ + Name: &call.name, State: &state, Input: &call.input, Output: &output, + }, + }, + }, event.ToolID) + delete(turn.tools, event.ToolID) + return nil +} + +func (turn *streamTurn) handleError(line []byte) error { + event := streamErrorEvent{} + if err := json.Unmarshal(line, &event); err != nil { + return fmt.Errorf("decode gemini error event: %w", err) + } + if event.Message == "" { + return errors.New("invalid gemini error event: message is required") + } + + prefix := "" + switch event.Severity { + case streamSeverityWarning: + prefix = "Warning" + case streamSeverityError: + prefix = "Error" + turn.streamErrorMessage = event.Message + default: + return fmt.Errorf("invalid gemini error event: unsupported severity %q", event.Severity) + } + turn.sink.Message(&console.AgentMessageAttributes{ + Role: console.AiRoleSystem, Message: fmt.Sprintf("%s: %s", prefix, event.Message), + }, "") + return nil +} + +func (turn *streamTurn) handleResult(line []byte) error { + event := streamResultEvent{} + if err := json.Unmarshal(line, &event); err != nil { + return fmt.Errorf("decode gemini result event: %w", err) + } + if event.Status != streamStatusSuccess && event.Status != streamStatusError { + return fmt.Errorf("invalid gemini result event: unsupported status %q", event.Status) + } + if event.Error != nil && event.Error.Message == "" { + return errors.New("invalid gemini result event: error message is required") + } + if event.Stats == nil { + return errors.New("invalid gemini result event: stats are required") + } + if err := event.Stats.validate(); err != nil { + return err + } + + turn.sink.Usage(event.Stats.usage()) + turn.flushAssistant(event.Stats) + if event.Status == streamStatusSuccess { + return nil + } + if event.Error != nil { + return fmt.Errorf("gemini result error: %s", event.Error.Message) + } + if turn.streamErrorMessage != "" { + return fmt.Errorf("gemini result error: %s", turn.streamErrorMessage) + } + return errors.New("gemini result status is error") +} + +func (turn *streamTurn) flushAssistant(stats *streamStats) { + message := turn.assistant.String() + if message == "" { + return + } + turn.assistant.Reset() + + attributes := &console.AgentMessageAttributes{Role: console.AiRoleAssistant, Message: message} + if stats != nil { + input := float64(max(stats.InputTokens, 0)) + output := float64(max(stats.OutputTokens, 0)) + attributes.Cost = &console.AgentMessageCostAttributes{ + Tokens: &console.AgentMessageTokensAttributes{Input: &input, Output: &output}, + } + } + turn.sink.Message(attributes, "") +} + +func (turn *streamTurn) recordError(err error) { + if err != nil { + turn.err = errors.Join(turn.err, err) + } +} + +func (event streamToolUseEvent) input() (string, error) { + parameters := map[string]json.RawMessage{} + if len(event.Parameters) == 0 || json.Unmarshal(event.Parameters, ¶meters) != nil || parameters == nil { + return "", errors.New("invalid gemini tool use event: parameters must be an object") + } + + buffer := new(bytes.Buffer) + if err := json.Compact(buffer, event.Parameters); err != nil { + return "", fmt.Errorf("compact gemini tool input: %w", err) + } + return buffer.String(), nil +} + +func (stats streamStats) validate() error { + if stats.TotalTokens < 0 || stats.InputTokens < 0 || stats.OutputTokens < 0 || + stats.CachedTokens < 0 || stats.DurationMS < 0 || stats.ToolCalls < 0 { + return errors.New("invalid gemini result event: stats cannot be negative") + } + return nil +} + +func (stats streamStats) usage() usage.Record { + total := max(stats.TotalTokens, stats.InputTokens+stats.OutputTokens) + return usage.Record{ + InputTokens: stats.InputTokens, + OutputTokens: stats.OutputTokens, + TotalTokens: total, + CachedTokens: stats.CachedTokens, + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream_test.go new file mode 100644 index 0000000000..171d7aaaab --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream_test.go @@ -0,0 +1,154 @@ +package gemini + +import ( + "strings" + "testing" +) + +func TestStreamTurnRejectsInvalidKnownEvents(t *testing.T) { + tests := []struct { + name string + line string + want string + }{ + { + name: "init without session", + line: `{"type":"init","model":"gemini"}`, + want: "session id and model are required", + }, + { + name: "assistant message without delta", + line: `{"type":"message","role":"assistant","content":"hello"}`, + want: "assistant message must be a delta", + }, + { + name: "tool use without parameters", + line: `{"type":"tool_use","tool_name":"read_file","tool_id":"call"}`, + want: "parameters must be an object", + }, + { + name: "uncorrelated tool result", + line: `{"type":"tool_result","tool_id":"call","status":"success","output":"ok"}`, + want: "was not started", + }, + { + name: "unknown error severity", + line: `{"type":"error","severity":"fatal","message":"boom"}`, + want: "unsupported severity", + }, + { + name: "result without stats", + line: `{"type":"result","status":"success"}`, + want: "stats are required", + }, + { + name: "negative result stats", + line: `{"type":"result","status":"success","stats":{"total_tokens":-1}}`, + want: "stats cannot be negative", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + turn := newStreamTurn("", newTestSink()) + turn.consume([]byte(test.line)) + if turn.err == nil || !strings.Contains(turn.err.Error(), test.want) { + t.Fatalf("stream error = %v, want %q", turn.err, test.want) + } + }) + } +} + +func TestStreamTurnIgnoresNoiseUnknownEventsAndUserMessages(t *testing.T) { + sink := newTestSink() + turn := newStreamTurn("", sink) + for _, line := range []string{ + "[DEBUG] stderr noise", + `{"type":"future_event","value":"ignored"}`, + `{"type":"message","role":"user","content":"runtime already emitted this"}`, + } { + turn.consume([]byte(line)) + } + + if turn.err != nil { + t.Fatalf("stream error = %v", turn.err) + } + if len(sink.messages) != 0 { + t.Fatalf("messages = %#v, want none", sink.messages) + } +} + +func TestStreamTurnStateIsPerTurn(t *testing.T) { + firstSink := newTestSink() + first := newStreamTurn("", firstSink) + first.consume([]byte(`{"type":"message","role":"assistant","content":"first","delta":true}`)) + + secondSink := newTestSink() + second := newStreamTurn("", secondSink) + second.consume([]byte(`{"type":"message","role":"assistant","content":"second","delta":true}`)) + second.consume([]byte(`{"type":"result","status":"success","stats":{}}`)) + first.consume([]byte(`{"type":"result","status":"success","stats":{}}`)) + + if len(firstSink.messages) != 1 || firstSink.messages[0].attributes.Message != "first" { + t.Fatalf("first turn messages = %#v", firstSink.messages) + } + if len(secondSink.messages) != 1 || secondSink.messages[0].attributes.Message != "second" { + t.Fatalf("second turn messages = %#v", secondSink.messages) + } +} + +func TestStreamTurnResultErrorFallback(t *testing.T) { + tests := []struct { + name string + events []string + want string + doesNotWant string + }{ + { + name: "latest severity error", + events: []string{ + `{"type":"error","severity":"error","message":"first error"}`, + `{"type":"error","severity":"warning","message":"later warning"}`, + `{"type":"error","severity":"error","message":"latest error"}`, + }, + want: "latest error", + }, + { + name: "warning is not a fatal fallback", + events: []string{ + `{"type":"error","severity":"warning","message":"warning only"}`, + }, + want: "gemini result status is error", + doesNotWant: "warning only", + }, + { + name: "explicit result error takes precedence", + events: []string{ + `{"type":"error","severity":"error","message":"stream error"}`, + }, + want: "explicit result error", + doesNotWant: "stream error", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + turn := newStreamTurn("", newTestSink()) + for _, event := range test.events { + turn.consume([]byte(event)) + } + result := `{"type":"result","status":"error","stats":{}}` + if test.name == "explicit result error takes precedence" { + result = `{"type":"result","status":"error","error":{"message":"explicit result error"},"stats":{}}` + } + turn.consume([]byte(result)) + + if turn.err == nil || !strings.Contains(turn.err.Error(), test.want) { + t.Fatalf("stream error = %v, want %q", turn.err, test.want) + } + if test.doesNotWant != "" && strings.Contains(turn.err.Error(), test.doesNotWant) { + t.Fatalf("stream error = %v, does not want %q", turn.err, test.doesNotWant) + } + }) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/invalid_stream.jsonl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/invalid_stream.jsonl new file mode 100644 index 0000000000..17ea379f7f --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/invalid_stream.jsonl @@ -0,0 +1,3 @@ +{"type":"init","session_id":"session-invalid-stream","model":"gemini-custom"} +{"type":"error","severity":"error","message":"response contained only thought content"} +{"type":"result","status":"error","stats":{"total_tokens":5,"input_tokens":5,"output_tokens":0,"duration_ms":200,"tool_calls":0}} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/malformed.jsonl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/malformed.jsonl new file mode 100644 index 0000000000..1e14312d51 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/malformed.jsonl @@ -0,0 +1,4 @@ +{not valid json +{"type":"init","session_id":"session-after-malformed","model":"gemini-custom"} +{"type":"message","role":"assistant","content":"processed after malformed event","delta":true} +{"type":"result","status":"success","stats":{"total_tokens":3,"input_tokens":2,"output_tokens":1}} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/result_error.jsonl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/result_error.jsonl new file mode 100644 index 0000000000..2a5f6245f1 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/result_error.jsonl @@ -0,0 +1,3 @@ +{"type":"init","session_id":"session-error","model":"gemini-custom"} +{"type":"message","role":"assistant","content":"partial response","delta":true} +{"type":"result","status":"error","error":{"type":"FatalToolExecutionError","message":"permission denied"},"stats":{"total_tokens":5,"input_tokens":4,"output_tokens":1}} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/success.jsonl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/success.jsonl new file mode 100644 index 0000000000..dc296edfa7 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/success.jsonl @@ -0,0 +1,12 @@ +{"type":"init","timestamp":"2026-09-10T12:00:00Z","session_id":"session-success","model":"gemini-custom"} +{"type":"message","timestamp":"2026-09-10T12:00:01Z","role":"user","content":"implement feature with spaces"} +{"type":"message","timestamp":"2026-09-10T12:00:02Z","role":"assistant","content":"I will ","delta":true} +{"type":"message","timestamp":"2026-09-10T12:00:03Z","role":"assistant","content":"inspect.","delta":true} +{"type":"tool_use","timestamp":"2026-09-10T12:00:04Z","tool_name":"read_file","tool_id":"call-1","parameters":{"path":"README.md"}} +{"type":"tool_result","timestamp":"2026-09-10T12:00:05Z","tool_id":"call-1","status":"success","output":"line one\nline two "} +{"type":"tool_use","timestamp":"2026-09-10T12:00:06Z","tool_name":"run_shell","tool_id":"call-2","parameters":{"command":"false"}} +{"type":"tool_result","timestamp":"2026-09-10T12:00:07Z","tool_id":"call-2","status":"error","error":{"type":"TOOL_EXECUTION_ERROR","message":"command failed"}} +{"type":"error","timestamp":"2026-09-10T12:00:08Z","severity":"warning","message":"approaching turn limit"} +{"type":"future_event","timestamp":"2026-09-10T12:00:09Z","value":"ignored"} +{"type":"message","timestamp":"2026-09-10T12:00:10Z","role":"assistant","content":"Done.","delta":true} +{"type":"result","timestamp":"2026-09-10T12:00:11Z","status":"success","stats":{"total_tokens":25,"input_tokens":20,"output_tokens":10,"cached":4,"duration_ms":900,"tool_calls":2}} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go index f36828980b..3850fb2a14 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go @@ -4,34 +4,31 @@ import ( "context" "errors" "fmt" - "math" "path/filepath" - "strconv" - - acpsdk "github.com/coder/acp-go-sdk" console "github.com/pluralsh/console/go/client" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" ) const ( geminiBinary = "gemini" - geminiACPFlag = "--acp" + geminiOutputFormatFlag = "--output-format" + geminiStreamJSONFormat = "stream-json" geminiModelFlag = "--model" - geminiApprovalModeFlag = "--approval-mode=yolo" + geminiApprovalModeFlag = "--approval-mode" + geminiApprovalModeYolo = "yolo" + geminiResumeFlag = "--resume" + geminiPromptFlag = "--prompt" geminiAPIKeyEnv = "GEMINI_API_KEY" geminiAPIBaseURLEnv = "GEMINI_API_BASE_URL" geminiTrustWorkspaceEnv = "GEMINI_CLI_TRUST_WORKSPACE" geminiHomeEnv = "GEMINI_CLI_HOME" geminiTrustWorkspace = "true" - geminiAPIKeyAuthMethod = "gemini-api-key" ) type Transport struct { agent *Agent - engine *acp.Engine workDir string } @@ -42,46 +39,24 @@ func NewTransport(agent *Agent) (*Transport, error) { return nil, errors.New("gemini agent is not set") } config, err := agent.configWithGemini() - if err != nil { return nil, err } workDir, err := filepath.Abs(config.WorkDir) - if err != nil { return nil, fmt.Errorf("resolve gemini work directory: %w", err) } - result := &Transport{ - agent: agent, - workDir: workDir, - } - - engine := acp.NewEngine( - acp.WithAuthenticationMethod(geminiAPIKeyAuthMethod), - acp.WithUsageResolver(result.toUsage), - // Gemini CLI v0.59.0 can emit a tool_call_update before its - // corresponding start event. Revisit on future upgrades. - acp.WithToolCallUpdateRecovery(), - // Gemini CLI v0.59.0 puts tool input in start content and omits - // rawInput. Revisit on future upgrades. - acp.WithToolCallStartContentAsInputWithoutRawInput(), - ) - - result.engine = engine - return result, nil + return &Transport{agent: agent, workDir: workDir}, nil } func (*Transport) Kind() toolv1.TransportKind { - return toolv1.TransportKindACP + return toolv1.TransportKindRaw } func (transport *Transport) Capabilities() toolv1.TransportCapabilities { return toolv1.TransportCapabilities{ - // Gemini CLI v0.59.0 session/load can corrupt same-minute saved sessions - // and fail with "No previous sessions found". Revisit on future upgrades. - // Ref: https://github.com/google-gemini/gemini-cli/issues/28693 - SessionResume: false, + SessionResume: true, ToolCallOutputStreaming: false, UsageReporting: true, FileSystemRead: true, @@ -89,49 +64,10 @@ func (transport *Transport) Capabilities() toolv1.TransportCapabilities { } } -func (transport *Transport) toUsage(response acpsdk.PromptResponse) *acpsdk.Usage { - if response.Usage != nil { - return response.Usage - } - quota, ok := response.Meta["quota"].(map[string]any) - if !ok { - return nil - } - - tokenCount, ok := quota["token_count"].(map[string]any) - if !ok { - return nil - } - - input, ok := transport.toTokenCount(tokenCount["input_tokens"]) - if !ok { - return nil - } - - output, ok := transport.toTokenCount(tokenCount["output_tokens"]) - if !ok || input > int(^uint(0)>>1)-output { - return nil - } - - return &acpsdk.Usage{InputTokens: input, OutputTokens: output, TotalTokens: input + output} -} - -func (transport *Transport) toTokenCount(value any) (int, bool) { - tokens, ok := value.(float64) - limit := math.Ldexp(1, strconv.IntSize-1) - - if !ok || math.IsNaN(tokens) || math.IsInf(tokens, 0) || tokens < 0 || tokens >= limit || math.Trunc(tokens) != tokens { - return 0, false - } - - return int(tokens), true -} - func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest, sink toolv1.TurnSink) (toolv1.TurnResult, error) { if ctx == nil { ctx = context.Background() } - if err := ctx.Err(); err != nil { return toolv1.TurnResult{SessionID: request.SessionID}, err } @@ -139,45 +75,50 @@ func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest return toolv1.TurnResult{SessionID: request.SessionID}, err } - process, err := transport.launch(request.Options, request.Settings.Mode, request.Settings.Model.Name) + executable, err := transport.executable(request) if err != nil { return toolv1.TurnResult{SessionID: request.SessionID}, err } - result, err := transport.engine.Turn(ctx, process, transport.acpRequest(request), sink) - return toolv1.TurnResult{SessionID: result.SessionID}, err + turn := newStreamTurn(request.SessionID, sink) + runErr := executable.RunStream(ctx, turn.consume) + return toolv1.TurnResult{SessionID: turn.sessionID}, errors.Join(runErr, turn.err) } -func (transport *Transport) acpRequest(request toolv1.TurnRequest) acp.Request { - return acp.Request{ - Cwd: transport.workDir, - Prompt: request.Prompt, - Settings: acp.SessionSettings{ModelID: request.Settings.Model.Name}, - FileSystemWrite: transport.Capabilities().FileSystemWrite, - } -} - -func (transport *Transport) launch(options []exec.Option, mode console.AgentRunMode, model string) (*exec.StdioProcess, error) { +func (transport *Transport) executable(request toolv1.TurnRequest) (exec.Executable, error) { config := transport.agent.config gemini, err := transport.agent.runConfig(config.Run) if err != nil { return nil, err } - args := []string{geminiACPFlag, geminiModelFlag, model} - if mode == console.AgentRunModeWrite { - args = append(args, geminiApprovalModeFlag) - } - - launchOptions := append([]exec.Option(nil), options...) - launchOptions = append(launchOptions, - exec.WithArgs(args), + launchOptions := append([]exec.Option(nil), request.Options...) + launchOptions = append( + launchOptions, + exec.WithArgs(transport.args(request)), exec.WithEnv(transport.agent.env(config)), exec.WithDir(transport.workDir), exec.WithTimeout(gemini.Timeout), ) - return exec.StartWithStdio(context.Background(), geminiBinary, launchOptions...) + return exec.NewExecutable(geminiBinary, launchOptions...), nil +} + +func (transport *Transport) args(request toolv1.TurnRequest) []string { + model := transport.agent.resolveModel(request.Settings.Model.Name) + args := []string{ + geminiOutputFormatFlag, + geminiStreamJSONFormat, + geminiModelFlag, + model, + } + if request.Settings.Mode == console.AgentRunModeWrite { + args = append(args, geminiApprovalModeFlag, geminiApprovalModeYolo) + } + if request.Kind != toolv1.TurnKindInitial && request.SessionID != "" { + args = append(args, geminiResumeFlag, request.SessionID) + } + return append(args, geminiPromptFlag, request.Prompt) } func (agent *Agent) env(config toolv1.Config) []string { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go index d7cf3d17dd..c33db45298 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go @@ -3,146 +3,371 @@ package gemini import ( "context" "errors" - "io" - "math" "os" "path/filepath" - "strconv" + "reflect" "strings" "sync/atomic" "testing" - acpsdk "github.com/coder/acp-go-sdk" console "github.com/pluralsh/console/go/client" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" stackv1 "github.com/pluralsh/console/go/deployment-operator/pkg/harness/stackrun/v1" ) -func TestTransportLaunchUsesACPAndGeminiEnvironment(t *testing.T) { +func TestTransportKindAndCapabilities(t *testing.T) { + transport := newTestTransport(t, console.AgentRunModeWrite, "gemini-custom", nil) + capabilities := transport.Capabilities() + if transport.Kind() != toolv1.TransportKindRaw { + t.Fatalf("Kind() = %q, want raw", transport.Kind()) + } + if !capabilities.SessionResume || capabilities.ToolCallOutputStreaming || !capabilities.UsageReporting || + !capabilities.FileSystemRead || !capabilities.FileSystemWrite { + t.Fatalf("Capabilities() = %#v", capabilities) + } +} + +func TestTransportArgs(t *testing.T) { + transport := newTestTransport(t, console.AgentRunModeAnalyze, "", nil) + tests := []struct { + name string + request toolv1.TurnRequest + want []string + }{ + { + name: "initial analyze turn starts a new session", + request: toolv1.TurnRequest{ + Kind: toolv1.TurnKindInitial, + Prompt: "analyze repository", + SessionID: "old-session", + Settings: toolv1.Settings{Mode: console.AgentRunModeAnalyze}, + }, + want: []string{"--output-format", "stream-json", "--model", defaultModel, "--prompt", "analyze repository"}, + }, + { + name: "followup review turn resumes", + request: toolv1.TurnRequest{ + Kind: toolv1.TurnKindFollowup, + Prompt: "review again", + SessionID: "session-1", + Settings: toolv1.Settings{ + Mode: console.AgentRunModeReview, + Model: toolv1.ModelSelection{Name: "gemini-review"}, + }, + }, + want: []string{"--output-format", "stream-json", "--model", "gemini-review", "--resume", "session-1", "--prompt", "review again"}, + }, + { + name: "babysit write turn enables yolo and resumes", + request: toolv1.TurnRequest{ + Kind: toolv1.TurnKindBabysit, + Prompt: "check pull request", + SessionID: "session-2", + Settings: toolv1.Settings{ + Mode: console.AgentRunModeWrite, + Model: toolv1.ModelSelection{Name: "gemini-write"}, + }, + }, + want: []string{ + "--output-format", "stream-json", "--model", "gemini-write", + "--approval-mode", "yolo", "--resume", "session-2", "--prompt", "check pull request", + }, + }, + { + name: "followup without a session starts fresh", + request: toolv1.TurnRequest{ + Kind: toolv1.TurnKindFollowup, + Prompt: "try again", + Settings: toolv1.Settings{Mode: console.AgentRunModeWrite}, + }, + want: []string{ + "--output-format", "stream-json", "--model", defaultModel, + "--approval-mode", "yolo", "--prompt", "try again", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := transport.args(test.request); !reflect.DeepEqual(got, test.want) { + t.Fatalf("args() = %q, want %q", got, test.want) + } + }) + } +} + +func TestTransportTurnParsesStreamAndPreservesExecutionOptions(t *testing.T) { binDir := t.TempDir() - output := filepath.Join(t.TempDir(), "launch") writeGeminiBinary(t, binDir) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("GEMINI_TEST_OUTPUT", output) + t.Setenv("GEMINI_TEST_FIXTURE", fixturePath(t, "success.jsonl")) + launchOutput := filepath.Join(t.TempDir(), "launch") + t.Setenv("GEMINI_TEST_OUTPUT", launchOutput) + endpoint := "https://api.example" - config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: geminiTestRun(console.AgentRunModeWrite, "gemini-custom", &endpoint)} - transport, err := NewTransport(NewAgent(config)) - if err != nil { - t.Fatal(err) - } + transport := newTestTransport(t, console.AgentRunModeWrite, "gemini-custom", &endpoint) + sink := newTestSink() var preStarts, postStarts atomic.Int32 - process, err := transport.launch([]exec.Option{ - exec.WithHook(stackv1.LifecyclePreStart, func() error { - preStarts.Add(1) - return nil - }), - exec.WithHook(stackv1.LifecyclePostStart, func() error { - postStarts.Add(1) - return nil - }), - }, console.AgentRunModeWrite, "gemini-custom") + result, err := transport.Turn(context.Background(), toolv1.TurnRequest{ + Kind: toolv1.TurnKindInitial, + Prompt: "implement feature with spaces", + Settings: toolv1.Settings{ + Mode: console.AgentRunModeWrite, + Model: toolv1.ModelSelection{Name: "gemini-custom"}, + }, + Options: []exec.Option{ + exec.WithHook(stackv1.LifecyclePreStart, func() error { + preStarts.Add(1) + return nil + }), + exec.WithHook(stackv1.LifecyclePostStart, func() error { + postStarts.Add(1) + return nil + }), + }, + }, sink) if err != nil { - t.Fatal(err) + t.Fatalf("Turn() error = %v", err) } - go io.Copy(io.Discard, process.Stdout) - go io.Copy(io.Discard, process.Stderr) - if err := process.Wait(); err != nil { - t.Fatal(err) + if result.SessionID != "session-success" { + t.Fatalf("Turn() session = %q", result.SessionID) } if preStarts.Load() != 1 || postStarts.Load() != 1 { - t.Fatalf("hooks = %d/%d", preStarts.Load(), postStarts.Load()) + t.Fatalf("lifecycle hooks = %d/%d, want 1/1", preStarts.Load(), postStarts.Load()) } - content, err := os.ReadFile(output) + + launch, err := os.ReadFile(launchOutput) if err != nil { t.Fatal(err) } - for _, want := range []string{"args=--acp --model gemini-custom --approval-mode=yolo", "key=api-key", "endpoint=https://api.example", "trust=true", "home=" + config.WorkDir, "cwd=" + transport.workDir} { - if !strings.Contains(string(content), want) { - t.Fatalf("launch missing %q: %s", want, content) + wantLaunchLines := []string{ + "arg=--output-format", "arg=stream-json", "arg=--model", "arg=gemini-custom", + "arg=--approval-mode", "arg=yolo", "arg=--prompt", "arg=implement feature with spaces", + "key=api-key", "endpoint=https://api.example", "trust=true", + "home=" + transport.agent.config.WorkDir, "cwd=" + transport.workDir, + } + for _, want := range wantLaunchLines { + if !strings.Contains(string(launch), want+"\n") { + t.Fatalf("launch output missing %q:\n%s", want, launch) } } + + assertSuccessfulStream(t, sink) } -func TestTransportCapabilitiesAndPreCancelledTurn(t *testing.T) { - config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: geminiTestRun(console.AgentRunModeWrite, "", nil)} - transport, err := NewTransport(NewAgent(config)) - if err != nil { - t.Fatal(err) +func TestTransportTurnReportsStreamErrorsAfterDrain(t *testing.T) { + binDir := t.TempDir() + writeGeminiBinary(t, binDir) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + tests := []struct { + name string + fixture string + wantError string + wantSessionID string + wantMessage string + }{ + { + name: "malformed event", + fixture: "malformed.jsonl", + wantError: "decode gemini stream event", + wantSessionID: "session-after-malformed", + wantMessage: "processed after malformed event", + }, + { + name: "error result", + fixture: "result_error.jsonl", + wantError: "permission denied", + wantSessionID: "session-error", + wantMessage: "partial response", + }, + { + name: "invalid stream result uses preceding error event", + fixture: "invalid_stream.jsonl", + wantError: "response contained only thought content", + wantSessionID: "session-invalid-stream", + wantMessage: "Error: response contained only thought content", + }, } - if transport.Kind() != toolv1.TransportKindACP || transport.Capabilities().SessionResume || transport.Capabilities().ToolCallOutputStreaming || - !transport.Capabilities().FileSystemWrite { - t.Fatalf("transport = %#v", transport.Capabilities()) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("GEMINI_TEST_FIXTURE", fixturePath(t, test.fixture)) + transport := newTestTransport(t, console.AgentRunModeAnalyze, "gemini-custom", nil) + sink := newTestSink() + var postStarts atomic.Int32 + result, err := transport.Turn(context.Background(), toolv1.TurnRequest{ + Kind: toolv1.TurnKindInitial, + Prompt: "test errors", + Settings: toolv1.Settings{ + Mode: console.AgentRunModeAnalyze, + Model: toolv1.ModelSelection{Name: "gemini-custom"}, + }, + Options: []exec.Option{exec.WithHook(stackv1.LifecyclePostStart, func() error { + postStarts.Add(1) + return nil + })}, + }, sink) + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("Turn() error = %v, want %q", err, test.wantError) + } + if result.SessionID != test.wantSessionID { + t.Fatalf("Turn() session = %q, want %q", result.SessionID, test.wantSessionID) + } + if postStarts.Load() != 1 { + t.Fatalf("post-start hook calls = %d, want 1", postStarts.Load()) + } + if !sink.hasMessage(test.wantMessage) { + t.Fatalf("messages = %#v, want %q after stream error", sink.messages, test.wantMessage) + } + }) } +} + +func TestTransportTurnRejectsPreCancelledContext(t *testing.T) { + transport := newTestTransport(t, console.AgentRunModeAnalyze, "", nil) ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err = transport.Turn(ctx, toolv1.TurnRequest{}, nil) + + result, err := transport.Turn(ctx, toolv1.TurnRequest{SessionID: "existing"}, nil) if !errors.Is(err, context.Canceled) { - t.Fatalf("Turn() error = %v", err) + t.Fatalf("Turn() error = %v, want context canceled", err) + } + if result.SessionID != "existing" { + t.Fatalf("Turn() session = %q", result.SessionID) } } -func TestTransportACPRequestStartsFreshSession(t *testing.T) { - config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: geminiTestRun(console.AgentRunModeWrite, "gemini-custom", nil)} - transport, err := NewTransport(NewAgent(config)) - if err != nil { - t.Fatal(err) +func assertSuccessfulStream(t *testing.T, sink *testSink) { + t.Helper() + if !reflect.DeepEqual(sink.sessions, []string{"session-success"}) { + t.Fatalf("sessions = %q", sink.sessions) } - - request := transport.acpRequest(toolv1.TurnRequest{ - Prompt: "follow up", - SessionID: "prior-session", - Settings: toolv1.Settings{ - Model: toolv1.ModelSelection{Name: "gemini-custom"}, - }, - }) - if request.SessionID != "" { - t.Fatalf("ACP session ID = %q, want empty", request.SessionID) + if len(sink.messages) != 7 { + t.Fatalf("messages = %#v", sink.messages) + } + if sink.messages[0].attributes.Message != "I will inspect." || sink.messages[0].callID != "" { + t.Fatalf("first assistant message = %#v", sink.messages[0]) } - if request.Cwd != transport.workDir || request.Prompt != "follow up" || request.Settings.ModelID != "gemini-custom" || !request.FileSystemWrite { - t.Fatalf("ACP request = %#v", request) + assertToolMessage(t, sink.messages[1], "call-1", "read_file", `{"path":"README.md"}`, toolv1.RunningToolOutput, console.AgentMessageToolStateRunning) + assertToolMessage(t, sink.messages[2], "call-1", "read_file", `{"path":"README.md"}`, "line one\nline two ", console.AgentMessageToolStateCompleted) + assertToolMessage(t, sink.messages[3], "call-2", "run_shell", `{"command":"false"}`, toolv1.RunningToolOutput, console.AgentMessageToolStateRunning) + assertToolMessage(t, sink.messages[4], "call-2", "run_shell", `{"command":"false"}`, "command failed", console.AgentMessageToolStateError) + if sink.messages[5].attributes.Role != console.AiRoleSystem || sink.messages[5].attributes.Message != "Warning: approaching turn limit" { + t.Fatalf("warning message = %#v", sink.messages[5]) + } + final := sink.messages[6].attributes + if final.Role != console.AiRoleAssistant || final.Message != "Done." || final.Cost == nil || final.Cost.Tokens == nil || + final.Cost.Tokens.Input == nil || *final.Cost.Tokens.Input != 20 || + final.Cost.Tokens.Output == nil || *final.Cost.Tokens.Output != 10 { + t.Fatalf("final assistant message = %#v", final) + } + if !reflect.DeepEqual(sink.usages, []usage.Record{{ + InputTokens: 20, OutputTokens: 10, TotalTokens: 30, CachedTokens: 4, + }}) { + t.Fatalf("usage = %#v", sink.usages) } } -func TestTransportToUsageReadsQuotaTokenCount(t *testing.T) { - transport := &Transport{} - usage := transport.toUsage(acpsdk.PromptResponse{Meta: map[string]any{ - "quota": map[string]any{"token_count": map[string]any{ - "input_tokens": float64(17), "output_tokens": float64(9), - }}, - }}) - if usage == nil || usage.InputTokens != 17 || usage.OutputTokens != 9 || usage.TotalTokens != 26 { - t.Fatalf("usage = %#v", usage) +func assertToolMessage( + t *testing.T, + message testSinkMessage, + callID, name, input, output string, + state console.AgentMessageToolState, +) { + t.Helper() + tool := message.attributes.Metadata + if message.callID != callID || tool == nil || tool.Tool == nil || tool.Tool.Name == nil || *tool.Tool.Name != name || + tool.Tool.Input == nil || *tool.Tool.Input != input || tool.Tool.Output == nil || *tool.Tool.Output != output || + tool.Tool.State == nil || *tool.Tool.State != state { + t.Fatalf("tool message = %#v", message) } - if usage := transport.toUsage(acpsdk.PromptResponse{Meta: map[string]any{"quota": map[string]any{"token_count": map[string]any{ - "input_tokens": float64(17.5), "output_tokens": float64(9), - }}}}); usage != nil { - t.Fatalf("usage = %#v, want nil", usage) +} + +func newTestTransport(t *testing.T, mode console.AgentRunMode, model string, endpoint *string) *Transport { + t.Helper() + config := toolv1.Config{ + WorkDir: t.TempDir(), + RepositoryDir: t.TempDir(), + Run: geminiTestRun(mode, model, endpoint), } - if _, ok := transport.toTokenCount(math.Ldexp(1, strconv.IntSize-1)); ok { - t.Fatal("toTokenCount() overflow was accepted") + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatal(err) } + return transport } -func TestTransportToUsagePrefersStandardUsage(t *testing.T) { - transport := &Transport{} - standard := &acpsdk.Usage{InputTokens: 8, OutputTokens: 3, TotalTokens: 11} - usage := transport.toUsage(acpsdk.PromptResponse{ - Usage: standard, - Meta: map[string]any{"quota": map[string]any{"token_count": map[string]any{ - "input_tokens": float64(17), "output_tokens": float64(9), - }}}, - }) - if usage != standard { - t.Fatalf("usage = %#v, want standard %#v", usage, standard) +func fixturePath(t *testing.T, name string) string { + t.Helper() + path, err := filepath.Abs(filepath.Join("testdata", name)) + if err != nil { + t.Fatal(err) } + return path } func writeGeminiBinary(t *testing.T, binDir string) { t.Helper() - script := "#!/bin/sh\n" + - "printf 'args=%s\\nkey=%s\\nendpoint=%s\\ntrust=%s\\nhome=%s\\ncwd=%s\\n' \"$*\" \"$GEMINI_API_KEY\" \"$GEMINI_API_BASE_URL\" \"$GEMINI_CLI_TRUST_WORKSPACE\" \"$GEMINI_CLI_HOME\" \"$PWD\" > \"$GEMINI_TEST_OUTPUT\"\n" + script := `#!/bin/sh +if [ -n "$GEMINI_TEST_OUTPUT" ]; then + : > "$GEMINI_TEST_OUTPUT" + for arg in "$@"; do + printf 'arg=%s\n' "$arg" >> "$GEMINI_TEST_OUTPUT" + done + printf 'key=%s\nendpoint=%s\ntrust=%s\nhome=%s\ncwd=%s\n' "$GEMINI_API_KEY" "$GEMINI_API_BASE_URL" "$GEMINI_CLI_TRUST_WORKSPACE" "$GEMINI_CLI_HOME" "$PWD" >> "$GEMINI_TEST_OUTPUT" +fi +printf '[DEBUG] ignored Gemini CLI stderr noise\n' >&2 +if [ -n "$GEMINI_TEST_FIXTURE" ]; then + while IFS= read -r line || [ -n "$line" ]; do + printf '%s\n' "$line" + done < "$GEMINI_TEST_FIXTURE" +fi +` if err := os.WriteFile(filepath.Join(binDir, geminiBinary), []byte(script), 0755); err != nil { t.Fatal(err) } } + +type testSinkMessage struct { + attributes *console.AgentMessageAttributes + callID string +} + +type testSink struct { + sessions []string + messages []testSinkMessage + outputs map[string]string + usages []usage.Record +} + +func newTestSink() *testSink { + return &testSink{outputs: make(map[string]string)} +} + +func (sink *testSink) Session(sessionID string) { + sink.sessions = append(sink.sessions, sessionID) +} + +func (sink *testSink) Message(attributes *console.AgentMessageAttributes, callID string) { + sink.messages = append(sink.messages, testSinkMessage{attributes: attributes, callID: callID}) +} + +func (sink *testSink) ToolCallOutput(callID, output string) { + sink.outputs[callID] = output +} + +func (sink *testSink) Usage(record usage.Record) { + sink.usages = append(sink.usages, record) +} + +func (sink *testSink) hasMessage(message string) bool { + for _, candidate := range sink.messages { + if candidate.attributes.Message == message { + return true + } + } + return false +} From 3f66744988f9d80871bc14581ea4b17ee460bb11 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 09:47:17 +0200 Subject: [PATCH 34/46] feat(gemini): exclude progress-only tools from settings - Added `UpdateTopicTool` to `excludeTools` in settings template. - Introduced test to validate exclusion of `UpdateTopicTool` for progress-only mode. --- .../tool/gemini/settings_test.go | 24 +++++++++++++++++++ .../gemini/templates/settings.json.gotmpl | 1 + 2 files changed, 25 insertions(+) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go index 8279f72965..cfd435af80 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go @@ -138,6 +138,30 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { } }) + t.Run("progress-only topic tool is excluded", func(t *testing.T) { + input := *baseInput + input.AgentRunMode = console.AgentRunModeWrite + + _, content, err := settings(&input) + if err != nil { + t.Fatalf("settings() failed: %v", err) + } + + var out struct { + ExcludeTools []string `json:"excludeTools"` + } + if err := json.Unmarshal([]byte(content), &out); err != nil { + t.Fatalf("generated content is not valid JSON: %v", err) + } + + for _, tool := range out.ExcludeTools { + if tool == "UpdateTopicTool" { + return + } + } + t.Errorf("excludeTools = %q, want UpdateTopicTool", out.ExcludeTools) + }) + t.Run("quotes model and repository directory", func(t *testing.T) { input := *baseInput input.Model = "gemini-3.1-\"flash\"" diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl index f6105d1056..7c2b2b571c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl @@ -33,6 +33,7 @@ "MemoryTool" ]{{ end }}, "excludeTools": [ + "UpdateTopicTool", "ShellTool(rm -rf)" ], "includeDirectories": [ From c206e43a9919b0fb8d56ce14eb3dcb2c5ac48446 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 11:12:52 +0200 Subject: [PATCH 35/46] **refactor(tool): decouple Pi runtime and enhance modularity** - Removed legacy `Pi` implementation, including runtime, tool, and test definitions. - Replaced with a streamlined, modular `Agent` structure for Pi runtime. - Introduced `runtime_config.go`: - Centralized runtime configuration handling and improved provider resolution logic. - Simplified agent initialization using consistent model and settings resolution. - Added granular methods for managing Pi's session, prompt, and skill paths: - Consolidated export logic for session directories in `artifacts`. - Improved type safety and eliminated redundant dependencies. --- .../dockerfiles/agent-harness/pi.Dockerfile | 12 +- .../agentrun-harness/tool/acp/engine_test.go | 3 + .../pkg/agentrun-harness/tool/acp/session.go | 1 + .../pkg/agentrun-harness/tool/gemini/agent.go | 10 +- .../tool/gemini/agent_config.go | 2 +- .../tool/gemini/agent_test.go | 25 ++ .../tool/gemini/runtime_config.go | 19 +- .../tool/gemini/runtime_config_test.go | 4 +- .../agentrun-harness/tool/gemini/transport.go | 25 +- .../tool/gemini/transport_test.go | 53 ++- .../tool/pi/acp_environment.go | 45 ++ .../tool/pi/acp_environment_test.go | 36 ++ .../pkg/agentrun-harness/tool/pi/agent.go | 221 +++++++++ .../agentrun-harness/tool/pi/agent_config.go | 112 +++++ .../tool/pi/agent_config_test.go | 98 ++++ .../agentrun-harness/tool/pi/agent_test.go | 115 +++++ .../pkg/agentrun-harness/tool/pi/pi.go | 419 ------------------ .../pkg/agentrun-harness/tool/pi/pi_test.go | 151 ------- .../pkg/agentrun-harness/tool/pi/pi_types.go | 107 ----- .../tool/pi/runtime_config.go | 160 +++++++ .../tool/pi/runtime_config_test.go | 61 +++ .../pkg/agentrun-harness/tool/pi/transport.go | 123 +++++ .../tool/pi/transport_test.go | 58 +++ .../pkg/agentrun-harness/tool/tool.go | 7 +- .../pkg/agentrun-harness/tool/tool_test.go | 16 + go/nexus/internal/middleware/auth.go | 30 +- go/nexus/internal/middleware/auth_test.go | 85 ++++ go/nexus/internal/router/gemini.go | 12 +- go/nexus/internal/router/gemini_test.go | 32 ++ 29 files changed, 1342 insertions(+), 700 deletions(-) create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/acp_environment.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/acp_environment_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/agent.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/agent_config.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/agent_config_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/agent_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/pi.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/pi_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/pi_types.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/runtime_config.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/runtime_config_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/transport.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/pi/transport_test.go create mode 100644 go/nexus/internal/router/gemini_test.go diff --git a/go/deployment-operator/dockerfiles/agent-harness/pi.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/pi.Dockerfile index 66df6d67c9..9c4c0e1ca2 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/pi.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/pi.Dockerfile @@ -1,6 +1,7 @@ ARG NODE_IMAGE_TAG=24 ARG NODE_IMAGE=node:${NODE_IMAGE_TAG}-slim ARG AGENT_VERSION=0.84.1 +ARG ACP_VERSION=0.0.33 ARG MCP_ADAPTER_VERSION=2.21.2 ARG AGENT_HARNESS_BASE_IMAGE_TAG=latest @@ -10,12 +11,16 @@ ARG AGENT_HARNESS_BASE_IMAGE=$AGENT_HARNESS_BASE_IMAGE_REPO:$AGENT_HARNESS_BASE_ FROM $NODE_IMAGE AS node ARG AGENT_VERSION +ARG ACP_VERSION ARG MCP_ADAPTER_VERSION USER root -RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent@${AGENT_VERSION} && \ +RUN npm install -g --ignore-scripts \ + @earendil-works/pi-coding-agent@${AGENT_VERSION} \ + pi-acp@${ACP_VERSION} && \ npm install --ignore-scripts --prefix /opt/pi-mcp-adapter pi-mcp-adapter@${MCP_ADAPTER_VERSION} && \ - pi --version + pi --version && \ + pi-acp --version FROM $AGENT_HARNESS_BASE_IMAGE AS final @@ -27,7 +32,8 @@ ENV NODE_PATH=/usr/local/lib/node_modules USER root RUN ln -s ../lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js /usr/local/bin/pi && \ - chown -R 65532:65532 /usr/local/bin/node /usr/local/lib/node_modules /opt/pi-mcp-adapter + ln -s ../lib/node_modules/pi-acp/dist/index.js /usr/local/bin/pi-acp && \ + chown -R 65532:65532 /usr/local/bin/node /usr/local/bin/pi /usr/local/bin/pi-acp /usr/local/lib/node_modules /opt/pi-mcp-adapter USER 65532:65532 # The base entrypoint execs /agent-harness as PID 1; verify that the active harness process remains alive. diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go index d0d5edb8af..a19420f2ff 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -442,6 +442,9 @@ func TestEngineTurnAdvertisesRequestedFilesystemWriteCapability(t *testing.T) { if !capabilities.ReadTextFile || capabilities.WriteTextFile != test.fileSystemWrite { t.Fatalf("filesystem capabilities = %#v", capabilities) } + if terminalOutput, ok := initializations[0].ClientCapabilities.Meta["terminal_output"].(bool); !ok || !terminalOutput { + t.Fatalf("terminal output capability = %#v", initializations[0].ClientCapabilities.Meta) + } }) } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go index ea72b48a23..6be6577a32 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go @@ -135,6 +135,7 @@ func (attempt *sessionAttempt) initialize() (acpsdk.InitializeResponse, error) { Version: "1", }, ClientCapabilities: acpsdk.ClientCapabilities{ + Meta: map[string]any{"terminal_output": true}, Fs: acpsdk.FileSystemCapabilities{ ReadTextFile: true, WriteTextFile: attempt.fileSystemWrite, diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go index 9670ec789c..4b6dc731d4 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go @@ -25,7 +25,9 @@ Gemini CLI compatibility: do not use command substitution forms such as $(), bac ) type Agent struct { - config toolv1.Config + config toolv1.Config + consoleURL string + consoleToken string } var _ toolv1.Agent = (*Agent)(nil) @@ -91,6 +93,12 @@ func (agent *Agent) Configure(ctx context.Context, request toolv1.ConfigureReque if err != nil { return err } + + agent.consoleURL = request.ConsoleURL + if request.ConsoleToken != "" { + agent.consoleToken = request.ConsoleToken + } + return agent.writeNativeConfig(config, request.Settings.Model.Name) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go index 9fcc20c134..29c28ccc6f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go @@ -17,7 +17,7 @@ func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error return err } if model == "" { - model = agent.resolveModel(gemini.Model) + model = agent.resolveModelForSettings(config, toolv1.Settings{Model: toolv1.ModelSelection{Name: gemini.Model}}) } input := &ConfigTemplateInput{ diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go index e6a2376232..5a5d5ed83c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go @@ -68,6 +68,31 @@ func TestAgentConfigureWritesSettings(t *testing.T) { } } +func TestAgentConfigureCapturesProxyCredentialsWithoutSettingsLeak(t *testing.T) { + workDir := t.TempDir() + run := geminiTestRun(console.AgentRunModeReview, "gemini-custom", nil) + run.Runtime.AiProxy = true + agent := NewAgent(toolv1.Config{WorkDir: workDir, RepositoryDir: "/repo", Run: run}) + if err := agent.Configure(context.Background(), toolv1.ConfigureRequest{ + Phase: toolv1.ConfigurePhaseInitial, + ConsoleURL: "https://console.example", + ConsoleToken: "console-token", + Settings: toolv1.Settings{Model: toolv1.ModelSelection{Name: "vertex/gemini-custom"}}, + }); err != nil { + t.Fatalf("Configure() error = %v", err) + } + if agent.consoleURL != "https://console.example" || agent.consoleToken != "console-token" { + t.Fatalf("proxy credentials were not captured: url=%q token=%q", agent.consoleURL, agent.consoleToken) + } + settings, err := os.ReadFile(filepath.Join(workDir, geminiHomeDir, SettingsFileName)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(settings), "console-token") { + t.Fatalf("native settings exposed Console token: %s", settings) + } +} + func TestAgentExportStagesChats(t *testing.T) { workDir := t.TempDir() chatDir := filepath.Join(workDir, geminiHomeDir, "tmp", "plural", geminiChatsDir) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go index 160a3f12cb..1111850c9f 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go @@ -5,6 +5,7 @@ import ( console "github.com/pluralsh/console/go/client" agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" ) @@ -22,15 +23,31 @@ func (agent *Agent) ResolveSettings(run *agentrunv1.AgentRun) (toolv1.Settings, if err != nil { return toolv1.Settings{}, err } + model := agent.resolveModel(gemini.Model) + if run.IsProxyEnabled() { + model = proxymodel.ProxyModel(console.AgentRuntimeTypeGemini, model) + } provider := console.AiProviderVertex return toolv1.Settings{ Mode: run.Mode, - Model: toolv1.ModelSelection{Provider: &provider, Name: agent.resolveModel(gemini.Model)}, + Model: toolv1.ModelSelection{Provider: &provider, Name: model}, Timeout: gemini.Timeout, Proxy: run.IsProxyEnabled(), }, nil } +func (agent *Agent) resolveModelForSettings(config toolv1.Config, settings toolv1.Settings) string { + model := settings.Model.Name + if model == "" { + model = config.Run.Runtime.Config.Gemini.Model + } + model = agent.resolveModel(model) + if config.Run.IsProxyEnabled() { + model = proxymodel.ProxyModel(console.AgentRuntimeTypeGemini, model) + } + return model +} + func (*Agent) validateMode(mode console.AgentRunMode) error { switch mode { case console.AgentRunModeAnalyze, console.AgentRunModeWrite, console.AgentRunModeReview: diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go index bb5b20f6ca..67471b8beb 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go @@ -35,8 +35,8 @@ func TestResolveSettingsPreservesExplicitModelAndProxy(t *testing.T) { if settings.Model.Provider == nil || *settings.Model.Provider != console.AiProviderVertex { t.Fatalf("provider = %v, want vertex", settings.Model.Provider) } - if settings.Model.Name != explicitModel { - t.Fatalf("model = %q, want %q", settings.Model.Name, explicitModel) + if settings.Model.Name != "vertex/"+explicitModel { + t.Fatalf("model = %q, want %q", settings.Model.Name, "vertex/"+explicitModel) } if !settings.Proxy { t.Fatalf("proxy = false, want true") diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go index 3850fb2a14..ab08268cd9 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "path/filepath" + "strings" console "github.com/pluralsh/console/go/client" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" @@ -22,6 +23,7 @@ const ( geminiPromptFlag = "--prompt" geminiAPIKeyEnv = "GEMINI_API_KEY" geminiAPIBaseURLEnv = "GEMINI_API_BASE_URL" + geminiGoogleBaseURLEnv = "GOOGLE_GEMINI_BASE_URL" geminiTrustWorkspaceEnv = "GEMINI_CLI_TRUST_WORKSPACE" geminiHomeEnv = "GEMINI_CLI_HOME" geminiTrustWorkspace = "true" @@ -123,15 +125,36 @@ func (transport *Transport) args(request toolv1.TurnRequest) []string { func (agent *Agent) env(config toolv1.Config) []string { gemini := config.Run.Runtime.Config.Gemini + apiKey := gemini.APIKey env := []string{ - fmt.Sprintf("%s=%s", geminiAPIKeyEnv, gemini.APIKey), + fmt.Sprintf("%s=%s", geminiAPIKeyEnv, apiKey), fmt.Sprintf("%s=%s", geminiTrustWorkspaceEnv, geminiTrustWorkspace), fmt.Sprintf("%s=%s", geminiHomeEnv, config.WorkDir), } + if config.Run.IsProxyEnabled() { + apiKey = agent.consoleToken + env[0] = fmt.Sprintf("%s=%s", geminiAPIKeyEnv, apiKey) + if baseURL := agent.proxyBaseURL(); baseURL != "" { + env = append(env, fmt.Sprintf("%s=%s", geminiGoogleBaseURLEnv, baseURL)) + } + return env + } + if gemini.Endpoint != nil { env = append(env, fmt.Sprintf("%s=%s", geminiAPIBaseURLEnv, *gemini.Endpoint)) } return env } + +func (agent *Agent) proxyBaseURL() string { + consoleURL := strings.TrimSuffix(agent.consoleURL, "/") + consoleURL = strings.TrimSuffix(consoleURL, "/ext/gql") + consoleURL = strings.TrimSuffix(consoleURL, "/gql") + consoleURL = strings.TrimSuffix(consoleURL, "/") + if consoleURL == "" { + return "" + } + return consoleURL + "/ext/ai/gemini" +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go index c33db45298..f8b0f48104 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go @@ -157,6 +157,57 @@ func TestTransportTurnParsesStreamAndPreservesExecutionOptions(t *testing.T) { assertSuccessfulStream(t, sink) } +func TestTransportEnvUsesConsoleCredentialsForProxy(t *testing.T) { + run := geminiTestRun(console.AgentRunModeWrite, "gemini-custom", nil) + run.Runtime.AiProxy = true + config := toolv1.Config{ + WorkDir: t.TempDir(), + RepositoryDir: t.TempDir(), + Run: run, + } + agent := NewAgent(config) + agent.consoleURL = "https://console.example/gql" + agent.consoleToken = "console-token" + values := make(map[string]string) + for _, item := range agent.env(config) { + key, value, ok := strings.Cut(item, "=") + if ok { + values[key] = value + } + } + + if values[geminiAPIKeyEnv] != "console-token" { + t.Fatalf("proxy API key = %q, want Console token", values[geminiAPIKeyEnv]) + } + if values[geminiGoogleBaseURLEnv] != "https://console.example/ext/ai/gemini" { + t.Fatalf("proxy base URL = %q", values[geminiGoogleBaseURLEnv]) + } + if _, ok := values[geminiAPIBaseURLEnv]; ok { + t.Fatal("proxy environment unexpectedly set legacy direct endpoint") + } + if values[geminiAPIKeyEnv] == run.Runtime.Config.Gemini.APIKey { + t.Fatal("proxy environment used provider API key") + } +} + +func TestTransportEnvDoesNotSubstituteProxyCredential(t *testing.T) { + run := geminiTestRun(console.AgentRunModeWrite, "gemini-custom", nil) + run.Runtime.AiProxy = true + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: run} + + values := make(map[string]string) + for _, item := range NewAgent(config).env(config) { + key, value, ok := strings.Cut(item, "=") + if ok { + values[key] = value + } + } + + if values[geminiAPIKeyEnv] != "" { + t.Fatalf("proxy API key = %q, want empty when Console token is missing", values[geminiAPIKeyEnv]) + } +} + func TestTransportTurnReportsStreamErrorsAfterDrain(t *testing.T) { binDir := t.TempDir() writeGeminiBinary(t, binDir) @@ -317,7 +368,7 @@ if [ -n "$GEMINI_TEST_OUTPUT" ]; then for arg in "$@"; do printf 'arg=%s\n' "$arg" >> "$GEMINI_TEST_OUTPUT" done - printf 'key=%s\nendpoint=%s\ntrust=%s\nhome=%s\ncwd=%s\n' "$GEMINI_API_KEY" "$GEMINI_API_BASE_URL" "$GEMINI_CLI_TRUST_WORKSPACE" "$GEMINI_CLI_HOME" "$PWD" >> "$GEMINI_TEST_OUTPUT" + printf 'key=%s\nendpoint=%s\ngoogle_endpoint=%s\ntrust=%s\nhome=%s\ncwd=%s\n' "$GEMINI_API_KEY" "$GEMINI_API_BASE_URL" "$GOOGLE_GEMINI_BASE_URL" "$GEMINI_CLI_TRUST_WORKSPACE" "$GEMINI_CLI_HOME" "$PWD" >> "$GEMINI_TEST_OUTPUT" fi printf '[DEBUG] ignored Gemini CLI stderr noise\n' >&2 if [ -n "$GEMINI_TEST_FIXTURE" ]; then diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/acp_environment.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/acp_environment.go new file mode 100644 index 0000000000..0c8f26a435 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/pi/acp_environment.go @@ -0,0 +1,45 @@ +package pi + +import ( + "fmt" + "strings" + + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +const ( + consoleTokenEnv = "PLRL_CONSOLE_TOKEN" + openAIAPIKeyEnv = "OPENAI_API_KEY" + anthropicAPIKeyEnv = "ANTHROPIC_API_KEY" + azureAPIKeyEnv = "AZURE_OPENAI_API_KEY" + bedrockAPIKeyEnv = "AWS_BEARER_TOKEN_BEDROCK" + xaiAPIKeyEnv = "XAI_API_KEY" +) + +func (agent *Agent) env(config toolv1.Config) []string { + pi, _ := agent.runConfig(config.Run) + apiKey := "" + if pi != nil { + apiKey = pi.APIKey + } + if config.Run.IsProxyEnabled() { + apiKey = agent.consoleToken + } + + env := []string{ + fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agent.piHome(config)), + fmt.Sprintf("%s=%s", consoleTokenEnv, agent.consoleToken), + fmt.Sprintf("%s=%s", openAIAPIKeyEnv, apiKey), + } + switch strings.ToLower(agent.resolvedProvider(config)) { + case providerAnthropic: + env = append(env, fmt.Sprintf("%s=%s", anthropicAPIKeyEnv, apiKey)) + case providerAzure: + env = append(env, fmt.Sprintf("%s=%s", azureAPIKeyEnv, apiKey)) + case providerAmazonBedrock, providerBedrock: + env = append(env, fmt.Sprintf("%s=%s", bedrockAPIKeyEnv, apiKey)) + case providerXAI: + env = append(env, fmt.Sprintf("%s=%s", xaiAPIKeyEnv, apiKey)) + } + return env +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/acp_environment_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/acp_environment_test.go new file mode 100644 index 0000000000..ab636300e8 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/pi/acp_environment_test.go @@ -0,0 +1,36 @@ +package pi + +import ( + "strings" + "testing" + + console "github.com/pluralsh/console/go/client" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestAgentEnvUsesSupportedProviderAPIKeys(t *testing.T) { + for _, test := range []struct { + provider string + envName string + }{ + {provider: providerAnthropic, envName: anthropicAPIKeyEnv}, + {provider: providerAzure, envName: azureAPIKeyEnv}, + {provider: providerAmazonBedrock, envName: bedrockAPIKeyEnv}, + {provider: providerXAI, envName: xaiAPIKeyEnv}, + } { + t.Run(test.provider, func(t *testing.T) { + run := piTestRun(console.AgentRunModeWrite, test.provider, "model", nil, false) + agent := NewAgent(toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: run}) + values := make(map[string]string) + for _, item := range agent.env(agent.config) { + key, value, ok := strings.Cut(item, "=") + if ok { + values[key] = value + } + } + if values[test.envName] != "api-key" { + t.Fatalf("%s environment = %q", test.envName, values[test.envName]) + } + }) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/agent.go new file mode 100644 index 0000000000..9726a47563 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/pi/agent.go @@ -0,0 +1,221 @@ +// Package pi implements the Pi coding-agent runtime for the agent harness. +package pi + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +const ( + piHomeDir = ".pi" + piAgentDir = "agent" + piSessionsDir = "sessions" + piSkillsDir = "skills" +) + +// Agent owns Pi's native configuration, shared prompt and skills preparation, +// and staging of Pi's provider-owned session state. +type Agent struct { + config toolv1.Config + consoleURL string + consoleToken string +} + +var _ toolv1.Agent = (*Agent)(nil) + +// NewAgent creates a Pi Agent for one agent run. +func NewAgent(config toolv1.Config) *Agent { + agent := &Agent{config: config} + if config.Run != nil && config.Run.PluralCreds != nil && config.Run.PluralCreds.Token != nil { + agent.consoleToken = *config.Run.PluralCreds.Token + } + return agent +} + +// Type identifies the Console runtime implemented by Agent. +func (*Agent) Type() console.AgentRuntimeType { + return console.AgentRuntimeTypePi +} + +// Capabilities advertises the modes supported by Pi. +func (*Agent) Capabilities() toolv1.AgentCapabilities { + return toolv1.AgentCapabilities{Modes: []console.AgentRunMode{ + console.AgentRunModeAnalyze, + console.AgentRunModeWrite, + console.AgentRunModeReview, + }} +} + +// Prepare writes Pi's shared system prompt and skills for a configuration +// phase. Native models and MCP configuration are written separately by +// Configure. +func (agent *Agent) Prepare(ctx context.Context, request toolv1.FileSystemRequest) error { + if err := agent.contextError(ctx); err != nil { + return err + } + config, err := agent.configForFilesystem(request) + if err != nil { + return err + } + + defaultTool := toolv1.DefaultTool{Config: config} + switch request.Phase { + case toolv1.ConfigurePhaseInitial: + err = defaultTool.ConfigureSystemPrompt(console.AgentRuntimeTypePi) + case toolv1.ConfigurePhaseBabysit: + err = defaultTool.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypePi) + default: + return fmt.Errorf("unsupported pi configuration phase %q", request.Phase) + } + if err != nil { + return err + } + + if err := agent.contextError(ctx); err != nil { + return err + } + return defaultTool.ConfigureSkills(agent.skillsPath(config)) +} + +// Configure writes Pi's native model and MCP configuration for the initial +// phase. Babysit runs reuse that configuration so transient proxy credentials +// are not replaced. +func (agent *Agent) Configure(ctx context.Context, request toolv1.ConfigureRequest) error { + if err := agent.contextError(ctx); err != nil { + return err + } + if request.Phase != toolv1.ConfigurePhaseInitial && request.Phase != toolv1.ConfigurePhaseBabysit { + return fmt.Errorf("unsupported pi configuration phase %q", request.Phase) + } + if request.Phase == toolv1.ConfigurePhaseBabysit { + return nil + } + + config, err := agent.configWithPi() + if err != nil { + return err + } + agent.consoleURL = request.ConsoleURL + if request.ConsoleToken != "" { + agent.consoleToken = request.ConsoleToken + } + + return agent.writeNativeConfig(config, request.Settings.Model.Name) +} + +// Export stages all native Pi sessions below OutputDir. Pi's ACP adapter +// persists the session JSONL files in the same directory used by the native +// CLI, so the whole sessions tree is retained for artifact uploads. +func (agent *Agent) Export(ctx context.Context, request toolv1.ExportRequest) (toolv1.ExportResult, error) { + if err := agent.contextError(ctx); err != nil { + return toolv1.ExportResult{}, err + } + if request.SessionID == "" { + return toolv1.ExportResult{}, errors.New("pi session id is not set") + } + if request.OutputDir == "" { + return toolv1.ExportResult{}, errors.New("pi export output directory is not set") + } + + config, err := agent.configWithPi() + if err != nil { + return toolv1.ExportResult{}, err + } + found, err := artifacts.StageSessionDirectory(ctx, agent.sessionsPath(config), request.OutputDir) + if err != nil { + return toolv1.ExportResult{}, fmt.Errorf("stage pi sessions: %w", err) + } + if !found { + return toolv1.ExportResult{}, nil + } + + return toolv1.ExportResult{SessionSource: artifacts.SessionSource{ + Path: request.OutputDir, + ArchivePath: piSessionsDir, + }}, nil +} + +func (agent *Agent) configWithPi() (toolv1.Config, error) { + if agent.config.WorkDir == "" { + return toolv1.Config{}, errors.New("work directory is not set") + } + if agent.config.RepositoryDir == "" { + return toolv1.Config{}, errors.New("repository directory is not set") + } + if _, err := agent.runConfig(agent.config.Run); err != nil { + return toolv1.Config{}, err + } + return agent.config, nil +} + +func (agent *Agent) configForFilesystem(request toolv1.FileSystemRequest) (toolv1.Config, error) { + if request.WorkDir == "" { + return toolv1.Config{}, errors.New("work directory is not set") + } + if request.RepositoryDir == "" { + return toolv1.Config{}, errors.New("repository directory is not set") + } + if agent.config.Run == nil { + return toolv1.Config{}, errors.New("agent run is not set") + } + + config := agent.config + config.WorkDir = request.WorkDir + config.RepositoryDir = request.RepositoryDir + return config, nil +} + +func (agent *Agent) runConfig(run *agentrunv1.AgentRun) (*agentrunv1.PiConfig, error) { + if run == nil { + return nil, errors.New("agent run is not set") + } + if run.Runtime == nil || run.Runtime.Config == nil || run.Runtime.Config.Pi == nil { + return nil, errors.New("pi runtime configuration is not set") + } + config := run.Runtime.Config.Pi + if err := agent.validateProvider(run, config); err != nil { + return nil, err + } + return config, nil +} + +func (*Agent) validateProvider(run *agentrunv1.AgentRun, config *agentrunv1.PiConfig) error { + if run.IsProxyEnabled() || config.Endpoint != nil || config.Provider == "" { + return nil + } + + switch strings.ToLower(config.Provider) { + case providerOpenAI, providerOpenAICompatible, providerAnthropic, providerOllama, + providerAzure, providerAmazonBedrock, providerBedrock, providerGoogleVertex, providerVertex, providerXAI: + return nil + default: + return fmt.Errorf("unsupported pi provider %q", config.Provider) + } +} + +func (*Agent) piHome(config toolv1.Config) string { + return filepath.Join(config.WorkDir, piHomeDir, piAgentDir) +} + +func (agent *Agent) skillsPath(config toolv1.Config) string { + return filepath.Join(agent.piHome(config), piSkillsDir) +} + +func (agent *Agent) sessionsPath(config toolv1.Config) string { + return filepath.Join(agent.piHome(config), piSessionsDir) +} + +func (*Agent) contextError(ctx context.Context) error { + if ctx == nil { + return nil + } + return ctx.Err() +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/agent_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/agent_config.go new file mode 100644 index 0000000000..a012d64e39 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/pi/agent_config.go @@ -0,0 +1,112 @@ +package pi + +import ( + "encoding/json" + "fmt" + "path/filepath" + + "github.com/pluralsh/console/go/deployment-operator/internal/helpers" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/common" +) + +const ( + modelsFileName = "models.json" + mcpFileName = "mcp.json" + settingsFileName = "settings.json" + piMCPExtensionPath = "/opt/pi-mcp-adapter/node_modules/pi-mcp-adapter/index.ts" +) + +func (agent *Agent) configPath(config toolv1.Config) string { + return filepath.Join(agent.piHome(config), modelsFileName) +} + +func (agent *Agent) mcpConfigPath(config toolv1.Config) string { + return filepath.Join(agent.piHome(config), mcpFileName) +} + +func (agent *Agent) settingsPath(config toolv1.Config) string { + return filepath.Join(agent.piHome(config), settingsFileName) +} + +func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error { + resolved, err := agent.nativeSettings(config, model) + if err != nil { + return err + } + + providers := map[string]any{} + if resolved.endpoint != "" { + providers[resolved.provider] = map[string]any{ + "baseUrl": resolved.endpoint, + "apiKey": fmt.Sprintf("$%s", openAIAPIKeyEnv), + "api": "openai-responses", + "models": []map[string]any{{ + "id": resolved.model, + "contextWindow": 128000, + "maxTokens": 16384, + }}, + } + } + data, err := json.Marshal(map[string]any{"providers": providers}) + if err != nil { + return fmt.Errorf("marshal pi model config: %w", err) + } + if err := helpers.File().Create(agent.configPath(config), string(data), 0644); err != nil { + return fmt.Errorf("write pi model config: %w", err) + } + + servers := map[string]any{ + "plural": map[string]any{ + "url": common.AgentMCPServerURL, + "directTools": true, + }, + common.CodebaseMemoryMCPServerName: map[string]any{ + "command": common.CodebaseMemoryMCPCommand, + "env": map[string]string{ + common.CodebaseMemoryCacheEnv: common.CodebaseMemoryCacheDir, + }, + "directTools": true, + }, + } + if err := addExternalMCPServers(servers); err != nil { + return err + } + mcpData, err := json.Marshal(map[string]any{"mcpServers": servers}) + if err != nil { + return fmt.Errorf("marshal pi mcp config: %w", err) + } + if err := helpers.File().Create(agent.mcpConfigPath(config), string(mcpData), 0644); err != nil { + return fmt.Errorf("write pi mcp config: %w", err) + } + settingsData, err := json.Marshal(map[string]any{"extensions": []string{piMCPExtensionPath}}) + if err != nil { + return fmt.Errorf("marshal pi settings: %w", err) + } + if err := helpers.File().Create(agent.settingsPath(config), string(settingsData), 0644); err != nil { + return fmt.Errorf("write pi settings: %w", err) + } + return nil +} + +func addExternalMCPServers(servers map[string]any) error { + external, err := mcp.Load() + if err != nil { + return fmt.Errorf("load external mcp servers: %w", err) + } + for _, server := range external { + entry := map[string]any{"url": server.URL} + if len(server.Headers) > 0 { + entry["headers"] = server.Headers + } + if server.HasAllowedTools() { + entry["directTools"] = server.AllowedTools + entry["includeTools"] = server.AllowedTools + } else { + entry["directTools"] = true + } + servers[server.Name] = entry + } + return nil +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/agent_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/agent_config_test.go new file mode 100644 index 0000000000..e3c0a83c81 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/pi/agent_config_test.go @@ -0,0 +1,98 @@ +package pi + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestAgentConfigure(t *testing.T) { + workDir := t.TempDir() + repositoryDir := t.TempDir() + run := piTestRun(console.AgentRunModeWrite, "", "gpt-5.4", nil, true) + agent := NewAgent(toolv1.Config{WorkDir: workDir, RepositoryDir: repositoryDir, Run: run}) + settings, err := agent.ResolveSettings(run) + if err != nil { + t.Fatalf("ResolveSettings() error = %v", err) + } + t.Setenv(mcp.EnvServers, `[{ + "name":"linear", + "url":"https://mcp.linear.app/mcp", + "allowedTools":["list_issues"] + }]`) + if err := agent.Configure(context.Background(), toolv1.ConfigureRequest{ + Phase: toolv1.ConfigurePhaseInitial, + ConsoleURL: "https://console.example", + ConsoleToken: "console-token", + Settings: settings, + }); err != nil { + t.Fatalf("Configure(initial) error = %v", err) + } + + models, err := os.ReadFile(filepath.Join(workDir, ".pi", "agent", modelsFileName)) + if err != nil { + t.Fatalf("read models config: %v", err) + } + var modelConfig map[string]any + if err := json.Unmarshal(models, &modelConfig); err != nil { + t.Fatalf("decode models config: %v", err) + } + provider := modelConfig["providers"].(map[string]any)[providerPlural].(map[string]any) + if provider["baseUrl"] != "https://console.example/ext/ai/v1" || provider["apiKey"] != "$OPENAI_API_KEY" { + t.Fatalf("proxy provider config = %#v", provider) + } + + mcpConfig, err := os.ReadFile(filepath.Join(workDir, ".pi", "agent", mcpFileName)) + if err != nil { + t.Fatalf("read mcp config: %v", err) + } + if !strings.Contains(string(mcpConfig), `"linear"`) { + t.Fatalf("external MCP server missing: %s", mcpConfig) + } + settingsConfig, err := os.ReadFile(filepath.Join(workDir, ".pi", "agent", settingsFileName)) + if err != nil { + t.Fatalf("read pi settings: %v", err) + } + if !strings.Contains(string(settingsConfig), piMCPExtensionPath) { + t.Fatalf("MCP extension missing from Pi settings: %s", settingsConfig) + } + + before := string(models) + if err := agent.Configure(context.Background(), toolv1.ConfigureRequest{Phase: toolv1.ConfigurePhaseBabysit}); err != nil { + t.Fatalf("Configure(babysit) error = %v", err) + } + after, err := os.ReadFile(filepath.Join(workDir, ".pi", "agent", modelsFileName)) + if err != nil { + t.Fatalf("read models config after babysit: %v", err) + } + if string(after) != before { + t.Fatal("babysit configuration unexpectedly rewrote native config") + } +} + +func TestAddExternalMCPServers(t *testing.T) { + t.Setenv(mcp.EnvServers, `[{ + "name":"linear", + "url":"https://mcp.linear.app/mcp", + "allowedTools":["list_issues"], + "headers":{"Authorization":"Bearer secret"} + }]`) + servers := map[string]any{} + if err := addExternalMCPServers(servers); err != nil { + t.Fatalf("addExternalMCPServers() error = %v", err) + } + linear := servers["linear"].(map[string]any) + if linear["url"] != "https://mcp.linear.app/mcp" { + t.Fatalf("url = %v", linear["url"]) + } + if linear["headers"].(map[string]string)["Authorization"] != "Bearer secret" { + t.Fatalf("headers = %#v", linear["headers"]) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/agent_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/agent_test.go new file mode 100644 index 0000000000..2a16888a65 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/pi/agent_test.go @@ -0,0 +1,115 @@ +package pi + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestAgentCapabilities(t *testing.T) { + capabilities := NewAgent(toolv1.Config{}).Capabilities() + for _, mode := range []console.AgentRunMode{ + console.AgentRunModeAnalyze, + console.AgentRunModeWrite, + console.AgentRunModeReview, + } { + if !capabilities.Supports(mode) { + t.Fatalf("Capabilities() does not support %q", mode) + } + } +} + +func TestAgentPrepare(t *testing.T) { + usePiSystemTemplates(t) + workDir := t.TempDir() + repositoryDir := t.TempDir() + run := piTestRun(console.AgentRunModeWrite, "", "gpt-5.4", nil, true) + run.Prompt = "initial prompt" + run.Skills = []agentrunv1.AgentSkill{{Name: "guide", Contents: "inspect changes"}} + agent := NewAgent(toolv1.Config{WorkDir: workDir, RepositoryDir: repositoryDir, Run: run}) + request := toolv1.FileSystemRequest{ + Phase: toolv1.ConfigurePhaseInitial, + WorkDir: workDir, + RepositoryDir: repositoryDir, + } + + if err := agent.Prepare(context.Background(), request); err != nil { + t.Fatalf("Prepare(initial) error = %v", err) + } + prompt, err := os.ReadFile(filepath.Join(workDir, ".pi", "agent", toolv1.SystemPromptFile)) + if err != nil { + t.Fatalf("read system prompt: %v", err) + } + if !strings.Contains(string(prompt), "initial prompt") { + t.Fatalf("prompt does not contain run prompt: %s", prompt) + } + if _, err := os.Stat(filepath.Join(workDir, ".pi", "agent", "skills", "guide", "SKILL.md")); err != nil { + t.Fatalf("skill file was not prepared: %v", err) + } + + request.Phase = toolv1.ConfigurePhaseBabysit + if err := agent.Prepare(context.Background(), request); err != nil { + t.Fatalf("Prepare(babysit) error = %v", err) + } +} + +func TestAgentExportStagesNativeSession(t *testing.T) { + config := toolv1.Config{ + WorkDir: t.TempDir(), + RepositoryDir: t.TempDir(), + Run: piTestRun(console.AgentRunModeWrite, "", "gpt-5.4", nil, false), + } + agent := NewAgent(config) + if err := os.MkdirAll(agent.sessionsPath(config), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agent.sessionsPath(config), "session.jsonl"), []byte("session"), 0644); err != nil { + t.Fatal(err) + } + outputDir := t.TempDir() + result, err := agent.Export(context.Background(), toolv1.ExportRequest{SessionID: "session-1", OutputDir: outputDir}) + if err != nil { + t.Fatalf("Export() error = %v", err) + } + if result.SessionSource.Path != outputDir || result.SessionSource.ArchivePath != piSessionsDir { + t.Fatalf("session source = %#v", result.SessionSource) + } +} + +func piTestRun(mode console.AgentRunMode, provider, model string, endpoint *string, proxy bool) *agentrunv1.AgentRun { + return &agentrunv1.AgentRun{ + ID: "run-1", + Mode: mode, + Runtime: &agentrunv1.AgentRuntime{ + AiProxy: proxy, + Config: &agentrunv1.AgentRuntimeConfig{Pi: &agentrunv1.PiConfig{ + APIKey: "api-key", + Provider: provider, + Model: model, + Endpoint: endpoint, + Timeout: 9 * time.Minute, + }}, + }, + } +} + +func usePiSystemTemplates(t *testing.T) { + t.Helper() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "system"), 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"analyze", "write", "review", "babysit"} { + if err := os.WriteFile(filepath.Join(root, "system", name+".md.tmpl"), []byte(name+" {{.Prompt}}"), 0644); err != nil { + t.Fatal(err) + } + } + t.Chdir(root) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi.go deleted file mode 100644 index 9f09959ebf..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi.go +++ /dev/null @@ -1,419 +0,0 @@ -package pi - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - - "k8s.io/klog/v2" - - console "github.com/pluralsh/console/go/client" - "github.com/pluralsh/console/go/deployment-operator/internal/helpers" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" - proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" - v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" - "github.com/pluralsh/console/go/deployment-operator/pkg/common" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" - "github.com/pluralsh/console/go/deployment-operator/pkg/log" -) - -func New(config v1.Config) v1.Tool { - runtimeConfig := config.Run.Runtime.Config.Pi - result := &Pi{ - DefaultTool: v1.DefaultTool{Config: config}, - model: defaultModel, - provider: openAIProvider, - } - if runtimeConfig != nil { - if runtimeConfig.Model != "" { - result.model = runtimeConfig.Model - } - if runtimeConfig.Provider != "" { - result.provider = runtimeConfig.Provider - } - result.apiKey = runtimeConfig.APIKey - if runtimeConfig.Endpoint != nil { - result.endpoint = *runtimeConfig.Endpoint - } - } - if config.Run.IsProxyEnabled() { - result.provider = proxyProviderKey - result.model = proxymodel.ProxyModel(console.AgentRuntimeTypePi, result.model) - } - if err := result.ensure(); err != nil { - klog.Fatalf("failed to initialize pi tool: %v", err) - } - return result -} - -func (in *Pi) ensure() error { - if in.Config.WorkDir == "" { - return fmt.Errorf("work directory is not set") - } - if in.Config.RepositoryDir == "" { - return fmt.Errorf("repository directory is not set") - } - if in.Config.Run == nil || in.Config.Run.Runtime == nil || in.Config.Run.Runtime.Config == nil || in.Config.Run.Runtime.Config.Pi == nil { - return fmt.Errorf("pi runtime configuration is not set") - } - return nil -} - -func (in *Pi) Run(ctx context.Context, options ...exec.Option) { - go in.start(ctx, in.Config.Run.Prompt, "", options...) -} - -func (in *Pi) Configure(consoleURL, consoleToken string) error { - in.consoleURL = consoleURL - in.consoleToken = consoleToken - if err := in.ConfigureSystemPrompt(console.AgentRuntimeTypePi); err != nil { - return err - } - if err := in.ConfigureSkills(in.skillsPath()); err != nil { - return err - } - if err := in.writeConfig(); err != nil { - return err - } - return nil -} - -func (in *Pi) ConfigureBabysitRun() error { - if err := in.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypePi); err != nil { - return err - } - return in.ConfigureSkills(in.skillsPath()) -} - -func (in *Pi) OnMessage(f v1.MessageCallback) { - in.onMessage = f -} - -func (in *Pi) BabysitRun(ctx context.Context, bCtx *v1.BabysitContext) bool { - if bCtx == nil { - return false - } - if err := in.run(ctx, bCtx.Prompt, in.sessionID, true); err != nil { - in.Config.ErrorChan <- err - } - return false -} - -func (in *Pi) FollowUpRun(ctx context.Context, prompt string) error { - return in.run(ctx, prompt, in.sessionID, false) -} - -func (in *Pi) start(ctx context.Context, prompt, session string, options ...exec.Option) { - if in.onMessage != nil { - in.onMessage(&console.AgentMessageAttributes{Message: prompt, Role: console.AiRoleUser}, "") - } - if err := in.runWithOptions(ctx, prompt, session, options...); err != nil { - klog.ErrorS(err, "pi execution failed") - in.Config.ErrorChan <- err - } -} - -func (in *Pi) run(ctx context.Context, prompt, session string, emitUser bool) error { - if emitUser && in.onMessage != nil { - in.onMessage(&console.AgentMessageAttributes{Message: prompt, Role: console.AiRoleUser}, "") - } - return in.runWithOptions(ctx, prompt, session) -} - -func (in *Pi) runWithOptions(ctx context.Context, prompt, session string, options ...exec.Option) error { - in.executable = exec.NewExecutable( - "pi", - append(options, - exec.WithArgs(in.args(prompt, session)), - exec.WithEnv(in.env()), - exec.WithDir(in.Config.RepositoryDir), - exec.WithTimeout(in.Config.Run.Runtime.Config.Pi.Timeout), - )..., - ) - return in.executable.RunStream(ctx, in.handleStreamLine) -} - -func (in *Pi) args(prompt, session string) []string { - args := []string{ - "--mode", "json", - "--approve", - "--provider", in.provider, - "--model", in.model, - "--session-dir", in.sessionsPath(), - "--extension", piMCPExtensionPath, - "--mcp-config", in.mcpConfigPath(), - } - if session != "" { - args = append(args, "--session", session) - } - proxyEnabled := in.Config.Run != nil && in.Config.Run.IsProxyEnabled() - if !proxyEnabled && in.apiKey != "" { - args = append(args, "--api-key", in.apiKey) - } - return append(args, prompt) -} - -func (in *Pi) env() []string { - apiKey := in.apiKey - if in.Config.Run.IsProxyEnabled() { - apiKey = in.consoleToken - } - return []string{ - fmt.Sprintf("PI_CODING_AGENT_DIR=%s", in.piHome()), - fmt.Sprintf("%s=%s", pluralAPIKeyEnv, in.consoleToken), - fmt.Sprintf("%s=%s", openAIAPIKeyEnv, apiKey), - } -} - -func (in *Pi) piHome() string { - return filepath.Join(in.Config.WorkDir, ".pi", "agent") -} - -func (in *Pi) skillsPath() string { - return filepath.Join(in.piHome(), "skills") -} - -func (in *Pi) sessionsPath() string { - return filepath.Join(in.piHome(), "sessions") -} - -func (in *Pi) configPath() string { - return filepath.Join(in.piHome(), "models.json") -} - -func (in *Pi) mcpConfigPath() string { - return filepath.Join(in.piHome(), "mcp.json") -} - -func (in *Pi) writeConfig() error { - endpoint := in.endpoint - if in.Config.Run.IsProxyEnabled() { - endpoint = fmt.Sprintf("%s/ext/ai/v1", in.consoleURL) - if in.Config.Run.IsStreamingProxyEnabled() { - endpoint = common.AgentOpenAIBaseURL - } - } - provider := in.provider - if endpoint != "" { - if in.Config.Run.IsProxyEnabled() { - // Use a non-"openai" provider key so the Pi CLI does not strip the - // "openai/" prefix from the model ID before calling the proxy endpoint. - provider = proxyProviderKey - } else { - // For custom non-proxy endpoints keep the openai provider so Pi uses - // its built-in OpenAI-compatible client. - provider = openAIProvider - } - in.provider = provider - } - models := map[string]any{"providers": map[string]any{}} - if endpoint != "" { - models["providers"].(map[string]any)[provider] = map[string]any{ - "baseUrl": endpoint, - "apiKey": fmt.Sprintf("$%s", openAIAPIKeyEnv), - "api": "openai-responses", - "models": []map[string]any{{ - "id": in.model, - "contextWindow": 128000, - "maxTokens": 16384, - }}, - } - } - data, err := json.Marshal(models) - if err != nil { - return fmt.Errorf("marshal pi model config: %w", err) - } - if err := helpers.File().Create(in.configPath(), string(data), 0644); err != nil { - return fmt.Errorf("write pi model config: %w", err) - } - - mcp := map[string]any{ - "mcpServers": map[string]any{ - "plural": map[string]any{ - "url": common.AgentMCPServerURL, - "directTools": true, - }, - common.CodebaseMemoryMCPServerName: map[string]any{ - "command": common.CodebaseMemoryMCPCommand, - "env": map[string]string{ - common.CodebaseMemoryCacheEnv: common.CodebaseMemoryCacheDir, - }, - "directTools": true, - }, - }, - } - if err := addExternalMCPServers(mcp["mcpServers"].(map[string]any)); err != nil { - return err - } - mcpData, err := json.Marshal(mcp) - if err != nil { - return fmt.Errorf("marshal pi mcp config: %w", err) - } - if err := helpers.File().Create(in.mcpConfigPath(), string(mcpData), 0644); err != nil { - return fmt.Errorf("write pi mcp config: %w", err) - } - return nil -} - -func addExternalMCPServers(servers map[string]any) error { - external, err := mcp.Load() - if err != nil { - return fmt.Errorf("load external mcp servers: %w", err) - } - for _, server := range external { - entry := map[string]any{ - "url": server.URL, - } - if len(server.Headers) > 0 { - entry["headers"] = server.Headers - } - if server.HasAllowedTools() { - entry["directTools"] = server.AllowedTools - entry["includeTools"] = server.AllowedTools - } else { - entry["directTools"] = true - } - servers[server.Name] = entry - } - return nil -} - -func (in *Pi) UploadArtifacts(ctx context.Context) (*artifacts.UploadArtifacts, error) { - return in.BuildUploadArtifacts(ctx, artifacts.BuildArtifactsOptions{ - Provider: "pi", - Source: artifacts.SessionSource{Path: in.sessionsPath(), ArchivePath: "sessions"}, - SessionID: in.sessionID, - }) -} - -func (in *Pi) handleStreamLine(line []byte) { - var event StreamEvent - if err := json.Unmarshal(line, &event); err != nil { - klog.V(log.LogLevelDebug).InfoS("ignoring non-json pi stream line", "line", string(line)) - return - } - if event.Type == "session" && event.ID != "" { - in.sessionID = event.ID - } - if event.Type == "tool_execution_update" { - in.EmitOutput(event.ToolCallID, toolResultText(event.PartialResult)) - return - } - message, callID := in.mapStreamEvent(&event) - if message != nil && in.onMessage != nil { - in.onMessage(message, callID) - } -} - -func (in *Pi) mapStreamEvent(event *StreamEvent) (*console.AgentMessageAttributes, string) { - switch event.Type { - case "tool_execution_start": - return toolMessage(event.ToolName, console.AgentMessageToolStateRunning, rawString(event.Args), v1.RunningToolOutput), event.ToolCallID - case "tool_execution_end": - state := console.AgentMessageToolStateCompleted - if event.IsError { - state = console.AgentMessageToolStateError - } - return toolMessage(event.ToolName, state, rawString(event.Args), toolResultText(event.Result)), event.ToolCallID - case "message_end": - return in.messageEnd(event.Message), "" - case "error": - if event.Error != nil && event.Error.Message != "" { - return &console.AgentMessageAttributes{Role: console.AiRoleAssistant, Message: event.Error.Message}, "" - } - } - return nil, "" -} - -func (in *Pi) messageEnd(message *AgentMessage) *console.AgentMessageAttributes { - if message == nil || message.Role != "assistant" { - return nil - } - text := assistantText(message.Content) - if text == "" && message.Usage == nil { - return nil - } - result := &console.AgentMessageAttributes{Role: console.AiRoleAssistant, Message: text} - if result.Message == "" { - result.Message = "__plrl_ignore__" - } - if message.Usage != nil { - total := message.Usage.Total - if total == 0 { - total = message.Usage.Input + message.Usage.Output - } - cost := 0.0 - if message.Usage.Cost != nil { - cost = message.Usage.Cost.Total - } - in.Config.Usage.RecordUsage(usage.Record{ - InputTokens: message.Usage.Input, - OutputTokens: message.Usage.Output, - TotalTokens: total, - CachedTokens: message.Usage.CacheRead + message.Usage.CacheWrite, - ReasoningTokens: message.Usage.Reasoning, - TotalCost: cost, - }) - result.Cost = &console.AgentMessageCostAttributes{ - Total: cost, - Tokens: &console.AgentMessageTokensAttributes{ - Input: new(float64(message.Usage.Input)), - Output: new(float64(message.Usage.Output)), - }, - } - } - return result -} - -func assistantText(content json.RawMessage) string { - text, _ := contentBlocksText(content) - return text -} - -func rawString(value json.RawMessage) string { - if len(value) == 0 || string(value) == "null" { - return "" - } - return string(value) -} - -func toolResultText(value json.RawMessage) string { - if len(value) == 0 || string(value) == "null" { - return "" - } - if text, ok := contentBlocksText(value); ok { - return text - } - var wrapped struct { - Content json.RawMessage `json:"content"` - } - if json.Unmarshal(value, &wrapped) == nil && len(wrapped.Content) > 0 { - if text, ok := contentBlocksText(wrapped.Content); ok { - // Empty content arrays are partial results with no stdout yet. - // Returning the wrapper JSON would poison later delta slicing. - return text - } - } - var s string - if json.Unmarshal(value, &s) == nil { - return s - } - return string(value) -} - -func contentBlocksText(value json.RawMessage) (string, bool) { - var blocks []contentBlock - if json.Unmarshal(value, &blocks) != nil { - return "", false - } - text := "" - for _, block := range blocks { - if block.Type == "text" { - text += block.Text - } - } - return text, true -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi_test.go deleted file mode 100644 index f10513023c..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package pi - -import ( - "encoding/json" - "reflect" - "testing" - - console "github.com/pluralsh/console/go/client" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" -) - -func TestAddExternalMCPServers(t *testing.T) { - t.Setenv(mcp.EnvServers, `[{"name":"linear","url":"https://mcp.linear.app/mcp","allowedTools":["list_issues"],"headers":{"Authorization":"Bearer secret"}}]`) - - servers := map[string]any{} - if err := addExternalMCPServers(servers); err != nil { - t.Fatalf("addExternalMCPServers() error = %v", err) - } - linear := servers["linear"].(map[string]any) - if linear["url"] != "https://mcp.linear.app/mcp" { - t.Fatalf("url = %v", linear["url"]) - } - headers := linear["headers"].(map[string]string) - if headers["Authorization"] != "Bearer secret" { - t.Fatalf("headers = %#v", headers) - } - directTools := linear["directTools"].([]string) - if len(directTools) != 1 || directTools[0] != "list_issues" { - t.Fatalf("directTools = %#v", directTools) - } -} - -func TestArgsIncludesJSONModeSessionAndMCPConfig(t *testing.T) { - tool := &Pi{ - DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{WorkDir: "/work"}}, - model: "openai/gpt-5.4", - provider: "openai", - } - want := []string{ - "--mode", "json", - "--approve", - "--provider", "openai", - "--model", "openai/gpt-5.4", - "--session-dir", "/work/.pi/agent/sessions", - "--extension", piMCPExtensionPath, - "--mcp-config", "/work/.pi/agent/mcp.json", - "--session", "session-1", - "write a test", - } - if got := tool.args("write a test", "session-1"); !reflect.DeepEqual(got, want) { - t.Fatalf("args = %#v, want %#v", got, want) - } -} - -func TestArgsWithProxyUsesPluralProvider(t *testing.T) { - tool := &Pi{ - DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{WorkDir: "/work"}}, - model: "openai/gpt-5.4", - provider: proxyProviderKey, - } - want := []string{ - "--mode", "json", - "--approve", - "--provider", proxyProviderKey, - "--model", "openai/gpt-5.4", - "--session-dir", "/work/.pi/agent/sessions", - "--extension", piMCPExtensionPath, - "--mcp-config", "/work/.pi/agent/mcp.json", - "write a task", - } - if got := tool.args("write a task", ""); !reflect.DeepEqual(got, want) { - t.Fatalf("args = %#v, want %#v", got, want) - } -} - -func TestMapStreamEventMapsToolLifecycle(t *testing.T) { - tool := &Pi{} - start, callID := tool.mapStreamEvent(&StreamEvent{ - Type: "tool_execution_start", - ToolCallID: "call-1", - ToolName: "bash", - Args: json.RawMessage(`{"command":"go test ./..."}`), - }) - if callID != "call-1" { - t.Fatalf("call id = %q", callID) - } - if start.Metadata == nil || start.Metadata.Tool == nil || *start.Metadata.Tool.State != console.AgentMessageToolStateRunning { - t.Fatalf("expected running tool message, got %#v", start) - } - - end, callID := tool.mapStreamEvent(&StreamEvent{ - Type: "tool_execution_end", - ToolCallID: "call-1", - ToolName: "bash", - Args: json.RawMessage(`{"command":"go test ./..."}`), - Result: json.RawMessage(`{"content":[{"type":"text","text":"ok"}]}`), - }) - if callID != "call-1" { - t.Fatalf("call id = %q", callID) - } - if end.Metadata == nil || end.Metadata.Tool == nil || *end.Metadata.Tool.State != console.AgentMessageToolStateCompleted { - t.Fatalf("expected completed tool message, got %#v", end) - } - if end.Metadata.Tool.Output == nil || *end.Metadata.Tool.Output != "ok" { - t.Fatalf("expected extracted tool output, got %#v", end.Metadata.Tool.Output) - } -} - -func TestHandleStreamLineEmitsToolOutput(t *testing.T) { - var callID, stdout string - tool := &Pi{} - tool.OnOutput(func(id, out string) { - callID = id - stdout = out - }) - tool.handleStreamLine([]byte(`{"type":"tool_execution_update","toolCallId":"call-1","toolName":"bash","partialResult":{"content":[{"type":"text","text":"hello\nworld"}]}}`)) - if callID != "call-1" { - t.Fatalf("call id = %q", callID) - } - if stdout != "hello\nworld" { - t.Fatalf("stdout = %q", stdout) - } -} - -func TestToolResultTextEmptyContent(t *testing.T) { - if got := toolResultText(json.RawMessage(`{"content":[]}`)); got != "" { - t.Fatalf("empty content = %q, want empty", got) - } -} - -func TestHandleStreamLineIgnoresEmptyPartialContent(t *testing.T) { - emitted := 0 - tool := &Pi{} - tool.OnOutput(func(string, string) { emitted++ }) - tool.handleStreamLine([]byte(`{"type":"tool_execution_update","toolCallId":"call-1","toolName":"bash","partialResult":{"content":[]}}`)) - if emitted != 0 { - t.Fatalf("emitted = %d, want 0", emitted) - } -} - -func TestMessageEndExtractsAssistantText(t *testing.T) { - tool := &Pi{} - message := tool.messageEnd(&AgentMessage{ - Role: "assistant", - Content: json.RawMessage(`[{"type":"text","text":"done"}]`), - }) - if message == nil || message.Message != "done" { - t.Fatalf("message = %#v, want assistant text", message) - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi_types.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi_types.go deleted file mode 100644 index 43fd6f4c50..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi_types.go +++ /dev/null @@ -1,107 +0,0 @@ -package pi - -import ( - "encoding/json" - - console "github.com/pluralsh/console/go/client" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" -) - -const ( - defaultModel = "gpt-5.4" - openAIProvider = "openai" - // proxyProviderKey is the models.json provider block name used when aiProxy is enabled. - // Using a non-"openai" name prevents the Pi CLI from stripping the "openai/" prefix - // from model IDs (e.g. "openai/gpt-5.4"), ensuring the full provider/model format - // reaches the Plural AI proxy at /ext/ai/v1. - proxyProviderKey = "plural" - pluralAPIKeyEnv = "PLRL_CONSOLE_TOKEN" - openAIAPIKeyEnv = "OPENAI_API_KEY" - piMCPExtensionPath = "/opt/pi-mcp-adapter/node_modules/pi-mcp-adapter/index.ts" -) - -// Pi implements the Pi coding-agent CLI integration. -type Pi struct { - toolv1.DefaultTool - - onMessage toolv1.MessageCallback - executable exec.Executable - sessionID string - model string - provider string - apiKey string - endpoint string - consoleURL string - consoleToken string -} - -// StreamEvent is a JSON-mode event emitted by `pi --mode json`. -type StreamEvent struct { - Type string `json:"type"` - ID string `json:"id,omitempty"` - Message *AgentMessage `json:"message,omitempty"` - AssistantMessageEvent *MessageUpdate `json:"assistantMessageEvent,omitempty"` - ToolCallID string `json:"toolCallId,omitempty"` - ToolName string `json:"toolName,omitempty"` - Args json.RawMessage `json:"args,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - PartialResult json.RawMessage `json:"partialResult,omitempty"` - IsError bool `json:"isError,omitempty"` - Error *StreamError `json:"error,omitempty"` -} - -type StreamError struct { - Message string `json:"message,omitempty"` -} - -type MessageUpdate struct { - Type string `json:"type,omitempty"` - Delta string `json:"delta,omitempty"` -} - -type AgentMessage struct { - Role string `json:"role,omitempty"` - Content json.RawMessage `json:"content,omitempty"` - Usage *Usage `json:"usage,omitempty"` -} - -type Usage struct { - Input int64 `json:"input,omitempty"` - Output int64 `json:"output,omitempty"` - CacheRead int64 `json:"cacheRead,omitempty"` - CacheWrite int64 `json:"cacheWrite,omitempty"` - Reasoning int64 `json:"reasoning,omitempty"` - Total int64 `json:"totalTokens,omitempty"` - Cost *UsageCost `json:"cost,omitempty"` -} - -type UsageCost struct { - Total float64 `json:"total,omitempty"` -} - -type contentBlock struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - Name string `json:"name,omitempty"` - ID string `json:"id,omitempty"` - Input json.RawMessage `json:"input,omitempty"` -} - -func toolMessage(name string, state console.AgentMessageToolState, input, output string) *console.AgentMessageAttributes { - tool := &console.AgentMessageToolAttributes{ - Name: new(name), - State: new(state), - Output: new(output), - } - if input != "" { - tool.Input = new(input) - } - return &console.AgentMessageAttributes{ - Role: console.AiRoleAssistant, - Message: "Called tool", - Metadata: &console.AgentMessageMetadataAttributes{ - Tool: tool, - }, - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/runtime_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/runtime_config.go new file mode 100644 index 0000000000..e7e35a6116 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/pi/runtime_config.go @@ -0,0 +1,160 @@ +package pi + +import ( + "fmt" + "strings" + + "github.com/samber/lo" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/common" +) + +const defaultModel = "gpt-5.4" + +const ( + providerPlural = "plural" + providerOpenAI = "openai" + providerOpenAICompatible = "openai-compatible" + providerAnthropic = "anthropic" + providerOllama = "ollama" + providerAzure = "azure-openai-responses" + providerAmazonBedrock = "amazon-bedrock" + providerBedrock = "bedrock" + providerGoogleVertex = "google-vertex" + providerVertex = "vertex" + providerXAI = "xai" +) + +type piSettings struct { + provider string + model string + endpoint string +} + +// ResolveSettings resolves Pi's model and timeout without exposing provider +// credentials to the provider-neutral runtime. +func (agent *Agent) ResolveSettings(run *agentrunv1.AgentRun) (toolv1.Settings, error) { + pi, err := agent.runConfig(run) + if err != nil { + return toolv1.Settings{}, err + } + + resolved := agent.resolveSettings(run, pi) + provider := agent.aiProvider(resolved.provider) + return toolv1.Settings{ + Mode: run.Mode, + Model: toolv1.ModelSelection{ + Provider: provider, + Name: resolved.model, + }, + Timeout: pi.Timeout, + Proxy: run.IsProxyEnabled(), + }, nil +} + +func (*Agent) resolveSettings(run *agentrunv1.AgentRun, config *agentrunv1.PiConfig) piSettings { + model := config.Model + if model == "" { + model = defaultModel + } + + provider := config.Provider + if run.IsProxyEnabled() { + return piSettings{ + provider: providerPlural, + model: proxymodel.ProxyModel(console.AgentRuntimeTypePi, model), + } + } + + if config.Endpoint != nil { + provider = providerOpenAI + } + if provider == "" { + provider = providerOpenAI + } + + return piSettings{ + provider: provider, + model: stripModelProvider(model, provider, config.Provider), + endpoint: lo.FromPtr(config.Endpoint), + } +} + +func (agent *Agent) resolvedProvider(config toolv1.Config) string { + pi, err := agent.runConfig(config.Run) + if err != nil { + return providerOpenAI + } + if config.Run.IsProxyEnabled() { + return providerPlural + } + if pi.Endpoint != nil { + return providerOpenAI + } + if pi.Provider == "" { + return providerOpenAI + } + return pi.Provider +} + +func stripModelProvider(model, provider, configuredProvider string) string { + for _, prefix := range []string{provider, configuredProvider} { + if prefix == "" { + continue + } + model = strings.TrimPrefix(model, prefix+"/") + } + return model +} + +func (*Agent) aiProvider(provider string) *console.AiProvider { + var mapped console.AiProvider + switch strings.ToLower(provider) { + case providerPlural, providerOpenAI: + mapped = console.AiProviderOpenai + case providerAnthropic: + mapped = console.AiProviderAnthropic + case providerOllama: + mapped = console.AiProviderOllama + case providerAzure: + mapped = console.AiProviderAzure + case providerAmazonBedrock, providerBedrock: + mapped = console.AiProviderBedrock + case providerGoogleVertex, providerVertex: + mapped = console.AiProviderVertex + case providerOpenAICompatible: + mapped = console.AiProviderOpenaiCompatible + case providerXAI: + mapped = console.AiProviderXai + default: + return nil + } + return &mapped +} + +func (agent *Agent) nativeSettings(config toolv1.Config, model string) (piSettings, error) { + pi, err := agent.runConfig(config.Run) + if err != nil { + return piSettings{}, err + } + resolved := agent.resolveSettings(config.Run, pi) + if model == "" { + model = resolved.model + } + resolved.model = model + if config.Run.IsProxyEnabled() { + resolved.endpoint = proxyEndpoint(agent.consoleURL, config.Run.IsStreamingProxyEnabled()) + } + return resolved, nil +} + +func proxyEndpoint(consoleURL string, streaming bool) string { + if streaming { + return common.AgentOpenAIBaseURL + } + return fmt.Sprintf("%s/ext/ai/v1", consoleURL) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/runtime_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/runtime_config_test.go new file mode 100644 index 0000000000..8b06375a2e --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/pi/runtime_config_test.go @@ -0,0 +1,61 @@ +package pi + +import ( + "testing" + "time" + + console "github.com/pluralsh/console/go/client" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/samber/lo" +) + +func TestAgentResolveSettings(t *testing.T) { + tests := []struct { + name string + provider string + model string + endpoint *string + proxy bool + wantProv console.AiProvider + wantName string + }{ + {name: "default", wantProv: console.AiProviderOpenai, wantName: defaultModel}, + {name: "native provider", provider: "anthropic", model: "claude-sonnet-4-6", wantProv: console.AiProviderAnthropic, wantName: "claude-sonnet-4-6"}, + {name: "native provider prefix", provider: "anthropic", model: "anthropic/claude-sonnet-4-6", wantProv: console.AiProviderAnthropic, wantName: "claude-sonnet-4-6"}, + {name: "azure responses provider", provider: providerAzure, model: "gpt-4.1", wantProv: console.AiProviderAzure, wantName: "gpt-4.1"}, + {name: "custom endpoint", provider: "litellm", model: "custom-model", endpoint: lo.ToPtr("https://llm.example/v1"), wantProv: console.AiProviderOpenai, wantName: "custom-model"}, + {name: "proxy", provider: "anthropic", model: "gpt-5.4", proxy: true, wantProv: console.AiProviderOpenai, wantName: "openai/gpt-5.4"}, + {name: "proxy preserves provider prefix", model: "openai/gpt-5.4", proxy: true, wantProv: console.AiProviderOpenai, wantName: "openai/gpt-5.4"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + run := piTestRun(console.AgentRunModeWrite, test.provider, test.model, test.endpoint, test.proxy) + settings, err := NewAgent(toolv1.Config{Run: run}).ResolveSettings(run) + if err != nil { + t.Fatalf("ResolveSettings() error = %v", err) + } + if settings.Model.Provider == nil || *settings.Model.Provider != test.wantProv { + t.Fatalf("provider = %v, want %q", settings.Model.Provider, test.wantProv) + } + if settings.Model.Name != test.wantName { + t.Fatalf("model = %q, want %q", settings.Model.Name, test.wantName) + } + if settings.Timeout != 9*time.Minute || settings.Proxy != test.proxy { + t.Fatalf("settings timeout/proxy = %s/%v", settings.Timeout, settings.Proxy) + } + }) + } +} + +func TestAgentResolveSettingsRejectsUnsupportedProvider(t *testing.T) { + for _, provider := range []string{"unsupported", "azure"} { + t.Run(provider, func(t *testing.T) { + run := piTestRun(console.AgentRunModeWrite, provider, "model", nil, false) + _, err := NewAgent(toolv1.Config{Run: run}).ResolveSettings(run) + if err == nil || err.Error() != `unsupported pi provider "`+provider+`"` { + t.Fatalf("ResolveSettings() error = %v", err) + } + }) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/transport.go new file mode 100644 index 0000000000..af416f6a9d --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/pi/transport.go @@ -0,0 +1,123 @@ +package pi + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/acp" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" +) + +const piACPBinary = "pi-acp" + +// Transport invokes Pi through its ACP adapter. It owns process launch and +// projects runtime settings into ACP identifiers; acp.Engine owns the +// protocol, event mapping, and session lifecycle. +type Transport struct { + agent *Agent + engine *acp.Engine + repositoryDir string +} + +var _ toolv1.Transport = (*Transport)(nil) + +// NewTransport creates a Pi ACP transport for an Agent. +func NewTransport(agent *Agent) (*Transport, error) { + if agent == nil { + return nil, errors.New("pi agent is not set") + } + config, err := agent.configWithPi() + if err != nil { + return nil, err + } + repositoryDir, err := filepath.Abs(config.RepositoryDir) + if err != nil { + return nil, fmt.Errorf("resolve pi repository directory: %w", err) + } + return &Transport{ + agent: agent, + engine: acp.NewEngine(acp.WithSessionRestorer(acp.LoadSession)), + repositoryDir: repositoryDir, + }, nil +} + +// Kind identifies this as an Agent Client Protocol transport. +func (*Transport) Kind() toolv1.TransportKind { + return toolv1.TransportKindACP +} + +// Capabilities reports the ACP features implemented by Pi. Pi performs +// filesystem operations locally, while shared write permission follows the +// agent run mode for any ACP filesystem requests. +func (transport *Transport) Capabilities() toolv1.TransportCapabilities { + return toolv1.TransportCapabilities{ + SessionResume: true, + ToolCallOutputStreaming: true, + UsageReporting: true, + FileSystemRead: true, + FileSystemWrite: transport.agent.config.Run.Mode == console.AgentRunModeWrite, + } +} + +// Turn launches pi-acp and delegates session lifecycle and event mapping to +// the provider-neutral ACP engine. +func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest, sink toolv1.TurnSink) (toolv1.TurnResult, error) { + if ctx == nil { + return toolv1.TurnResult{SessionID: request.SessionID}, errors.New("pi turn context is not set") + } + if err := ctx.Err(); err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + + modelID := transport.modelID(request.Settings) + process, err := transport.launchWithContext(ctx, request.Options) + if err != nil { + return toolv1.TurnResult{SessionID: request.SessionID}, err + } + result, err := transport.engine.Turn(ctx, process, acp.Request{ + Cwd: transport.repositoryDir, + Prompt: request.Prompt, + SessionID: request.SessionID, + Settings: acp.SessionSettings{ModelID: modelID}, + FileSystemWrite: transport.Capabilities().FileSystemWrite, + }, sink) + return toolv1.TurnResult{SessionID: result.SessionID}, err +} + +func (transport *Transport) modelID(settings toolv1.Settings) string { + provider := transport.agent.resolvedProvider(transport.agent.config) + model := settings.Model.Name + if model == "" { + model = defaultModel + } + if provider == providerPlural { + return provider + "/" + model + } + return provider + "/" + stripModelProvider(model, provider, "") +} + +func (transport *Transport) launch(options []exec.Option) (*exec.StdioProcess, error) { + return transport.launchWithContext(context.Background(), options) +} + +func (transport *Transport) launchWithContext(ctx context.Context, options []exec.Option) (*exec.StdioProcess, error) { + config := transport.agent.config + pi, err := transport.agent.runConfig(config.Run) + if err != nil { + return nil, err + } + + launchOptions := append([]exec.Option(nil), options...) + launchOptions = append(launchOptions, + exec.WithEnv(transport.agent.env(config)), + exec.WithDir(transport.repositoryDir), + exec.WithTimeout(pi.Timeout), + ) + // ACP owns cancellation ordering. The engine sends session/cancel before + // closing stdin or killing the process, so the child is detached from ctx. + return exec.StartWithStdio(context.WithoutCancel(ctx), piACPBinary, launchOptions...) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/transport_test.go new file mode 100644 index 0000000000..cb3764a7fe --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/pi/transport_test.go @@ -0,0 +1,58 @@ +package pi + +import ( + "testing" + + console "github.com/pluralsh/console/go/client" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestTransportCapabilitiesReflectRunMode(t *testing.T) { + for _, mode := range []console.AgentRunMode{ + console.AgentRunModeAnalyze, + console.AgentRunModeReview, + console.AgentRunModeWrite, + } { + t.Run(string(mode), func(t *testing.T) { + run := piTestRun(mode, "openai", "gpt-5.4", nil, false) + transport, err := NewTransport(NewAgent(toolv1.Config{ + WorkDir: t.TempDir(), + RepositoryDir: t.TempDir(), + Run: run, + })) + if err != nil { + t.Fatal(err) + } + capabilities := transport.Capabilities() + if !capabilities.SessionResume || !capabilities.ToolCallOutputStreaming || !capabilities.FileSystemRead { + t.Fatalf("capabilities = %#v", capabilities) + } + if capabilities.FileSystemWrite != (mode == console.AgentRunModeWrite) { + t.Fatalf("FileSystemWrite = %t, mode = %q", capabilities.FileSystemWrite, mode) + } + }) + } +} + +func TestTransportModelIDUsesNativeProvider(t *testing.T) { + endpoint := "https://llm.example/v1" + run := piTestRun(console.AgentRunModeWrite, "litellm", "custom-model", &endpoint, false) + agent := NewAgent(toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: run}) + transport, err := NewTransport(agent) + if err != nil { + t.Fatal(err) + } + if got := transport.modelID(toolv1.Settings{Model: toolv1.ModelSelection{Name: "custom-model"}}); got != "openai/custom-model" { + t.Fatalf("modelID = %q", got) + } + + proxyRun := piTestRun(console.AgentRunModeWrite, "", "gpt-5.4", nil, true) + proxyAgent := NewAgent(toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: proxyRun}) + proxyTransport, err := NewTransport(proxyAgent) + if err != nil { + t.Fatal(err) + } + if got := proxyTransport.modelID(toolv1.Settings{Model: toolv1.ModelSelection{Name: "openai/gpt-5.4"}}); got != "plural/openai/gpt-5.4" { + t.Fatalf("proxy modelID = %q", got) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go index db4fedc089..a98ed6bd63 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go @@ -55,7 +55,12 @@ func New(runtimeType console.AgentRuntimeType, config v1.Config) (v1.Tool, error } return v1.NewRuntime(config, agent, transport) case console.AgentRuntimeTypePi: - return pi.New(config), nil + agent := pi.NewAgent(config) + transport, err := pi.NewTransport(agent) + if err != nil { + return nil, err + } + return v1.NewRuntime(config, agent, transport) default: return nil, fmt.Errorf("unsupported agent run type: %s", runtimeType) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go index 09f7ed089d..a593e324c5 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go @@ -83,6 +83,22 @@ func TestNewComposesGeminiRuntime(t *testing.T) { } } +func TestNewComposesPiRuntime(t *testing.T) { + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: &agentrunv1.AgentRun{ + Mode: console.AgentRunModeWrite, + Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{ + Pi: &agentrunv1.PiConfig{Model: "gpt-5.4", Timeout: time.Minute}, + }}, + }} + created, err := New(console.AgentRuntimeTypePi, config) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if _, ok := created.(*toolv1.Runtime); !ok { + t.Fatalf("Pi factory returned %T, want *v1.Runtime", created) + } +} + func TestNewRejectsMissingAgentRun(t *testing.T) { if _, err := New(console.AgentRuntimeTypeClaude, toolv1.Config{}); err == nil { t.Fatal("New() error = nil, want missing agent run error") diff --git a/go/nexus/internal/middleware/auth.go b/go/nexus/internal/middleware/auth.go index 1fbbf032ae..2aff96040a 100644 --- a/go/nexus/internal/middleware/auth.go +++ b/go/nexus/internal/middleware/auth.go @@ -17,7 +17,7 @@ type ConsoleAuthenticator interface { // Auth creates an authentication middleware that validates tokens with Console // FR-3.1: Federated authentication to Console via gRPC -// FR-3.2: Support for Bearer tokens +// FR-3.2: Support for Bearer tokens and Gemini API key headers // FR-3.3: Return 403 for invalid tokens // FR-3.4: Return 401 for missing tokens // FR-3.5: No caching - validate on every request @@ -26,17 +26,16 @@ func Auth(authenticator ConsoleAuthenticator) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - logger.Error("missing authorization header", + token, authError := requestToken(r) + if authError != "" { + logger.Error(authError, zap.String("path", r.URL.Path), zap.String("method", r.Method), ) - writeJSONError(w, http.StatusUnauthorized, "missing authorization header") + writeJSONError(w, http.StatusUnauthorized, authError) return } - token := extractToken(authHeader) if token == "" { logger.Error("invalid authorization header format", zap.String("path", r.URL.Path), @@ -78,6 +77,25 @@ func Auth(authenticator ConsoleAuthenticator) func(http.Handler) http.Handler { } } +// requestToken extracts a Console token from the standard Bearer header or the +// Gemini API key header. Authorization takes precedence when both are set. +func requestToken(r *http.Request) (string, string) { + authHeader := r.Header.Get("Authorization") + if authHeader != "" { + token := extractToken(authHeader) + if token == "" { + return "", "invalid authorization header format" + } + return token, "" + } + + token := strings.TrimSpace(r.Header.Get("X-Goog-Api-Key")) + if token == "" { + return "", "missing authorization header" + } + return token, "" +} + // extractToken extracts the token from Authorization header // Supports: // - "Bearer " diff --git a/go/nexus/internal/middleware/auth_test.go b/go/nexus/internal/middleware/auth_test.go index ccd378d986..a3ec1f6314 100644 --- a/go/nexus/internal/middleware/auth_test.go +++ b/go/nexus/internal/middleware/auth_test.go @@ -65,6 +65,60 @@ func TestAuth_BearerToken(t *testing.T) { assert.Equal(t, "test-bearer-token", authenticator.calledWith) } +func TestAuth_GeminiAPIKeyHeader(t *testing.T) { + authenticator := &mockAuthenticator{authenticated: true} + middleware := Auth(authenticator) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("POST", "/gemini/v1beta/models/gemini:generateContent", nil) + req.Header.Set("x-goog-api-key", "console-token") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "console-token", authenticator.calledWith) +} + +func TestAuth_BearerHeaderTakesPrecedenceOverGeminiAPIKey(t *testing.T) { + authenticator := &mockAuthenticator{authenticated: true} + middleware := Auth(authenticator) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("POST", "/gemini/v1beta/models/gemini:generateContent", nil) + req.Header.Set("Authorization", "Bearer bearer-token") + req.Header.Set("x-goog-api-key", "gemini-token") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "bearer-token", authenticator.calledWith) +} + +func TestAuth_InvalidAuthorizationTakesPrecedenceOverGeminiAPIKey(t *testing.T) { + authenticator := &mockAuthenticator{authenticated: true} + middleware := Auth(authenticator) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be called with invalid Authorization") + })) + + req := httptest.NewRequest("POST", "/gemini/v1beta/models/gemini:generateContent", nil) + req.Header.Set("Authorization", "Basic credentials") + req.Header.Set("x-goog-api-key", "gemini-token") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, rec.Body.String(), "invalid authorization header format") + assert.Empty(t, authenticator.calledWith) +} + // TestAuth_InvalidToken tests FR-3.3: Return 403 for invalid tokens func TestAuth_InvalidToken(t *testing.T) { authenticator := &mockAuthenticator{authenticated: false} @@ -253,6 +307,37 @@ func TestExtractToken(t *testing.T) { } } +func TestRequestToken(t *testing.T) { + testCases := []struct { + name string + authority string + apiKey string + expected string + errorMsg string + }{ + {name: "missing headers", errorMsg: "missing authorization header"}, + {name: "gemini API key", apiKey: " gemini-token ", expected: "gemini-token"}, + {name: "invalid Authorization wins", authority: "Basic credentials", apiKey: "gemini-token", errorMsg: "invalid authorization header format"}, + {name: "bearer wins", authority: "Bearer bearer-token", apiKey: "gemini-token", expected: "bearer-token"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/gemini/v1beta/models/gemini:generateContent", nil) + if tc.authority != "" { + req.Header.Set("Authorization", tc.authority) + } + if tc.apiKey != "" { + req.Header.Set("X-Goog-Api-Key", tc.apiKey) + } + + got, errMessage := requestToken(req) + assert.Equal(t, tc.expected, got) + assert.Equal(t, tc.errorMsg, errMessage) + }) + } +} + // TestAuth_CaseInsensitivePrefix tests that Bearer/Deploy prefixes are case-insensitive func TestAuth_CaseInsensitivePrefix(t *testing.T) { testCases := []struct { diff --git a/go/nexus/internal/router/gemini.go b/go/nexus/internal/router/gemini.go index a4bd0a5985..0239dd73ae 100644 --- a/go/nexus/internal/router/gemini.go +++ b/go/nexus/internal/router/gemini.go @@ -13,13 +13,13 @@ import ( ) const ( - routeGeminiV1GenerateContent = "/gemini/v1/models/{model}:generateContent" - routeGeminiV1StreamGenerateContent = "/gemini/v1/models/{model}:streamGenerateContent" - routeGeminiV1CountTokens = "/gemini/v1/models/{model}:countTokens" + routeGeminiV1GenerateContent = "/gemini/v1/models/{model:.*}:generateContent" + routeGeminiV1StreamGenerateContent = "/gemini/v1/models/{model:.*}:streamGenerateContent" + routeGeminiV1CountTokens = "/gemini/v1/models/{model:.*}:countTokens" - routeGeminiV1BetaGenerateContent = "/gemini/v1beta/models/{model}:generateContent" - routeGeminiV1BetaStreamGenerateContent = "/gemini/v1beta/models/{model}:streamGenerateContent" - routeGeminiV1BetaCountTokens = "/gemini/v1beta/models/{model}:countTokens" + routeGeminiV1BetaGenerateContent = "/gemini/v1beta/models/{model:.*}:generateContent" + routeGeminiV1BetaStreamGenerateContent = "/gemini/v1beta/models/{model:.*}:streamGenerateContent" + routeGeminiV1BetaCountTokens = "/gemini/v1beta/models/{model:.*}:countTokens" ) type geminiContextKey string diff --git a/go/nexus/internal/router/gemini_test.go b/go/nexus/internal/router/gemini_test.go new file mode 100644 index 0000000000..a9e04ca42c --- /dev/null +++ b/go/nexus/internal/router/gemini_test.go @@ -0,0 +1,32 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func TestGeminiRouteCapturesProviderPrefixedModel(t *testing.T) { + router := chi.NewRouter() + router.Post(routeGeminiV1BetaGenerateContent, func(w http.ResponseWriter, r *http.Request) { + if got := chi.URLParam(r, "model"); got != "vertex/gemini-custom" { + t.Errorf("model path parameter = %q, want %q", got, "vertex/gemini-custom") + } + w.WriteHeader(http.StatusNoContent) + }) + + req := httptest.NewRequest( + http.MethodPost, + "/gemini/v1beta/models/vertex/gemini-custom:generateContent", + nil, + ) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } +} From 980c7387ce7cdb5bac33a4ced3e113997c86e25a Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 11:27:27 +0200 Subject: [PATCH 36/46] refactor(gemini): improve error event handling and variable initialization - Replaced implicit empty string initialization with explicit `var` declaration for `prefix`. - Added a newline for readability before sink message construction. --- .../pkg/agentrun-harness/tool/gemini/stream.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go index ba9524589c..7ceab85d20 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go @@ -261,7 +261,7 @@ func (turn *streamTurn) handleError(line []byte) error { return errors.New("invalid gemini error event: message is required") } - prefix := "" + var prefix string switch event.Severity { case streamSeverityWarning: prefix = "Warning" @@ -271,6 +271,7 @@ func (turn *streamTurn) handleError(line []byte) error { default: return fmt.Errorf("invalid gemini error event: unsupported severity %q", event.Severity) } + turn.sink.Message(&console.AgentMessageAttributes{ Role: console.AiRoleSystem, Message: fmt.Sprintf("%s: %s", prefix, event.Message), }, "") From ef8a85a8c3105c86df65e89722bd6398903c1e7c Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 12:17:09 +0200 Subject: [PATCH 37/46] refactor(gemini): update tool settings structure, improve tests, and modernize transport logic - Replaced `coreTools` with `tools.core` and adjusted related test logic for consistency. - Renamed and updated various tools in `settings.json.gotmpl` template for better representation and conformance. - Deprecated `excludeTools` in favor of the nested `tools.exclude` structure. - Removed `RepositoryDir` field from `ConfigTemplateInput` and transport logic. - Updated transport to use `repositoryDir` instead of `workDir` for execution context. - Enhanced templates and tests for quoting model names and excluding auxiliary directories from settings. - Refined Gemini compatibility instructions to explicitly note restrictions on `.git` internals. - Improved test coverage for tool settings and transport execution, including stricter validation for deprecated fields. --- .../pkg/agentrun-harness/tool/gemini/agent.go | 2 + .../tool/gemini/agent_config.go | 1 - .../agentrun-harness/tool/gemini/settings.go | 1 - .../tool/gemini/settings_test.go | 63 +++++++++++-------- .../gemini/templates/settings.json.gotmpl | 62 +++++++++--------- .../agentrun-harness/tool/gemini/transport.go | 12 ++-- .../tool/gemini/transport_test.go | 4 +- 7 files changed, 77 insertions(+), 68 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go index 4b6dc731d4..0185074db8 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go @@ -21,6 +21,8 @@ const ( geminiCompatibilityInstructions = ` Gemini CLI compatibility: do not use command substitution forms such as $(), backticks, <(), or >(), because the CLI blocks them even in yolo mode. Use arithmetic loops, shell builtins, temporary files, or separate commands instead. + +Git metadata: inspect repository history and state with git commands. Do not use file tools to read .git internals such as .git/HEAD, because Gemini CLI restricts direct access to those paths. ` ) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go index 29c28ccc6f..98cbc26194 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go @@ -22,7 +22,6 @@ func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error input := &ConfigTemplateInput{ Model: model, - RepositoryDir: config.RepositoryDir, AgentRunMode: config.Run.Mode, InactivityTimeout: int64(gemini.InactivityTimeout.Seconds()), } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go index f2ceece898..2607e39455 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go @@ -17,7 +17,6 @@ const SettingsFileName = "settings.json" type ConfigTemplateInput struct { Model string - RepositoryDir string AgentRunMode console.AgentRunMode InactivityTimeout int64 } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go index cfd435af80..19e2fd5ffc 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go @@ -12,8 +12,7 @@ import ( //nolint:gocyclo func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { baseInput := &ConfigTemplateInput{ - Model: "gemini-3.1-flash-lite", - RepositoryDir: "/repo", + Model: "gemini-3.1-flash-lite", } t.Run("plural MCP server uses in-pod remote URL", func(t *testing.T) { @@ -73,7 +72,7 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { } }) - t.Run("coreTools differ by mode", func(t *testing.T) { + t.Run("tools.core differs by mode", func(t *testing.T) { writeInput := *baseInput writeInput.AgentRunMode = console.AgentRunModeWrite _, writeContent, err := settings(&writeInput) @@ -106,36 +105,48 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { t.Fatalf("REVIEW content not valid JSON: %v", err) } - writeCoreTools, _ := writeOut["coreTools"].([]any) - analyzeCoreTools, _ := analyzeOut["coreTools"].([]any) - reviewCoreTools, _ := reviewOut["coreTools"].([]any) + writeTools := writeOut["tools"].(map[string]any) + analyzeTools := analyzeOut["tools"].(map[string]any) + reviewTools := reviewOut["tools"].(map[string]any) + writeCoreTools, _ := writeTools["core"].([]any) + analyzeCoreTools, _ := analyzeTools["core"].([]any) + reviewCoreTools, _ := reviewTools["core"].([]any) hasWriteFile := false for _, t := range writeCoreTools { - if s, ok := t.(string); ok && s == "WriteFileTool" { + if s, ok := t.(string); ok && s == "write_file" { hasWriteFile = true break } } if !hasWriteFile { - t.Error("WRITE mode coreTools should include WriteFileTool") + t.Error("WRITE mode tools.core should include write_file") } hasWriteInAnalyze := false for _, t := range analyzeCoreTools { - if s, ok := t.(string); ok && (s == "WriteFileTool" || s == "EditTool") { + if s, ok := t.(string); ok && (s == "write_file" || s == "replace") { hasWriteInAnalyze = true break } } if hasWriteInAnalyze { - t.Error("ANALYZE mode coreTools should not include WriteFileTool or EditTool") + t.Error("ANALYZE mode tools.core should not include write_file or replace") } for _, tool := range reviewCoreTools { - if tool == "WriteFileTool" || tool == "EditTool" { - t.Error("REVIEW mode coreTools should not include WriteFileTool or EditTool") + if tool == "write_file" || tool == "replace" { + t.Error("REVIEW mode tools.core should not include write_file or replace") } } + if _, ok := writeOut["coreTools"]; ok { + t.Error("settings unexpectedly contains deprecated top-level coreTools") + } + if _, ok := writeOut["excludeTools"]; ok { + t.Error("settings unexpectedly contains deprecated top-level excludeTools") + } + if writeTools["shell"].(map[string]any)["inactivityTimeout"] != float64(baseInput.InactivityTimeout) { + t.Errorf("tools.shell.inactivityTimeout = %v, want %d", writeTools["shell"].(map[string]any)["inactivityTimeout"], baseInput.InactivityTimeout) + } }) t.Run("progress-only topic tool is excluded", func(t *testing.T) { @@ -148,24 +159,25 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { } var out struct { - ExcludeTools []string `json:"excludeTools"` + Tools struct { + ExcludeTools []string `json:"exclude"` + } `json:"tools"` } if err := json.Unmarshal([]byte(content), &out); err != nil { t.Fatalf("generated content is not valid JSON: %v", err) } - for _, tool := range out.ExcludeTools { - if tool == "UpdateTopicTool" { + for _, tool := range out.Tools.ExcludeTools { + if tool == "update_topic" { return } } - t.Errorf("excludeTools = %q, want UpdateTopicTool", out.ExcludeTools) + t.Errorf("tools.exclude = %q, want update_topic", out.Tools.ExcludeTools) }) - t.Run("quotes model and repository directory", func(t *testing.T) { + t.Run("quotes model", func(t *testing.T) { input := *baseInput input.Model = "gemini-3.1-\"flash\"" - input.RepositoryDir = "/repo/with \"quotes\"" _, content, err := settings(&input) if err != nil { @@ -173,8 +185,10 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { } var out struct { - IncludeDirectories []string `json:"includeDirectories"` - Model struct { + Context struct { + IncludeDirectories []string `json:"includeDirectories"` + } `json:"context"` + Model struct { Name string `json:"name"` } `json:"model"` } @@ -184,8 +198,8 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { if out.Model.Name != input.Model { t.Errorf("model = %q, want %q", out.Model.Name, input.Model) } - if len(out.IncludeDirectories) != 2 || out.IncludeDirectories[1] != input.RepositoryDir { - t.Errorf("includeDirectories = %#v, want repository %q", out.IncludeDirectories, input.RepositoryDir) + if len(out.Context.IncludeDirectories) != 0 { + t.Errorf("context.includeDirectories = %#v, want no auxiliary directories", out.Context.IncludeDirectories) } }) } @@ -194,9 +208,8 @@ func TestSettingsTemplate_ExternalMCPServer(t *testing.T) { t.Setenv(mcp.EnvServers, `[{"name":"linear","url":"https://mcp.linear.app/mcp","allowedTools":["list_issues"],"headers":{"Authorization":"Bearer secret"}}]`) input := &ConfigTemplateInput{ - Model: "gemini-3.1-flash-lite", - RepositoryDir: "/repo", - AgentRunMode: console.AgentRunModeWrite, + Model: "gemini-3.1-flash-lite", + AgentRunMode: console.AgentRunModeWrite, } _, content, err := settings(input) if err != nil { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl index 7c2b2b571c..ccf78fb933 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl @@ -10,36 +10,37 @@ "context": { "fileName": "AGENTS.md" }, - "coreTools": {{ if eq .AgentRunMode "WRITE" }}[ - "ReadFileTool", - "ReadManyFilesTool", - "WriteFileTool", - "EditTool", - "GlobTool", - "LSTool", - "GrepTool", - "ShellTool", - "WebSearchTool", - "WebFetchTool", - "MemoryTool" + "tools": { + "core": {{ if eq .AgentRunMode "WRITE" }}[ + "read_file", + "read_many_files", + "write_file", + "replace", + "glob", + "list_directory", + "grep_search", + "run_shell_command", + "google_web_search", + "web_fetch", + "save_memory" ]{{ else }}[ - "ReadFileTool", - "ReadManyFilesTool", - "GlobTool", - "LSTool", - "GrepTool", - "ShellTool(ls, cd, pwd, git status, git diff, git branch, git log, git show, git merge-base, git rev-parse, head, tail, cat, grep, rg, find)", - "WebSearchTool", - "MemoryTool" + "read_file", + "read_many_files", + "glob", + "list_directory", + "grep_search", + "run_shell_command(ls, cd, pwd, git status, git diff, git branch, git log, git show, git merge-base, git rev-parse, head, tail, cat, grep, rg, find)", + "google_web_search", + "save_memory" ]{{ end }}, - "excludeTools": [ - "UpdateTopicTool", - "ShellTool(rm -rf)" - ], - "includeDirectories": [ - "/plural/contexts", - {{ quote .RepositoryDir }} - ], + "exclude": [ + "update_topic", + "run_shell_command(rm -rf)" + ], + "shell": { + "inactivityTimeout": {{ .InactivityTimeout }} + } + }, "model": { "name": {{ quote .Model }} }, @@ -64,10 +65,5 @@ }, "telemetry": { "enabled": false - }, - "tools": { - "shell": { - "inactivityTimeout": {{ .InactivityTimeout }} - } } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go index ab08268cd9..2c03ef0867 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go @@ -30,8 +30,8 @@ const ( ) type Transport struct { - agent *Agent - workDir string + agent *Agent + repositoryDir string } var _ toolv1.Transport = (*Transport)(nil) @@ -44,12 +44,12 @@ func NewTransport(agent *Agent) (*Transport, error) { if err != nil { return nil, err } - workDir, err := filepath.Abs(config.WorkDir) + repositoryDir, err := filepath.Abs(config.RepositoryDir) if err != nil { - return nil, fmt.Errorf("resolve gemini work directory: %w", err) + return nil, fmt.Errorf("resolve gemini repository directory: %w", err) } - return &Transport{agent: agent, workDir: workDir}, nil + return &Transport{agent: agent, repositoryDir: repositoryDir}, nil } func (*Transport) Kind() toolv1.TransportKind { @@ -99,7 +99,7 @@ func (transport *Transport) executable(request toolv1.TurnRequest) (exec.Executa launchOptions, exec.WithArgs(transport.args(request)), exec.WithEnv(transport.agent.env(config)), - exec.WithDir(transport.workDir), + exec.WithDir(transport.repositoryDir), exec.WithTimeout(gemini.Timeout), ) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go index f8b0f48104..9c1d6a3f50 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go @@ -98,7 +98,7 @@ func TestTransportArgs(t *testing.T) { } } -func TestTransportTurnParsesStreamAndPreservesExecutionOptions(t *testing.T) { +func TestTransportTurnUsesRepositoryCWDAndPreservesExecutionOptions(t *testing.T) { binDir := t.TempDir() writeGeminiBinary(t, binDir) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) @@ -146,7 +146,7 @@ func TestTransportTurnParsesStreamAndPreservesExecutionOptions(t *testing.T) { "arg=--output-format", "arg=stream-json", "arg=--model", "arg=gemini-custom", "arg=--approval-mode", "arg=yolo", "arg=--prompt", "arg=implement feature with spaces", "key=api-key", "endpoint=https://api.example", "trust=true", - "home=" + transport.agent.config.WorkDir, "cwd=" + transport.workDir, + "home=" + transport.agent.config.WorkDir, "cwd=" + transport.repositoryDir, } for _, want := range wantLaunchLines { if !strings.Contains(string(launch), want+"\n") { From 59a76fecca42dcc17acf91194a8d59700fc3a6c4 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 12:48:27 +0200 Subject: [PATCH 38/46] refactor(acp, gemini): improve file handling and update test logic - Enhanced `readTextFile` with cancellation handling and introduced `cancelableTextFile` for better resource cleanup. - Refactored core file processing into modular methods to streamline operations and improve readability. - Updated `settings_test.go` to use `httpUrl` instead of `url` for MCP server configuration, adjusting error messages accordingly. - Added new test to validate cancellation handling during file reads. - Updated Gemini settings template to rename `url` to `httpUrl`, ensuring consistency with test logic. --- .../pkg/agentrun-harness/tool/acp/client.go | 69 +++++++++++++++---- .../agentrun-harness/tool/acp/client_test.go | 60 ++++++++++++++++ .../tool/gemini/settings_test.go | 11 +-- .../gemini/templates/settings.json.gotmpl | 2 +- 4 files changed, 125 insertions(+), 17 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go index 345686dfab..03a4e65dac 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "syscall" acpsdk "github.com/coder/acp-go-sdk" @@ -37,10 +38,41 @@ func (client *client) ReadTextFile(ctx context.Context, request acpsdk.ReadTextF if err != nil { return acpsdk.ReadTextFileResponse{}, err } - defer file.Close() - reader := bufio.NewReader(io.LimitReader(&contextReader{ctx: ctx, reader: file}, maxTextFileBytes+1)) - exhausted, err := client.skipTextFileLines(reader, request.Line, request.Path) + return client.readTextFile(ctx, file, request) +} + +func (client *client) readTextFile(ctx context.Context, reader io.ReadCloser, request acpsdk.ReadTextFileRequest) (acpsdk.ReadTextFileResponse, error) { + file := newCancelableTextFile(reader) + if err := ctx.Err(); err != nil { + file.closeAsync() + return acpsdk.ReadTextFileResponse{}, err + } + + type result struct { + response acpsdk.ReadTextFileResponse + err error + } + resultCh := make(chan result, 1) + go func() { + response, err := client.readTextFileResponse(file, request) + file.closeAsync() + <-file.closed + resultCh <- result{response: response, err: err} + }() + + select { + case <-ctx.Done(): + file.closeAsync() + return acpsdk.ReadTextFileResponse{}, ctx.Err() + case result := <-resultCh: + return result.response, result.err + } +} + +func (client *client) readTextFileResponse(reader io.Reader, request acpsdk.ReadTextFileRequest) (acpsdk.ReadTextFileResponse, error) { + buffered := bufio.NewReader(io.LimitReader(reader, maxTextFileBytes+1)) + exhausted, err := client.skipTextFileLines(buffered, request.Line, request.Path) if err != nil { return acpsdk.ReadTextFileResponse{}, err } @@ -48,7 +80,7 @@ func (client *client) ReadTextFile(ctx context.Context, request acpsdk.ReadTextF return acpsdk.ReadTextFileResponse{}, nil } - content, err := client.readTextFileContent(reader, request.Path, request.Limit) + content, err := client.readTextFileContent(buffered, request.Path, request.Limit) if err != nil { return acpsdk.ReadTextFileResponse{}, err } @@ -135,16 +167,29 @@ func (client *client) readTextFileContent(reader *bufio.Reader, path string, lim return strings.Join(lines, "\n"), nil } -type contextReader struct { - ctx context.Context - reader io.Reader +type cancelableTextFile struct { + reader io.ReadCloser + closeOnce sync.Once + closed chan struct{} } -func (reader *contextReader) Read(buffer []byte) (int, error) { - if err := reader.ctx.Err(); err != nil { - return 0, err - } - return reader.reader.Read(buffer) +func newCancelableTextFile(reader io.ReadCloser) *cancelableTextFile { + return &cancelableTextFile{reader: reader, closed: make(chan struct{})} +} + +func (file *cancelableTextFile) Read(buffer []byte) (int, error) { + return file.reader.Read(buffer) +} + +func (file *cancelableTextFile) closeAsync() { + file.closeOnce.Do(func() { + // Closing an owned os.File usually interrupts its Read. Some filesystems + // leave the syscall uninterruptible, so Close must not block this caller. + go func() { + _ = file.reader.Close() + close(file.closed) + }() + }) } func (client *client) WriteTextFile(ctx context.Context, request acpsdk.WriteTextFileRequest) (acpsdk.WriteTextFileResponse, error) { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go index ada85fa948..567592fa99 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -43,6 +43,66 @@ func TestClientReadsAndWritesTextFiles(t *testing.T) { } } +func TestClientReadTextFileCancellationInterruptsBlockedRead(t *testing.T) { + underlying := &stalledReadCloser{ + readStarted: make(chan struct{}), + closeStarted: make(chan struct{}), + releaseRead: make(chan struct{}), + releaseClose: make(chan struct{}), + } + t.Cleanup(func() { + close(underlying.releaseRead) + close(underlying.releaseClose) + }) + + ctx, cancel := context.WithCancel(context.Background()) + readDone := make(chan error, 1) + go func() { + _, err := (&client{}).readTextFile(ctx, underlying, acpsdk.ReadTextFileRequest{Path: "/file.txt"}) + readDone <- err + }() + + select { + case <-underlying.readStarted: + case <-time.After(time.Second): + t.Fatal("underlying read did not start") + } + cancel() + + select { + case err := <-readDone: + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled read error = %v, want context canceled", err) + } + case <-time.After(time.Second): + t.Fatal("canceled read remained blocked") + } + select { + case <-underlying.closeStarted: + case <-time.After(time.Second): + t.Fatal("cancellation did not attempt to close the underlying reader") + } +} + +type stalledReadCloser struct { + readStarted chan struct{} + closeStarted chan struct{} + releaseRead chan struct{} + releaseClose chan struct{} +} + +func (reader *stalledReadCloser) Read([]byte) (int, error) { + close(reader.readStarted) + <-reader.releaseRead + return 0, errors.New("read released") +} + +func (reader *stalledReadCloser) Close() error { + close(reader.closeStarted) + <-reader.releaseClose + return nil +} + func TestClientRejectsWritesWithoutPermission(t *testing.T) { acpClient, directory := newTestClient(t, false) path := filepath.Join(directory, "nested", "file.txt") diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go index 19e2fd5ffc..c0b3a90385 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go @@ -15,7 +15,7 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { Model: "gemini-3.1-flash-lite", } - t.Run("plural MCP server uses in-pod remote URL", func(t *testing.T) { + t.Run("plural MCP server uses in-pod streamable HTTP URL", func(t *testing.T) { input := *baseInput input.AgentRunMode = console.AgentRunModeWrite @@ -38,12 +38,15 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { t.Fatal("mcpServers.plural missing or not an object") } - url, ok := plural["url"].(string) + url, ok := plural["httpUrl"].(string) if !ok { - t.Fatal("mcpServers.plural.url missing or not a string") + t.Fatal("mcpServers.plural.httpUrl missing or not a string") } if url != "http://127.0.0.1:8080/mcp" { - t.Errorf("expected mcpServers.plural.url=http://127.0.0.1:8080/mcp, got %q", url) + t.Errorf("expected mcpServers.plural.httpUrl=http://127.0.0.1:8080/mcp, got %q", url) + } + if _, ok := plural["url"]; ok { + t.Fatal("mcpServers.plural unexpectedly configured with SSE url") } }) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl index ccf78fb933..22dede643c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl @@ -46,7 +46,7 @@ }, "mcpServers": { "plural": { - "url": "http://127.0.0.1:8080/mcp", + "httpUrl": "http://127.0.0.1:8080/mcp", "description": "Plural MCP Server", "trust": true }, From 575a7ef34e52ad706268a1e6fe716b4ddf3c4fdb Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 14:03:45 +0200 Subject: [PATCH 39/46] refactor(acp, gemini): improve file handling, tool runtime, and tests - Updated tool result handling to replace empty-output values with default success message. - Adjusted test logic for `GeminiInactivityTimeout` to prioritize explicit environment values. - Introduced private default path and policy file creation for Gemini runtime setup. - Enhanced ACP client for text file reads with resource management and context cancellation. - Modified terminal-related methods to return unavailable state in unattended runs. - Refined settings and templates with improved variable redaction and modernized permissions handling. - Added stricter validation and error handling in ACP transport and file management. --- .../pkg/agentrun-harness/agentrun/v1/types.go | 6 +- .../agentrun/v1/types_test.go | 22 ++ .../pkg/agentrun-harness/tool/acp/client.go | 121 ++++---- .../agentrun-harness/tool/acp/client_test.go | 268 ++++++++++++++++-- .../agentrun-harness/tool/acp/engine_test.go | 15 +- .../pkg/agentrun-harness/tool/acp/session.go | 20 +- .../pkg/agentrun-harness/tool/gemini/agent.go | 10 + .../tool/gemini/agent_config.go | 59 +++- .../tool/gemini/agent_test.go | 167 ++++++++++- .../tool/gemini/settings_test.go | 56 ++-- .../agentrun-harness/tool/gemini/stream.go | 6 +- .../gemini/templates/settings.json.gotmpl | 9 +- .../tool/gemini/testdata/success.jsonl | 2 +- .../agentrun-harness/tool/gemini/transport.go | 15 +- .../tool/gemini/transport_test.go | 17 +- 15 files changed, 658 insertions(+), 135 deletions(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types.go b/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types.go index ba15599db9..dd23d232c8 100644 --- a/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types.go +++ b/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types.go @@ -21,9 +21,9 @@ const ( // for a command before it is terminated. defaultBashMaxTimeout = defaultTimeout - // defaultInactivityTimeout is the default Gemini CLI timeout for the process, + // defaultGeminiInactivityTimeout is the default Gemini CLI timeout for the process, // tool call, or session if there is no output or input detected. - defaultInactivityTimeout = defaultBashTimeout + defaultGeminiInactivityTimeout = 5 * time.Minute defaultBabysitInterval = int64(60) // seconds between PR/SCM babysit checks ) @@ -273,7 +273,7 @@ func (ar *AgentRun) fromEnv(runtime *console.AgentRuntimeFragment) *AgentRuntime APIKey: helpers.GetPluralEnv(controller.EnvGeminiAPIKey, ""), Model: helpers.GetPluralEnv(controller.EnvGeminiModel, ""), Timeout: helpers.GetPluralEnvDuration(controller.EnvExecTimeout, defaultTimeout), - InactivityTimeout: helpers.GetPluralEnvDuration(controller.EnvGeminiInactivityTimeout, defaultInactivityTimeout), + InactivityTimeout: helpers.GetPluralEnvDuration(controller.EnvGeminiInactivityTimeout, defaultGeminiInactivityTimeout), } if endpoint := helpers.GetPluralEnv(controller.EnvGeminiEndpoint, ""); endpoint != "" { config.Gemini.Endpoint = &endpoint diff --git a/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types_test.go b/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types_test.go index 49a06992c8..19bd9b3b75 100644 --- a/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types_test.go @@ -2,10 +2,32 @@ package v1 import ( "testing" + "time" console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/internal/controller" ) +func TestAgentRunFromEnvGeminiInactivityTimeout(t *testing.T) { + runtime := &console.AgentRuntimeFragment{Type: console.AgentRuntimeTypeGemini} + + t.Run("defaults to Gemini CLI timeout", func(t *testing.T) { + t.Setenv(controller.EnvGeminiInactivityTimeout, "") + config := new(AgentRun).fromEnv(runtime).Config.Gemini + if config.InactivityTimeout != 5*time.Minute { + t.Fatalf("Gemini inactivity timeout = %s, want 5m", config.InactivityTimeout) + } + }) + + t.Run("explicit environment value overrides default", func(t *testing.T) { + t.Setenv(controller.EnvGeminiInactivityTimeout, "47s") + config := new(AgentRun).fromEnv(runtime).Config.Gemini + if config.InactivityTimeout != 47*time.Second { + t.Fatalf("Gemini inactivity timeout = %s, want 47s", config.InactivityTimeout) + } + }) +} + func TestExaConnectionEnabled(t *testing.T) { run := &AgentRun{Runtime: &AgentRuntime{ExaConnection: true}} if !run.ExaConnectionEnabled() { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go index 03a4e65dac..382aad6725 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go @@ -9,30 +9,49 @@ import ( "os" "path/filepath" "strings" - "sync" "syscall" acpsdk "github.com/coder/acp-go-sdk" ) -const maxTextFileBytes = 16 << 20 +const ( + maxTextFileBytes = 16 << 20 + maxConcurrentTextFileReads = 1 +) var _ acpsdk.Client = (*client)(nil) +var ( + errTerminalUnavailable = errors.New("acp terminal requests are unavailable in unattended runs") + errReadGateUninitialized = errors.New("acp client text file read gate is not initialized") +) + type client struct { - turn *turnState - cwd string - root *os.Root - fileSystemWrite bool + turn *turnState + cwd string + root *os.Root + textFileReadSlots chan struct{} + fileSystemWrite bool +} + +func newClient(turn *turnState, cwd string, root *os.Root, fileSystemWrite bool) *client { + return &client{ + turn: turn, + cwd: cwd, + root: root, + textFileReadSlots: make(chan struct{}, maxConcurrentTextFileReads), + fileSystemWrite: fileSystemWrite, + } } func (client *client) ReadTextFile(ctx context.Context, request acpsdk.ReadTextFileRequest) (acpsdk.ReadTextFileResponse, error) { if err := client.validateSession(request.SessionId); err != nil { return acpsdk.ReadTextFileResponse{}, err } - if err := ctx.Err(); err != nil { + if err := client.acquireTextFileRead(ctx); err != nil { return acpsdk.ReadTextFileResponse{}, err } + defer client.releaseTextFileRead() file, err := client.openTextFile(request.Path) if err != nil { @@ -42,32 +61,41 @@ func (client *client) ReadTextFile(ctx context.Context, request acpsdk.ReadTextF return client.readTextFile(ctx, file, request) } -func (client *client) readTextFile(ctx context.Context, reader io.ReadCloser, request acpsdk.ReadTextFileRequest) (acpsdk.ReadTextFileResponse, error) { - file := newCancelableTextFile(reader) +func (client *client) acquireTextFileRead(ctx context.Context) error { + if client.textFileReadSlots == nil { + return errReadGateUninitialized + } if err := ctx.Err(); err != nil { - file.closeAsync() - return acpsdk.ReadTextFileResponse{}, err + return err } - type result struct { - response acpsdk.ReadTextFileResponse - err error + select { + case client.textFileReadSlots <- struct{}{}: + if err := ctx.Err(); err != nil { + client.releaseTextFileRead() + return err + } + + return nil + case <-ctx.Done(): + return ctx.Err() } - resultCh := make(chan result, 1) - go func() { - response, err := client.readTextFileResponse(file, request) - file.closeAsync() - <-file.closed - resultCh <- result{response: response, err: err} +} + +func (client *client) releaseTextFileRead() { + <-client.textFileReadSlots +} + +func (client *client) readTextFile(ctx context.Context, reader io.ReadCloser, request acpsdk.ReadTextFileRequest) (response acpsdk.ReadTextFileResponse, err error) { + defer func() { + err = errors.Join(err, reader.Close()) }() - select { - case <-ctx.Done(): - file.closeAsync() - return acpsdk.ReadTextFileResponse{}, ctx.Err() - case result := <-resultCh: - return result.response, result.err + if err := ctx.Err(); err != nil { + return acpsdk.ReadTextFileResponse{}, err } + + return client.readTextFileResponse(&contextReader{ctx: ctx, reader: reader}, request) } func (client *client) readTextFileResponse(reader io.Reader, request acpsdk.ReadTextFileRequest) (acpsdk.ReadTextFileResponse, error) { @@ -167,29 +195,22 @@ func (client *client) readTextFileContent(reader *bufio.Reader, path string, lim return strings.Join(lines, "\n"), nil } -type cancelableTextFile struct { - reader io.ReadCloser - closeOnce sync.Once - closed chan struct{} +type contextReader struct { + ctx context.Context + reader io.Reader } -func newCancelableTextFile(reader io.ReadCloser) *cancelableTextFile { - return &cancelableTextFile{reader: reader, closed: make(chan struct{})} -} +func (reader *contextReader) Read(buffer []byte) (int, error) { + if err := reader.ctx.Err(); err != nil { + return 0, err + } -func (file *cancelableTextFile) Read(buffer []byte) (int, error) { - return file.reader.Read(buffer) -} + read, err := reader.reader.Read(buffer) + if contextErr := reader.ctx.Err(); contextErr != nil { + return 0, contextErr + } -func (file *cancelableTextFile) closeAsync() { - file.closeOnce.Do(func() { - // Closing an owned os.File usually interrupts its Read. Some filesystems - // leave the syscall uninterruptible, so Close must not block this caller. - go func() { - _ = file.reader.Close() - close(file.closed) - }() - }) + return read, err } func (client *client) WriteTextFile(ctx context.Context, request acpsdk.WriteTextFileRequest) (acpsdk.WriteTextFileResponse, error) { @@ -253,23 +274,23 @@ func (client *client) RequestPermission(_ context.Context, request acpsdk.Reques } func (*client) CreateTerminal(context.Context, acpsdk.CreateTerminalRequest) (acpsdk.CreateTerminalResponse, error) { - return acpsdk.CreateTerminalResponse{TerminalId: "terminal-1"}, nil + return acpsdk.CreateTerminalResponse{}, errTerminalUnavailable } func (*client) KillTerminal(context.Context, acpsdk.KillTerminalRequest) (acpsdk.KillTerminalResponse, error) { - return acpsdk.KillTerminalResponse{}, nil + return acpsdk.KillTerminalResponse{}, errTerminalUnavailable } func (*client) TerminalOutput(context.Context, acpsdk.TerminalOutputRequest) (acpsdk.TerminalOutputResponse, error) { - return acpsdk.TerminalOutputResponse{Output: "", Truncated: false}, nil + return acpsdk.TerminalOutputResponse{}, errTerminalUnavailable } func (*client) ReleaseTerminal(context.Context, acpsdk.ReleaseTerminalRequest) (acpsdk.ReleaseTerminalResponse, error) { - return acpsdk.ReleaseTerminalResponse{}, nil + return acpsdk.ReleaseTerminalResponse{}, errTerminalUnavailable } func (*client) WaitForTerminalExit(context.Context, acpsdk.WaitForTerminalExitRequest) (acpsdk.WaitForTerminalExitResponse, error) { - return acpsdk.WaitForTerminalExitResponse{}, nil + return acpsdk.WaitForTerminalExitResponse{}, errTerminalUnavailable } func (client *client) SessionUpdate(_ context.Context, notification acpsdk.SessionNotification) error { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go index 567592fa99..af36c238cd 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -3,9 +3,11 @@ package acp import ( "context" "errors" + "io" "os" "path/filepath" "strings" + "sync" "syscall" "testing" "time" @@ -24,7 +26,12 @@ func newTestClient(t *testing.T, fileSystemWrite bool) (*client, string) { } t.Cleanup(func() { _ = root.Close() }) engine := NewEngine() - return &client{turn: newTurn(engine, &testSink{}, "session-1"), cwd: directory, root: root, fileSystemWrite: fileSystemWrite}, directory + return newClient( + newTurn(engine, &testSink{}, "session-1"), + directory, + root, + fileSystemWrite, + ), directory } func TestClientReadsAndWritesTextFiles(t *testing.T) { @@ -43,17 +50,101 @@ func TestClientReadsAndWritesTextFiles(t *testing.T) { } } -func TestClientReadTextFileCancellationInterruptsBlockedRead(t *testing.T) { - underlying := &stalledReadCloser{ +func TestClientReadTextFileCancellationWhileWaitingForAdmission(t *testing.T) { + acpClient, directory := newTestClient(t, true) + path := filepath.Join(directory, "file.txt") + if err := os.WriteFile(path, []byte("content"), 0o600); err != nil { + t.Fatalf("write text file: %v", err) + } + if err := acpClient.acquireTextFileRead(context.Background()); err != nil { + t.Fatalf("hold text file read slot: %v", err) + } + if slots := cap(acpClient.textFileReadSlots); slots != 1 { + t.Fatalf("text file read slot capacity = %d, want 1", slots) + } + + var releaseOnce sync.Once + release := func() { + releaseOnce.Do(acpClient.releaseTextFileRead) + } + t.Cleanup(release) + + baseCtx, cancel := context.WithCancel(context.Background()) + ctx := &observedDoneContext{ + Context: baseCtx, + observed: make(chan struct{}), + } + readDone := make(chan error, 1) + go func() { + _, err := acpClient.ReadTextFile(ctx, acpsdk.ReadTextFileRequest{ + SessionId: "session-1", + Path: path, + }) + readDone <- err + }() + + select { + case <-ctx.observed: + case <-time.After(time.Second): + t.Fatal("read did not wait for admission") + } + cancel() + + select { + case err := <-readDone: + if !errors.Is(err, context.Canceled) { + t.Fatalf("queued read error = %v, want context canceled", err) + } + case <-time.After(time.Second): + t.Fatal("queued read did not return after cancellation") + } + + if slots := len(acpClient.textFileReadSlots); slots != maxConcurrentTextFileReads { + t.Fatalf("occupied text file read slots = %d, want %d", slots, maxConcurrentTextFileReads) + } + release() + if slots := len(acpClient.textFileReadSlots); slots != 0 { + t.Fatalf("occupied text file read slots after release = %d, want 0", slots) + } +} + +func TestClientReadTextFileRejectsMissingAdmissionGate(t *testing.T) { + acpClient, directory := newTestClient(t, true) + acpClient.textFileReadSlots = nil + path := filepath.Join(directory, "file.txt") + if err := os.WriteFile(path, []byte("content"), 0o600); err != nil { + t.Fatalf("write text file: %v", err) + } + + _, err := acpClient.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{ + SessionId: "session-1", + Path: path, + }) + if !errors.Is(err, errReadGateUninitialized) { + t.Fatalf("missing text file read gate error = %v, want %v", err, errReadGateUninitialized) + } +} + +type observedDoneContext struct { + context.Context + observed chan struct{} + observedOnce sync.Once +} + +func (ctx *observedDoneContext) Done() <-chan struct{} { + ctx.observedOnce.Do(func() { close(ctx.observed) }) + return ctx.Context.Done() +} + +func TestClientReadTextFileCancellationRetainsResourceOwnership(t *testing.T) { + underlying := &blockingReadCloser{ readStarted: make(chan struct{}), closeStarted: make(chan struct{}), releaseRead: make(chan struct{}), releaseClose: make(chan struct{}), + read: 1, } - t.Cleanup(func() { - close(underlying.releaseRead) - close(underlying.releaseClose) - }) + t.Cleanup(underlying.release) ctx, cancel := context.WithCancel(context.Background()) readDone := make(chan error, 1) @@ -69,40 +160,128 @@ func TestClientReadTextFileCancellationInterruptsBlockedRead(t *testing.T) { } cancel() + select { + case <-underlying.closeStarted: + t.Fatal("cancellation started close concurrently with the blocked read") + case err := <-readDone: + t.Fatalf("canceled read returned before the owned read stopped: %v", err) + case <-time.After(100 * time.Millisecond): + } + + underlying.unblockRead() + select { + case <-underlying.closeStarted: + case <-time.After(time.Second): + t.Fatal("underlying close did not start after the read stopped") + } + + select { + case err := <-readDone: + t.Fatalf("canceled read returned before the owned close stopped: %v", err) + case <-time.After(100 * time.Millisecond): + } + + underlying.unblockClose() select { case err := <-readDone: if !errors.Is(err, context.Canceled) { t.Fatalf("canceled read error = %v, want context canceled", err) } case <-time.After(time.Second): - t.Fatal("canceled read remained blocked") + t.Fatal("canceled read did not return after cleanup completed") } +} + +func TestClientReadTextFileCancellationTakesPrecedenceOverEOF(t *testing.T) { + underlying := &blockingReadCloser{ + readStarted: make(chan struct{}), + closeStarted: make(chan struct{}), + releaseRead: make(chan struct{}), + releaseClose: make(chan struct{}), + readErr: io.EOF, + } + t.Cleanup(underlying.release) + + ctx, cancel := context.WithCancel(context.Background()) + readDone := make(chan error, 1) + go func() { + _, err := (&client{}).readTextFile(ctx, underlying, acpsdk.ReadTextFileRequest{Path: "/file.txt"}) + readDone <- err + }() + + select { + case <-underlying.readStarted: + case <-time.After(time.Second): + t.Fatal("underlying read did not start") + } + cancel() + underlying.unblockRead() + select { case <-underlying.closeStarted: case <-time.After(time.Second): - t.Fatal("cancellation did not attempt to close the underlying reader") + t.Fatal("underlying close did not start after EOF") + } + + select { + case err := <-readDone: + t.Fatalf("canceled read returned before the owned close stopped: %v", err) + case <-time.After(100 * time.Millisecond): + } + + underlying.unblockClose() + select { + case err := <-readDone: + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled EOF read error = %v, want context canceled", err) + } + case <-time.After(time.Second): + t.Fatal("canceled EOF read did not return after cleanup completed") } } -type stalledReadCloser struct { - readStarted chan struct{} - closeStarted chan struct{} - releaseRead chan struct{} - releaseClose chan struct{} +type blockingReadCloser struct { + readStarted chan struct{} + closeStarted chan struct{} + releaseRead chan struct{} + releaseClose chan struct{} + readStartedOnce sync.Once + releaseReadOnce sync.Once + releaseCloseOnce sync.Once + readCount int + read int + readErr error } -func (reader *stalledReadCloser) Read([]byte) (int, error) { - close(reader.readStarted) +func (reader *blockingReadCloser) Read([]byte) (int, error) { + reader.readStartedOnce.Do(func() { close(reader.readStarted) }) <-reader.releaseRead - return 0, errors.New("read released") + reader.readCount++ + if reader.readCount > 1 { + return 0, io.EOF + } + return reader.read, reader.readErr } -func (reader *stalledReadCloser) Close() error { +func (reader *blockingReadCloser) Close() error { close(reader.closeStarted) <-reader.releaseClose return nil } +func (reader *blockingReadCloser) release() { + reader.unblockRead() + reader.unblockClose() +} + +func (reader *blockingReadCloser) unblockRead() { + reader.releaseReadOnce.Do(func() { close(reader.releaseRead) }) +} + +func (reader *blockingReadCloser) unblockClose() { + reader.releaseCloseOnce.Do(func() { close(reader.releaseClose) }) +} + func TestClientRejectsWritesWithoutPermission(t *testing.T) { acpClient, directory := newTestClient(t, false) path := filepath.Join(directory, "nested", "file.txt") @@ -160,6 +339,59 @@ func TestClientRequestPermissionStartsToolCallBeforeDenying(t *testing.T) { } } +func TestClientTerminalRequestsAreUnavailable(t *testing.T) { + const expected = "acp terminal requests are unavailable in unattended runs" + + tests := []struct { + name string + call func() error + }{ + { + name: "create", + call: func() error { + _, err := (&client{}).CreateTerminal(context.Background(), acpsdk.CreateTerminalRequest{}) + return err + }, + }, + { + name: "kill", + call: func() error { + _, err := (&client{}).KillTerminal(context.Background(), acpsdk.KillTerminalRequest{}) + return err + }, + }, + { + name: "output", + call: func() error { + _, err := (&client{}).TerminalOutput(context.Background(), acpsdk.TerminalOutputRequest{}) + return err + }, + }, + { + name: "release", + call: func() error { + _, err := (&client{}).ReleaseTerminal(context.Background(), acpsdk.ReleaseTerminalRequest{}) + return err + }, + }, + { + name: "wait for exit", + call: func() error { + _, err := (&client{}).WaitForTerminalExit(context.Background(), acpsdk.WaitForTerminalExitRequest{}) + return err + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.call(); err == nil || err.Error() != expected { + t.Fatalf("terminal request error = %v, want %q", err, expected) + } + }) + } +} + func TestClientRejectsToolCallUpdateBeforeToolCallByDefault(t *testing.T) { acpClient := &client{turn: newTurn(NewEngine(), &testSink{}, "session-1")} completed := acpsdk.ToolCallStatusCompleted diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go index a19420f2ff..7672984814 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -411,7 +411,7 @@ func TestEngineTurnCreatesAndResumesSession(t *testing.T) { } } -func TestEngineTurnAdvertisesRequestedFilesystemWriteCapability(t *testing.T) { +func TestEngineTurnAdvertisesClientCapabilities(t *testing.T) { for _, test := range []struct { name string fileSystemWrite bool @@ -438,12 +438,15 @@ func TestEngineTurnAdvertisesRequestedFilesystemWriteCapability(t *testing.T) { if len(initializations) != 1 { t.Fatalf("initializations = %#v", initializations) } - capabilities := initializations[0].ClientCapabilities.Fs - if !capabilities.ReadTextFile || capabilities.WriteTextFile != test.fileSystemWrite { - t.Fatalf("filesystem capabilities = %#v", capabilities) + capabilities := initializations[0].ClientCapabilities + if !capabilities.Fs.ReadTextFile || capabilities.Fs.WriteTextFile != test.fileSystemWrite { + t.Fatalf("filesystem capabilities = %#v", capabilities.Fs) } - if terminalOutput, ok := initializations[0].ClientCapabilities.Meta["terminal_output"].(bool); !ok || !terminalOutput { - t.Fatalf("terminal output capability = %#v", initializations[0].ClientCapabilities.Meta) + if capabilities.Terminal { + t.Fatal("terminal capability unexpectedly advertised") + } + if _, exists := capabilities.Meta["terminal_output"]; exists { + t.Fatalf("legacy terminal output capability = %#v, want absent", capabilities.Meta) } }) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go index 6be6577a32..25d9c97867 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go @@ -135,7 +135,6 @@ func (attempt *sessionAttempt) initialize() (acpsdk.InitializeResponse, error) { Version: "1", }, ClientCapabilities: acpsdk.ClientCapabilities{ - Meta: map[string]any{"terminal_output": true}, Fs: acpsdk.FileSystemCapabilities{ ReadTextFile: true, WriteTextFile: attempt.fileSystemWrite, @@ -323,16 +322,17 @@ func newSessionAttempt(engine *Engine, ctx context.Context, process *exec.StdioP return nil, fmt.Errorf("open acp working directory: %w", err) } turn := newTurn(engine, sink, request.SessionID) + protocolClient := newClient( + turn, + request.Cwd, + root, + request.FileSystemWrite, + ) attempt := &sessionAttempt{ - engine: engine, - ctx: ctx, - process: process, - connection: acpsdk.NewClientSideConnection(&client{ - turn: turn, - cwd: request.Cwd, - root: root, - fileSystemWrite: request.FileSystemWrite, - }, process.Stdin, process.Stdout), + engine: engine, + ctx: ctx, + process: process, + connection: acpsdk.NewClientSideConnection(protocolClient, process.Stdin, process.Stdout), turn: turn, settings: request.Settings, cwd: request.Cwd, diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go index 0185074db8..15bf88380d 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go @@ -18,11 +18,17 @@ const ( geminiHomeDir = ".gemini" geminiSkillsDir = "skills" geminiChatsDir = "chats" + geminiPoliciesDir = "policies" + geminiPolicyFileName = "plural-harness.toml" geminiCompatibilityInstructions = ` Gemini CLI compatibility: do not use command substitution forms such as $(), backticks, <(), or >(), because the CLI blocks them even in yolo mode. Use arithmetic loops, shell builtins, temporary files, or separate commands instead. Git metadata: inspect repository history and state with git commands. Do not use file tools to read .git internals such as .git/HEAD, because Gemini CLI restricts direct access to those paths. + +MCP endpoint safety: never probe http://127.0.0.1:8080/mcp with an unbounded curl. A bare GET is a long-lived streamable-HTTP notification connection, not a health probe. Use Gemini MCP tools for MCP work. If a connectivity diagnostic is necessary, use an explicit short deadline such as curl --max-time 5. + +Environment safety: do not run broad environment dumps or inspect secret values directly. Check only a named non-secret variable when necessary. ` ) @@ -178,6 +184,10 @@ func (agent *Agent) skillsPath(config toolv1.Config) string { return filepath.Join(agent.geminiHome(config), geminiSkillsDir) } +func (agent *Agent) policiesPath(config toolv1.Config) string { + return filepath.Join(agent.geminiHome(config), geminiPoliciesDir) +} + func (agent *Agent) chatsPath(config toolv1.Config) string { return filepath.Join(agent.geminiHome(config), "tmp", "plural", geminiChatsDir) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go index 98cbc26194..e609dfeda0 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go @@ -8,6 +8,33 @@ import ( toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" ) +const ( + privateDirectoryMode = 0700 + privateFileMode = 0600 + geminiPolicy = `[[rule]] +toolName = "update_topic" +decision = "deny" +priority = 999 +denyMessage = "Use normal progress messages instead of update_topic." + +[[rule]] +toolName = "run_shell_command" +commandPrefix = "rm -rf" +decision = "deny" +priority = 999 +denyMessage = "Recursive forced deletion is disabled in this environment." + +[[rule]] +toolName = "run_shell_command" +# This is an accidental secret-dump guard, not a shell sandbox. +# Gemini matches commandRegex against JSON-encoded tool arguments. +commandRegex = '''\s*(?:\(\s*)?(?:[A-Za-z_][A-Za-z0-9_]*=(?:[^\s"'\\]|\\"(?:[^"\\]|\\.)*\\"|\\\\.|'[^']*')*\s+)*(?:(?:exec|command|builtin)\s+)?(?:(?:/usr/bin/|/bin/)?env(?:\s+(?:-|--|-i|--ignore-environment|-0|--null|-v|--debug)|\s+(?:-u|-C|-S|--unset|--chdir|--split-string|--argv0)\s+\S+|\s+(?:-[uCS]\S+|--(?:unset|chdir|split-string|argv0)=\S+))*(?:\s+[A-Za-z_][A-Za-z0-9_]*=(?:[^\s"'\\]|\\"(?:[^"\\]|\\.)*\\"|\\\\.|'[^']*')*)*|(?:/usr/bin/|/bin/)?printenv(?:\s+-\S+)*|set|export\s+-p)(?:\s*(?:[|;&]|\d*(?:>>?|< /tmp/environment", + "printenv", + "printenv > /tmp/environment", + "builtin set | cat", + "export -p", + "export -p > /tmp/environment", + } { + match := compiled.FindStringIndex(policyCommandSubject(command)) + if match == nil || match[0] != 0 { + t.Errorf("environment enumeration commandRegex does not match %q", command) + } + } + for _, command := range []string{ + "env FOO=bar command", + `env FOO="bar baz" command`, + `env FOO=bar\ baz command`, + "env -u FOO command", + "printenv PATH", + "printenv --null PATH", + "set -o", + "export -p PATH", + "FOO=bar", + `FOO="bar baz"`, + `FOO=bar\ baz`, + "FOO=bar git status", + `FOO="bar baz" git status`, + `FOO=bar\ baz git status`, + } { + match := compiled.FindStringIndex(policyCommandSubject(command)) + if match != nil && match[0] == 0 { + t.Errorf("environment enumeration commandRegex unexpectedly matches %q", command) + } + } +} + +func policyCommandSubject(command string) string { + return strconv.Quote(command)[1:] + "}" +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go index c0b3a90385..ffcace3feb 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go @@ -12,7 +12,8 @@ import ( //nolint:gocyclo func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { baseInput := &ConfigTemplateInput{ - Model: "gemini-3.1-flash-lite", + Model: "gemini-3.1-flash-lite", + InactivityTimeout: 300, } t.Run("plural MCP server uses in-pod streamable HTTP URL", func(t *testing.T) { @@ -75,6 +76,30 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { } }) + t.Run("redacts inherited environment variables from shell tools", func(t *testing.T) { + input := *baseInput + input.AgentRunMode = console.AgentRunModeWrite + + _, content, err := settings(&input) + if err != nil { + t.Fatalf("settings() failed: %v", err) + } + + var out struct { + Security struct { + EnvironmentVariableRedaction struct { + Enabled bool `json:"enabled"` + } `json:"environmentVariableRedaction"` + } `json:"security"` + } + if err := json.Unmarshal([]byte(content), &out); err != nil { + t.Fatalf("generated content is not valid JSON: %v", err) + } + if !out.Security.EnvironmentVariableRedaction.Enabled { + t.Fatal("security.environmentVariableRedaction.enabled = false, want true") + } + }) + t.Run("tools.core differs by mode", func(t *testing.T) { writeInput := *baseInput writeInput.AgentRunMode = console.AgentRunModeWrite @@ -147,37 +172,14 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { if _, ok := writeOut["excludeTools"]; ok { t.Error("settings unexpectedly contains deprecated top-level excludeTools") } + if _, ok := writeTools["exclude"]; ok { + t.Error("settings unexpectedly contains deprecated tools.exclude") + } if writeTools["shell"].(map[string]any)["inactivityTimeout"] != float64(baseInput.InactivityTimeout) { t.Errorf("tools.shell.inactivityTimeout = %v, want %d", writeTools["shell"].(map[string]any)["inactivityTimeout"], baseInput.InactivityTimeout) } }) - t.Run("progress-only topic tool is excluded", func(t *testing.T) { - input := *baseInput - input.AgentRunMode = console.AgentRunModeWrite - - _, content, err := settings(&input) - if err != nil { - t.Fatalf("settings() failed: %v", err) - } - - var out struct { - Tools struct { - ExcludeTools []string `json:"exclude"` - } `json:"tools"` - } - if err := json.Unmarshal([]byte(content), &out); err != nil { - t.Fatalf("generated content is not valid JSON: %v", err) - } - - for _, tool := range out.Tools.ExcludeTools { - if tool == "update_topic" { - return - } - } - t.Errorf("tools.exclude = %q, want update_topic", out.Tools.ExcludeTools) - }) - t.Run("quotes model", func(t *testing.T) { input := *baseInput input.Model = "gemini-3.1-\"flash\"" diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go index 7ceab85d20..4449191bc2 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go @@ -12,6 +12,8 @@ import ( "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" ) +const successfulToolResultWithoutDisplay = "Tool completed successfully; Gemini CLI did not expose display output." + type streamEventType string const ( @@ -233,8 +235,8 @@ func (turn *streamTurn) handleToolResult(line []byte) error { return fmt.Errorf("invalid gemini tool result event: unsupported status %q", event.Status) } - output := "" - if event.Output != nil { + output := successfulToolResultWithoutDisplay + if event.Output != nil && *event.Output != "" { output = *event.Output } else if event.Error != nil { output = event.Error.Message diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl index 22dede643c..7f7561d808 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl @@ -33,10 +33,6 @@ "google_web_search", "save_memory" ]{{ end }}, - "exclude": [ - "update_topic", - "run_shell_command(rm -rf)" - ], "shell": { "inactivityTimeout": {{ .InactivityTimeout }} } @@ -60,6 +56,11 @@ "trust": true } }, + "security": { + "environmentVariableRedaction": { + "enabled": true + } + }, "privacy": { "usageStatisticsEnabled": false }, diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/success.jsonl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/success.jsonl index dc296edfa7..7dbab14ba3 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/success.jsonl +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/success.jsonl @@ -3,7 +3,7 @@ {"type":"message","timestamp":"2026-09-10T12:00:02Z","role":"assistant","content":"I will ","delta":true} {"type":"message","timestamp":"2026-09-10T12:00:03Z","role":"assistant","content":"inspect.","delta":true} {"type":"tool_use","timestamp":"2026-09-10T12:00:04Z","tool_name":"read_file","tool_id":"call-1","parameters":{"path":"README.md"}} -{"type":"tool_result","timestamp":"2026-09-10T12:00:05Z","tool_id":"call-1","status":"success","output":"line one\nline two "} +{"type":"tool_result","timestamp":"2026-09-10T12:00:05Z","tool_id":"call-1","status":"success","output":""} {"type":"tool_use","timestamp":"2026-09-10T12:00:06Z","tool_name":"run_shell","tool_id":"call-2","parameters":{"command":"false"}} {"type":"tool_result","timestamp":"2026-09-10T12:00:07Z","tool_id":"call-2","status":"error","error":{"type":"TOOL_EXECUTION_ERROR","message":"command failed"}} {"type":"error","timestamp":"2026-09-10T12:00:08Z","severity":"warning","message":"approaching turn limit"} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go index 2c03ef0867..0070166668 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go @@ -27,6 +27,11 @@ const ( geminiTrustWorkspaceEnv = "GEMINI_CLI_TRUST_WORKSPACE" geminiHomeEnv = "GEMINI_CLI_HOME" geminiTrustWorkspace = "true" + gitConfigCountEnv = "GIT_CONFIG_COUNT" + gitConfigKeyEnv = "GIT_CONFIG_KEY_0" + gitConfigValueEnv = "GIT_CONFIG_VALUE_0" + gitConfigCount = "1" + gitSafeDirectoryKey = "safe.directory" ) type Transport struct { @@ -98,7 +103,7 @@ func (transport *Transport) executable(request toolv1.TurnRequest) (exec.Executa launchOptions = append( launchOptions, exec.WithArgs(transport.args(request)), - exec.WithEnv(transport.agent.env(config)), + exec.WithEnv(transport.env(config)), exec.WithDir(transport.repositoryDir), exec.WithTimeout(gemini.Timeout), ) @@ -106,6 +111,14 @@ func (transport *Transport) executable(request toolv1.TurnRequest) (exec.Executa return exec.NewExecutable(geminiBinary, launchOptions...), nil } +func (transport *Transport) env(config toolv1.Config) []string { + return append(transport.agent.env(config), + fmt.Sprintf("%s=%s", gitConfigCountEnv, gitConfigCount), + fmt.Sprintf("%s=%s", gitConfigKeyEnv, gitSafeDirectoryKey), + fmt.Sprintf("%s=%s", gitConfigValueEnv, transport.repositoryDir), + ) +} + func (transport *Transport) args(request toolv1.TurnRequest) []string { model := transport.agent.resolveModel(request.Settings.Model.Name) args := []string{ diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go index 9c1d6a3f50..cf070e5307 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go @@ -105,6 +105,9 @@ func TestTransportTurnUsesRepositoryCWDAndPreservesExecutionOptions(t *testing.T t.Setenv("GEMINI_TEST_FIXTURE", fixturePath(t, "success.jsonl")) launchOutput := filepath.Join(t.TempDir(), "launch") t.Setenv("GEMINI_TEST_OUTPUT", launchOutput) + t.Setenv("GIT_CONFIG_COUNT", "8") + t.Setenv("GIT_CONFIG_KEY_0", "unsafe.key") + t.Setenv("GIT_CONFIG_VALUE_0", "unsafe-value") endpoint := "https://api.example" transport := newTestTransport(t, console.AgentRunModeWrite, "gemini-custom", &endpoint) @@ -118,6 +121,11 @@ func TestTransportTurnUsesRepositoryCWDAndPreservesExecutionOptions(t *testing.T Model: toolv1.ModelSelection{Name: "gemini-custom"}, }, Options: []exec.Option{ + exec.WithEnv([]string{ + "GIT_CONFIG_COUNT=9", + "GIT_CONFIG_KEY_0=another.unsafe.key", + "GIT_CONFIG_VALUE_0=another-unsafe-value", + }), exec.WithHook(stackv1.LifecyclePreStart, func() error { preStarts.Add(1) return nil @@ -147,6 +155,8 @@ func TestTransportTurnUsesRepositoryCWDAndPreservesExecutionOptions(t *testing.T "arg=--approval-mode", "arg=yolo", "arg=--prompt", "arg=implement feature with spaces", "key=api-key", "endpoint=https://api.example", "trust=true", "home=" + transport.agent.config.WorkDir, "cwd=" + transport.repositoryDir, + "git_config_count=1", "git_config_key_0=safe.directory", "git_config_value_0=" + transport.repositoryDir, + "git_safe_directory=" + transport.repositoryDir, } for _, want := range wantLaunchLines { if !strings.Contains(string(launch), want+"\n") { @@ -303,7 +313,7 @@ func assertSuccessfulStream(t *testing.T, sink *testSink) { t.Fatalf("first assistant message = %#v", sink.messages[0]) } assertToolMessage(t, sink.messages[1], "call-1", "read_file", `{"path":"README.md"}`, toolv1.RunningToolOutput, console.AgentMessageToolStateRunning) - assertToolMessage(t, sink.messages[2], "call-1", "read_file", `{"path":"README.md"}`, "line one\nline two ", console.AgentMessageToolStateCompleted) + assertToolMessage(t, sink.messages[2], "call-1", "read_file", `{"path":"README.md"}`, "Tool completed successfully; Gemini CLI did not expose display output.", console.AgentMessageToolStateCompleted) assertToolMessage(t, sink.messages[3], "call-2", "run_shell", `{"command":"false"}`, toolv1.RunningToolOutput, console.AgentMessageToolStateRunning) assertToolMessage(t, sink.messages[4], "call-2", "run_shell", `{"command":"false"}`, "command failed", console.AgentMessageToolStateError) if sink.messages[5].attributes.Role != console.AiRoleSystem || sink.messages[5].attributes.Message != "Warning: approaching turn limit" { @@ -368,7 +378,10 @@ if [ -n "$GEMINI_TEST_OUTPUT" ]; then for arg in "$@"; do printf 'arg=%s\n' "$arg" >> "$GEMINI_TEST_OUTPUT" done - printf 'key=%s\nendpoint=%s\ngoogle_endpoint=%s\ntrust=%s\nhome=%s\ncwd=%s\n' "$GEMINI_API_KEY" "$GEMINI_API_BASE_URL" "$GOOGLE_GEMINI_BASE_URL" "$GEMINI_CLI_TRUST_WORKSPACE" "$GEMINI_CLI_HOME" "$PWD" >> "$GEMINI_TEST_OUTPUT" + printf 'key=%s\nendpoint=%s\ngoogle_endpoint=%s\ntrust=%s\nhome=%s\ncwd=%s\ngit_config_count=%s\ngit_config_key_0=%s\ngit_config_value_0=%s\n' "$GEMINI_API_KEY" "$GEMINI_API_BASE_URL" "$GOOGLE_GEMINI_BASE_URL" "$GEMINI_CLI_TRUST_WORKSPACE" "$GEMINI_CLI_HOME" "$PWD" "$GIT_CONFIG_COUNT" "$GIT_CONFIG_KEY_0" "$GIT_CONFIG_VALUE_0" >> "$GEMINI_TEST_OUTPUT" + git config --get-all safe.directory | while IFS= read -r directory; do + printf 'git_safe_directory=%s\n' "$directory" >> "$GEMINI_TEST_OUTPUT" + done fi printf '[DEBUG] ignored Gemini CLI stderr noise\n' >&2 if [ -n "$GEMINI_TEST_FIXTURE" ]; then From 4f7544ecbc6c5e7304a0831bb968c29eed7ebd77 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 14:47:57 +0200 Subject: [PATCH 40/46] refactor(gemini, acp): update runtime structure, transport logic, and tests - Renamed `workDir` to `repositoryDir` across Gemini transport and configuration for clearer execution context. - Refined `defaultGeminiInactivityTimeout` constant handling for consistency and clarity. - Added new transport tests utilizing `repositoryDir` to validate execution behavior. - Introduced `Gemini` tool with enhanced runtime, including babysitting and session management. - Implemented model validation and argument parsing for Gemini execution. - Replaced `tools.core` with `coreTools` in settings and aligned tests accordingly. - Consolidated tests to modernize and ensure consistency with refactored transport and tool settings. --- .../deployment-operator-cd-agent-harness.yaml | 4 +- .../agent-harness/gemini.Dockerfile | 2 +- .../internal/controller/agentrun_pod.go | 2 +- .../pkg/agentrun-harness/agentrun/v1/types.go | 6 +- .../agentrun/v1/types_test.go | 22 - .../agentrun-harness/tool/claude/transport.go | 16 +- .../tool/claude/transport_test.go | 131 ++++++ .../pkg/agentrun-harness/tool/gemini/agent.go | 222 --------- .../tool/gemini/agent_config.go | 92 ---- .../tool/gemini/agent_test.go | 289 ------------ .../agentrun-harness/tool/gemini/artifacts.go | 17 + .../tool/gemini/events/README.md | 2 + .../tool/gemini/events/base.go | 80 ++++ .../tool/gemini/events/error.go | 47 ++ .../tool/gemini/events/init.go | 21 + .../tool/gemini/events/message.go | 46 ++ .../tool/gemini/events/result.go | 88 ++++ .../tool/gemini/events/tool_result.go | 83 ++++ .../tool/gemini/events/tool_result_test.go | 36 ++ .../tool/gemini/events/tool_use.go | 64 +++ .../agentrun-harness/tool/gemini/gemini.go | 318 +++++++++++++ .../tool/gemini/gemini_args_test.go | 68 +++ .../pkg/agentrun-harness/tool/gemini/model.go | 19 + .../tool/gemini/runtime_config.go | 58 --- .../tool/gemini/runtime_config_test.go | 56 --- .../agentrun-harness/tool/gemini/settings.go | 14 +- .../tool/gemini/settings_test.go | 109 +---- .../agentrun-harness/tool/gemini/stream.go | 368 --------------- .../tool/gemini/stream_test.go | 154 ------ .../gemini/templates/settings.json.gotmpl | 66 +-- .../tool/gemini/testdata/invalid_stream.jsonl | 3 - .../tool/gemini/testdata/malformed.jsonl | 4 - .../tool/gemini/testdata/result_error.jsonl | 3 - .../tool/gemini/testdata/success.jsonl | 12 - .../agentrun-harness/tool/gemini/transport.go | 173 ------- .../tool/gemini/transport_test.go | 437 ------------------ .../pkg/agentrun-harness/tool/tool.go | 7 +- .../pkg/agentrun-harness/tool/tool_test.go | 7 +- 38 files changed, 1100 insertions(+), 2046 deletions(-) delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/artifacts.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/README.md create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/base.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/error.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/init.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/message.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/result.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_use.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini_args_test.go create mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/model.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream_test.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/invalid_stream.jsonl delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/malformed.jsonl delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/result_error.jsonl delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/success.jsonl delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go delete mode 100644 go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go diff --git a/.github/workflows/deployment-operator-cd-agent-harness.yaml b/.github/workflows/deployment-operator-cd-agent-harness.yaml index 33de6b8b43..567e04113c 100644 --- a/.github/workflows/deployment-operator-cd-agent-harness.yaml +++ b/.github/workflows/deployment-operator-cd-agent-harness.yaml @@ -32,7 +32,7 @@ jobs: env: NODE_VERSION: 24.11.1 CLAUDE_VERSION: 2.1.236 - GEMINI_VERSION: 0.59.0 + GEMINI_VERSION: 0.44.1 OPENCODE_VERSION: 1.18.23 CODEX_VERSION: 0.153.4 PI_VERSION: 0.84.1 @@ -181,7 +181,7 @@ jobs: - name: claude version: 2.1.236 - name: gemini - version: 0.59.0 + version: 0.44.1 - name: opencode version: 1.18.23 - name: codex diff --git a/go/deployment-operator/dockerfiles/agent-harness/gemini.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/gemini.Dockerfile index da9c69dbf6..1ea8a7896a 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/gemini.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/gemini.Dockerfile @@ -1,6 +1,6 @@ ARG NODE_IMAGE_TAG=24 ARG NODE_IMAGE=node:${NODE_IMAGE_TAG}-slim -ARG AGENT_VERSION=0.59.0 +ARG AGENT_VERSION=0.44.1 ARG AGENT_HARNESS_BASE_IMAGE_TAG=latest ARG AGENT_HARNESS_BASE_IMAGE_REPO=ghcr.io/pluralsh/agent-harness-base diff --git a/go/deployment-operator/internal/controller/agentrun_pod.go b/go/deployment-operator/internal/controller/agentrun_pod.go index 465f4b38ef..b75b5c7619 100644 --- a/go/deployment-operator/internal/controller/agentrun_pod.go +++ b/go/deployment-operator/internal/controller/agentrun_pod.go @@ -122,7 +122,7 @@ var ( // Check .github/workflows/deployment-operator-cd-agent-harness.yaml to see images being published. defaultContainerVersions = map[console.AgentRuntimeType]string{ console.AgentRuntimeTypeClaude: "%s-claude-2.1.236", - console.AgentRuntimeTypeGemini: "%s-gemini-0.59.0", + console.AgentRuntimeTypeGemini: "%s-gemini-0.44.1", console.AgentRuntimeTypeOpencode: "%s-opencode-1.18.23", console.AgentRuntimeTypeCodex: "%s-codex-0.153.4", console.AgentRuntimeTypePi: "%s-pi-0.84.1", diff --git a/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types.go b/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types.go index dd23d232c8..ba15599db9 100644 --- a/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types.go +++ b/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types.go @@ -21,9 +21,9 @@ const ( // for a command before it is terminated. defaultBashMaxTimeout = defaultTimeout - // defaultGeminiInactivityTimeout is the default Gemini CLI timeout for the process, + // defaultInactivityTimeout is the default Gemini CLI timeout for the process, // tool call, or session if there is no output or input detected. - defaultGeminiInactivityTimeout = 5 * time.Minute + defaultInactivityTimeout = defaultBashTimeout defaultBabysitInterval = int64(60) // seconds between PR/SCM babysit checks ) @@ -273,7 +273,7 @@ func (ar *AgentRun) fromEnv(runtime *console.AgentRuntimeFragment) *AgentRuntime APIKey: helpers.GetPluralEnv(controller.EnvGeminiAPIKey, ""), Model: helpers.GetPluralEnv(controller.EnvGeminiModel, ""), Timeout: helpers.GetPluralEnvDuration(controller.EnvExecTimeout, defaultTimeout), - InactivityTimeout: helpers.GetPluralEnvDuration(controller.EnvGeminiInactivityTimeout, defaultGeminiInactivityTimeout), + InactivityTimeout: helpers.GetPluralEnvDuration(controller.EnvGeminiInactivityTimeout, defaultInactivityTimeout), } if endpoint := helpers.GetPluralEnv(controller.EnvGeminiEndpoint, ""); endpoint != "" { config.Gemini.Endpoint = &endpoint diff --git a/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types_test.go b/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types_test.go index 19bd9b3b75..49a06992c8 100644 --- a/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/agentrun/v1/types_test.go @@ -2,32 +2,10 @@ package v1 import ( "testing" - "time" console "github.com/pluralsh/console/go/client" - "github.com/pluralsh/console/go/deployment-operator/internal/controller" ) -func TestAgentRunFromEnvGeminiInactivityTimeout(t *testing.T) { - runtime := &console.AgentRuntimeFragment{Type: console.AgentRuntimeTypeGemini} - - t.Run("defaults to Gemini CLI timeout", func(t *testing.T) { - t.Setenv(controller.EnvGeminiInactivityTimeout, "") - config := new(AgentRun).fromEnv(runtime).Config.Gemini - if config.InactivityTimeout != 5*time.Minute { - t.Fatalf("Gemini inactivity timeout = %s, want 5m", config.InactivityTimeout) - } - }) - - t.Run("explicit environment value overrides default", func(t *testing.T) { - t.Setenv(controller.EnvGeminiInactivityTimeout, "47s") - config := new(AgentRun).fromEnv(runtime).Config.Gemini - if config.InactivityTimeout != 47*time.Second { - t.Fatalf("Gemini inactivity timeout = %s, want 47s", config.InactivityTimeout) - } - }) -} - func TestExaConnectionEnabled(t *testing.T) { run := &AgentRun{Runtime: &AgentRuntime{ExaConnection: true}} if !run.ExaConnectionEnabled() { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go index df3a98497f..b8bf29a73d 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go @@ -15,9 +15,9 @@ import ( const claudeACPBinary = "claude-agent-acp" type Transport struct { - agent *Agent - engine *acp.Engine - workDir string + agent *Agent + engine *acp.Engine + repositoryDir string } var _ toolv1.Transport = (*Transport)(nil) @@ -30,11 +30,11 @@ func NewTransport(agent *Agent) (*Transport, error) { if err != nil { return nil, err } - workDir, err := filepath.Abs(config.WorkDir) + repositoryDir, err := filepath.Abs(config.RepositoryDir) if err != nil { - return nil, fmt.Errorf("resolve claude work directory: %w", err) + return nil, fmt.Errorf("resolve claude repository directory: %w", err) } - return &Transport{agent: agent, engine: acp.NewEngine(), workDir: workDir}, nil + return &Transport{agent: agent, engine: acp.NewEngine(), repositoryDir: repositoryDir}, nil } func (*Transport) Kind() toolv1.TransportKind { @@ -66,7 +66,7 @@ func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest return toolv1.TurnResult{SessionID: request.SessionID}, err } result, err := transport.engine.Turn(ctx, process, acp.Request{ - Cwd: transport.workDir, + Cwd: transport.repositoryDir, Prompt: request.Prompt, SessionID: request.SessionID, Settings: acp.SessionSettings{ModeID: modeID, ModelID: request.Settings.Model.Name}, @@ -82,6 +82,6 @@ func (transport *Transport) launch(options []exec.Option) (*exec.StdioProcess, e return nil, err } launchOptions := append([]exec.Option(nil), options...) - launchOptions = append(launchOptions, exec.WithEnv(transport.agent.env(config)), exec.WithDir(transport.workDir), exec.WithTimeout(claude.Timeout)) + launchOptions = append(launchOptions, exec.WithEnv(transport.agent.env(config)), exec.WithDir(transport.repositoryDir), exec.WithTimeout(claude.Timeout)) return exec.StartWithStdio(context.Background(), claudeACPBinary, launchOptions...) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go index ca4a743654..0c6e316189 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go @@ -10,8 +10,10 @@ import ( "sync/atomic" "testing" + acpsdk "github.com/coder/acp-go-sdk" console "github.com/pluralsh/console/go/client" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" stackv1 "github.com/pluralsh/console/go/deployment-operator/pkg/harness/stackrun/v1" ) @@ -53,6 +55,50 @@ func TestTransportLaunchUsesACPAdapterAndClaudeEnvironment(t *testing.T) { } } +func TestTransportTurnUsesRepositoryRootForACPAndProcess(t *testing.T) { + binDir := t.TempDir() + rootsPath := filepath.Join(t.TempDir(), "roots") + writeClaudeACPHelperBinary(t, binDir) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("CLAUDE_ACP_HELPER", "1") + t.Setenv("CLAUDE_ACP_HELPER_BINARY", os.Args[0]) + t.Setenv("CLAUDE_ACP_ROOTS_FILE", rootsPath) + + config := toolv1.Config{ + WorkDir: t.TempDir(), + RepositoryDir: t.TempDir(), + Run: claudeTestRun(console.AgentRunModeAnalyze, "", false), + } + transport, err := NewTransport(NewAgent(config)) + if err != nil { + t.Fatal(err) + } + result, err := transport.Turn(context.Background(), toolv1.TurnRequest{ + Prompt: "inspect repository", + Settings: toolv1.Settings{Mode: console.AgentRunModeAnalyze}, + }, &claudeTransportTestSink{}) + if err != nil { + t.Fatalf("Turn() error = %v", err) + } + if result.SessionID != "claude-test-session" { + t.Fatalf("session ID = %q", result.SessionID) + } + + content, err := os.ReadFile(rootsPath) + if err != nil { + t.Fatal(err) + } + roots := testEnvValues(strings.Split(string(content), "\n")) + for _, key := range []string{"cwd", "pwd"} { + if roots[key] != transport.repositoryDir { + t.Fatalf("%s = %q, want repository directory %q", key, roots[key], transport.repositoryDir) + } + } + if transport.repositoryDir == config.WorkDir { + t.Fatalf("repository directory unexpectedly uses work directory %q", config.WorkDir) + } +} + func TestTransportProjectsClaudeACP(t *testing.T) { config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: claudeTestRun(console.AgentRunModeAnalyze, "", true)} transport, err := NewTransport(NewAgent(config)) @@ -117,6 +163,91 @@ func writeClaudeACPBinary(t *testing.T, binDir string) { } } +func writeClaudeACPHelperBinary(t *testing.T, binDir string) { + t.Helper() + path := filepath.Join(binDir, claudeACPBinary) + script := "#!/bin/sh\nexec \"$CLAUDE_ACP_HELPER_BINARY\" -test.run=TestClaudeACPHelperProcess --\n" + if err := os.WriteFile(path, []byte(script), 0755); err != nil { + t.Fatal(err) + } +} + +func TestClaudeACPHelperProcess(t *testing.T) { + if os.Getenv("CLAUDE_ACP_HELPER") != "1" { + return + } + + agent := &claudeACPTestAgent{} + connection := acpsdk.NewAgentSideConnection(agent, os.Stdout, os.Stdin) + <-connection.Done() +} + +type claudeACPTestAgent struct{} + +var _ acpsdk.Agent = (*claudeACPTestAgent)(nil) + +func (*claudeACPTestAgent) Authenticate(context.Context, acpsdk.AuthenticateRequest) (acpsdk.AuthenticateResponse, error) { + return acpsdk.AuthenticateResponse{}, nil +} + +func (*claudeACPTestAgent) Initialize(context.Context, acpsdk.InitializeRequest) (acpsdk.InitializeResponse, error) { + return acpsdk.InitializeResponse{ProtocolVersion: acpsdk.ProtocolVersionNumber}, nil +} + +func (*claudeACPTestAgent) Logout(context.Context, acpsdk.LogoutRequest) (acpsdk.LogoutResponse, error) { + return acpsdk.LogoutResponse{}, nil +} + +func (*claudeACPTestAgent) Cancel(context.Context, acpsdk.CancelNotification) error { + return nil +} + +func (*claudeACPTestAgent) CloseSession(context.Context, acpsdk.CloseSessionRequest) (acpsdk.CloseSessionResponse, error) { + return acpsdk.CloseSessionResponse{}, nil +} + +func (*claudeACPTestAgent) ListSessions(context.Context, acpsdk.ListSessionsRequest) (acpsdk.ListSessionsResponse, error) { + return acpsdk.ListSessionsResponse{}, nil +} + +func (*claudeACPTestAgent) NewSession(_ context.Context, params acpsdk.NewSessionRequest) (acpsdk.NewSessionResponse, error) { + workingDirectory, err := os.Getwd() + if err != nil { + return acpsdk.NewSessionResponse{}, err + } + content := []byte("cwd=" + params.Cwd + "\npwd=" + workingDirectory + "\n") + if err := os.WriteFile(os.Getenv("CLAUDE_ACP_ROOTS_FILE"), content, 0644); err != nil { + return acpsdk.NewSessionResponse{}, err + } + return acpsdk.NewSessionResponse{SessionId: "claude-test-session"}, nil +} + +func (*claudeACPTestAgent) Prompt(context.Context, acpsdk.PromptRequest) (acpsdk.PromptResponse, error) { + return acpsdk.PromptResponse{StopReason: acpsdk.StopReasonEndTurn}, nil +} + +func (*claudeACPTestAgent) ResumeSession(context.Context, acpsdk.ResumeSessionRequest) (acpsdk.ResumeSessionResponse, error) { + return acpsdk.ResumeSessionResponse{}, nil +} + +func (*claudeACPTestAgent) SetSessionConfigOption(context.Context, acpsdk.SetSessionConfigOptionRequest) (acpsdk.SetSessionConfigOptionResponse, error) { + return acpsdk.SetSessionConfigOptionResponse{}, nil +} + +func (*claudeACPTestAgent) SetSessionMode(context.Context, acpsdk.SetSessionModeRequest) (acpsdk.SetSessionModeResponse, error) { + return acpsdk.SetSessionModeResponse{}, nil +} + +type claudeTransportTestSink struct{} + +func (*claudeTransportTestSink) Session(string) {} + +func (*claudeTransportTestSink) Message(*console.AgentMessageAttributes, string) {} + +func (*claudeTransportTestSink) ToolCallOutput(string, string) {} + +func (*claudeTransportTestSink) Usage(usage.Record) {} + func testEnvValues(env []string) map[string]string { values := make(map[string]string, len(env)) for _, item := range env { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go deleted file mode 100644 index 15bf88380d..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent.go +++ /dev/null @@ -1,222 +0,0 @@ -package gemini - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "path/filepath" - - console "github.com/pluralsh/console/go/client" - agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" -) - -const ( - geminiHomeDir = ".gemini" - geminiSkillsDir = "skills" - geminiChatsDir = "chats" - geminiPoliciesDir = "policies" - geminiPolicyFileName = "plural-harness.toml" - geminiCompatibilityInstructions = ` - -Gemini CLI compatibility: do not use command substitution forms such as $(), backticks, <(), or >(), because the CLI blocks them even in yolo mode. Use arithmetic loops, shell builtins, temporary files, or separate commands instead. - -Git metadata: inspect repository history and state with git commands. Do not use file tools to read .git internals such as .git/HEAD, because Gemini CLI restricts direct access to those paths. - -MCP endpoint safety: never probe http://127.0.0.1:8080/mcp with an unbounded curl. A bare GET is a long-lived streamable-HTTP notification connection, not a health probe. Use Gemini MCP tools for MCP work. If a connectivity diagnostic is necessary, use an explicit short deadline such as curl --max-time 5. - -Environment safety: do not run broad environment dumps or inspect secret values directly. Check only a named non-secret variable when necessary. -` -) - -type Agent struct { - config toolv1.Config - consoleURL string - consoleToken string -} - -var _ toolv1.Agent = (*Agent)(nil) - -func NewAgent(config toolv1.Config) *Agent { - return &Agent{config: config} -} - -func (*Agent) Type() console.AgentRuntimeType { - return console.AgentRuntimeTypeGemini -} - -func (*Agent) Capabilities() toolv1.AgentCapabilities { - return toolv1.AgentCapabilities{Modes: []console.AgentRunMode{ - console.AgentRunModeAnalyze, - console.AgentRunModeWrite, - console.AgentRunModeReview, - }} -} - -func (agent *Agent) Prepare(ctx context.Context, request toolv1.FileSystemRequest) error { - if err := agent.contextError(ctx); err != nil { - return err - } - config, err := agent.configForFilesystem(request) - if err != nil { - return err - } - - defaultTool := toolv1.DefaultTool{Config: config} - switch request.Phase { - case toolv1.ConfigurePhaseInitial: - err = defaultTool.ConfigureSystemPrompt(console.AgentRuntimeTypeGemini) - case toolv1.ConfigurePhaseBabysit: - err = defaultTool.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeGemini) - default: - return fmt.Errorf("unsupported gemini configuration phase %q", request.Phase) - } - if err != nil { - return err - } - if err := agent.contextError(ctx); err != nil { - return err - } - if err := agent.appendCompatibilityInstructions(config); err != nil { - return err - } - return defaultTool.ConfigureSkills(agent.skillsPath(config)) -} - -func (agent *Agent) Configure(ctx context.Context, request toolv1.ConfigureRequest) error { - if err := agent.contextError(ctx); err != nil { - return err - } - if request.Phase != toolv1.ConfigurePhaseInitial && request.Phase != toolv1.ConfigurePhaseBabysit { - return fmt.Errorf("unsupported gemini configuration phase %q", request.Phase) - } - if request.Phase == toolv1.ConfigurePhaseBabysit { - return nil - } - - config, err := agent.configWithGemini() - if err != nil { - return err - } - - agent.consoleURL = request.ConsoleURL - if request.ConsoleToken != "" { - agent.consoleToken = request.ConsoleToken - } - - return agent.writeNativeConfig(config, request.Settings.Model.Name) -} - -func (agent *Agent) Export(ctx context.Context, request toolv1.ExportRequest) (toolv1.ExportResult, error) { - if err := agent.contextError(ctx); err != nil { - return toolv1.ExportResult{}, err - } - if request.SessionID == "" { - return toolv1.ExportResult{}, errors.New("gemini session id is not set") - } - if request.OutputDir == "" { - return toolv1.ExportResult{}, errors.New("gemini export output directory is not set") - } - config, err := agent.configWithGemini() - if err != nil { - return toolv1.ExportResult{}, err - } - - source := agent.chatsPath(config) - found, err := artifacts.StageSessionDirectory(ctx, source, request.OutputDir) - if err != nil { - return toolv1.ExportResult{}, fmt.Errorf("stage gemini chats: %w", err) - } - if !found { - return toolv1.ExportResult{}, nil - } - return toolv1.ExportResult{SessionSource: artifacts.SessionSource{ - Path: request.OutputDir, ArchivePath: geminiChatsDir, - }}, nil -} - -func (agent *Agent) configWithGemini() (toolv1.Config, error) { - if agent.config.WorkDir == "" { - return toolv1.Config{}, errors.New("work directory is not set") - } - if agent.config.RepositoryDir == "" { - return toolv1.Config{}, errors.New("repository directory is not set") - } - if _, err := agent.runConfig(agent.config.Run); err != nil { - return toolv1.Config{}, err - } - return agent.config, nil -} - -func (agent *Agent) configForFilesystem(request toolv1.FileSystemRequest) (toolv1.Config, error) { - if request.WorkDir == "" { - return toolv1.Config{}, errors.New("work directory is not set") - } - if request.RepositoryDir == "" { - return toolv1.Config{}, errors.New("repository directory is not set") - } - if agent.config.Run == nil { - return toolv1.Config{}, errors.New("agent run is not set") - } - config := agent.config - config.WorkDir, config.RepositoryDir = request.WorkDir, request.RepositoryDir - return config, nil -} - -func (*Agent) runConfig(run *agentrunv1.AgentRun) (*agentrunv1.GeminiConfig, error) { - if run == nil { - return nil, errors.New("agent run is not set") - } - if run.Runtime == nil || run.Runtime.Config == nil || run.Runtime.Config.Gemini == nil { - return nil, errors.New("gemini runtime configuration is not set") - } - return run.Runtime.Config.Gemini, nil -} - -func (agent *Agent) geminiHome(config toolv1.Config) string { - return filepath.Join(config.WorkDir, geminiHomeDir) -} - -func (agent *Agent) skillsPath(config toolv1.Config) string { - return filepath.Join(agent.geminiHome(config), geminiSkillsDir) -} - -func (agent *Agent) policiesPath(config toolv1.Config) string { - return filepath.Join(agent.geminiHome(config), geminiPoliciesDir) -} - -func (agent *Agent) chatsPath(config toolv1.Config) string { - return filepath.Join(agent.geminiHome(config), "tmp", "plural", geminiChatsDir) -} - -func (agent *Agent) appendCompatibilityInstructions(config toolv1.Config) error { - promptPath := filepath.Join(agent.geminiHome(config), toolv1.SystemPromptFile) - prompt, err := os.OpenFile(promptPath, os.O_WRONLY|os.O_APPEND, 0) - if err != nil { - return fmt.Errorf("open Gemini system prompt for compatibility instructions: %w", err) - } - - written, err := io.WriteString(prompt, geminiCompatibilityInstructions) - if err != nil { - _ = prompt.Close() - return fmt.Errorf("append Gemini compatibility instructions: %w", err) - } - if written != len(geminiCompatibilityInstructions) { - _ = prompt.Close() - return fmt.Errorf("append Gemini compatibility instructions: %w", io.ErrShortWrite) - } - if err := prompt.Close(); err != nil { - return fmt.Errorf("close Gemini system prompt: %w", err) - } - return nil -} - -func (*Agent) contextError(ctx context.Context) error { - if ctx == nil { - return nil - } - return ctx.Err() -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go deleted file mode 100644 index e609dfeda0..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/agent_config.go +++ /dev/null @@ -1,92 +0,0 @@ -package gemini - -import ( - "fmt" - "os" - "path/filepath" - - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" -) - -const ( - privateDirectoryMode = 0700 - privateFileMode = 0600 - geminiPolicy = `[[rule]] -toolName = "update_topic" -decision = "deny" -priority = 999 -denyMessage = "Use normal progress messages instead of update_topic." - -[[rule]] -toolName = "run_shell_command" -commandPrefix = "rm -rf" -decision = "deny" -priority = 999 -denyMessage = "Recursive forced deletion is disabled in this environment." - -[[rule]] -toolName = "run_shell_command" -# This is an accidental secret-dump guard, not a shell sandbox. -# Gemini matches commandRegex against JSON-encoded tool arguments. -commandRegex = '''\s*(?:\(\s*)?(?:[A-Za-z_][A-Za-z0-9_]*=(?:[^\s"'\\]|\\"(?:[^"\\]|\\.)*\\"|\\\\.|'[^']*')*\s+)*(?:(?:exec|command|builtin)\s+)?(?:(?:/usr/bin/|/bin/)?env(?:\s+(?:-|--|-i|--ignore-environment|-0|--null|-v|--debug)|\s+(?:-u|-C|-S|--unset|--chdir|--split-string|--argv0)\s+\S+|\s+(?:-[uCS]\S+|--(?:unset|chdir|split-string|argv0)=\S+))*(?:\s+[A-Za-z_][A-Za-z0-9_]*=(?:[^\s"'\\]|\\"(?:[^"\\]|\\.)*\\"|\\\\.|'[^']*')*)*|(?:/usr/bin/|/bin/)?printenv(?:\s+-\S+)*|set|export\s+-p)(?:\s*(?:[|;&]|\d*(?:>>?|< /tmp/environment", - "printenv", - "printenv > /tmp/environment", - "builtin set | cat", - "export -p", - "export -p > /tmp/environment", - } { - match := compiled.FindStringIndex(policyCommandSubject(command)) - if match == nil || match[0] != 0 { - t.Errorf("environment enumeration commandRegex does not match %q", command) - } - } - for _, command := range []string{ - "env FOO=bar command", - `env FOO="bar baz" command`, - `env FOO=bar\ baz command`, - "env -u FOO command", - "printenv PATH", - "printenv --null PATH", - "set -o", - "export -p PATH", - "FOO=bar", - `FOO="bar baz"`, - `FOO=bar\ baz`, - "FOO=bar git status", - `FOO="bar baz" git status`, - `FOO=bar\ baz git status`, - } { - match := compiled.FindStringIndex(policyCommandSubject(command)) - if match != nil && match[0] == 0 { - t.Errorf("environment enumeration commandRegex unexpectedly matches %q", command) - } - } -} - -func policyCommandSubject(command string) string { - return strconv.Quote(command)[1:] + "}" -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/artifacts.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/artifacts.go new file mode 100644 index 0000000000..f2784c6d24 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/artifacts.go @@ -0,0 +1,17 @@ +package gemini + +import ( + "context" + "path/filepath" + + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" +) + +func (in *Gemini) UploadArtifacts(ctx context.Context) (*artifacts.UploadArtifacts, error) { + chatsPath := filepath.Join(in.providerPath(), "tmp", "plural", "chats") + return in.BuildUploadArtifacts(ctx, artifacts.BuildArtifactsOptions{ + Provider: "gemini", + Source: artifacts.SessionSource{Path: chatsPath, ArchivePath: "chats"}, + SessionID: in.sessionID, + }) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/README.md b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/README.md new file mode 100644 index 0000000000..6e7e6213b7 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/README.md @@ -0,0 +1,2 @@ +Types defined in this package reflect those found in: +https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/output/types.ts diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/base.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/base.go new file mode 100644 index 0000000000..a03f7cf076 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/base.go @@ -0,0 +1,80 @@ +package events + +import ( + "encoding/json" + "fmt" + "time" + + v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/log" + "k8s.io/klog/v2" +) + +type EventType string + +const ( + EventTypeInit EventType = "init" + EventTypeMessage EventType = "message" + EventTypeToolUse EventType = "tool_use" + EventTypeToolResult EventType = "tool_result" + EventTypeError EventType = "error" + EventTypeResult EventType = "result" +) + +type Event interface { + Validate() bool + Process(onMessage v1.MessageCallback) +} + +type EventBase struct { + Type EventType `json:"type"` + Timestamp time.Time `json:"timestamp"` +} + +func (e EventBase) OnMessage(line []byte, onMessage v1.MessageCallback) error { + if onMessage == nil { + klog.V(log.LogLevelDebug).InfoS("ignoring event as message handler is not defined", + "type", e.Type, "line", string(line)) + return nil + } + + switch e.Type { + case EventTypeInit: + return handleEvent[InitEvent](line, onMessage) + case EventTypeMessage: + return handleEvent[MessageEvent](line, onMessage) + case EventTypeToolUse: + return handleEvent[ToolUseEvent](line, onMessage) + case EventTypeToolResult: + return handleEvent[ToolResultEvent](line, onMessage) + case EventTypeError: + return handleEvent[ErrorEvent](line, onMessage) + case EventTypeResult: + return handleEvent[ResultEvent](line, onMessage) + default: + klog.V(log.LogLevelDebug).InfoS("ignoring unknown event", "type", e.Type, "line", string(line)) + } + + return nil +} + +// handleEvent is a generic helper to unmarshal, validate and process an event. +func handleEvent[T any, PT interface { + *T + Event +}](line []byte, onMessage v1.MessageCallback) error { + var t T + pt := PT(&t) + if err := json.Unmarshal(line, pt); err != nil { + return fmt.Errorf("failed to unmarshal %T: %w", pt, err) + } + + if !pt.Validate() { + klog.V(log.LogLevelDebug).InfoS("ignoring invalid event", "event", pt) + return nil + } + + pt.Process(onMessage) + + return nil +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/error.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/error.go new file mode 100644 index 0000000000..95a6ffd929 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/error.go @@ -0,0 +1,47 @@ +package events + +import ( + "fmt" + + console "github.com/pluralsh/console/go/client" + v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +type Severity string + +const ( + ErrorSeverityWarning Severity = "warning" + ErrorSeverityError Severity = "error" +) + +func (s Severity) String() string { + switch s { + case ErrorSeverityWarning: + return "Warning" + case ErrorSeverityError: + return "Error" + default: + return "Error" + } +} + +type ErrorEvent struct { + EventBase + Severity Severity `json:"severity"` + Message string `json:"message"` +} + +func (e *ErrorEvent) Validate() bool { + return e.Type == EventTypeError && e.Message != "" +} + +func (e *ErrorEvent) Process(onMessage v1.MessageCallback) { + onMessage(e.Attributes(), "") +} + +func (e *ErrorEvent) Attributes() *console.AgentMessageAttributes { + return &console.AgentMessageAttributes{ + Role: console.AiRoleSystem, + Message: fmt.Sprintf("%s: %s", e.Severity.String(), e.Message), + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/init.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/init.go new file mode 100644 index 0000000000..122f985924 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/init.go @@ -0,0 +1,21 @@ +package events + +import ( + v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/log" + "k8s.io/klog/v2" +) + +type InitEvent struct { + EventBase + SessionID string `json:"session_id"` + Model string `json:"model"` +} + +func (e *InitEvent) Validate() bool { + return e.Type == EventTypeInit +} + +func (e *InitEvent) Process(_ v1.MessageCallback) { + klog.V(log.LogLevelDebug).Infof("initialized %s model", e.Model) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/message.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/message.go new file mode 100644 index 0000000000..565275c04c --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/message.go @@ -0,0 +1,46 @@ +package events + +import ( + "strings" + + console "github.com/pluralsh/console/go/client" + v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/log" + "k8s.io/klog/v2" +) + +var messageBuilder strings.Builder + +type Role string + +const ( + RoleUser Role = "user" + RoleAssistant Role = "assistant" +) + +func (r Role) Attributes() console.AiRole { + switch r { + case RoleAssistant: + return console.AiRoleAssistant + case RoleUser: + return console.AiRoleUser + default: + return console.AiRoleSystem + } +} + +type MessageEvent struct { + EventBase + Role Role `json:"role"` + Content string `json:"content"` + Delta *bool `json:"delta,omitempty"` +} + +func (e *MessageEvent) Validate() bool { + return e.Type == EventTypeMessage && e.Content != "" && e.Delta != nil && *e.Delta +} + +func (e *MessageEvent) Process(_ v1.MessageCallback) { + messageBuilder.WriteString(e.Content) + klog.V(log.LogLevelDebug).Infof("appended message delta: %s", e.Content) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/result.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/result.go new file mode 100644 index 0000000000..885b1b5dca --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/result.go @@ -0,0 +1,88 @@ +package events + +import ( + console "github.com/pluralsh/console/go/client" + v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/samber/lo" +) + +type StreamStats struct { + TotalTokens int `json:"total_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + DurationMs int `json:"duration_ms"` + ToolCalls int `json:"tool_calls"` +} + +func (s *StreamStats) Attributes() *console.AgentMessageCostAttributes { + if s == nil { + return nil + } + + return &console.AgentMessageCostAttributes{ + Total: float64(s.TotalTokens), + Tokens: &console.AgentMessageTokensAttributes{ + Input: lo.ToPtr(float64(s.InputTokens)), + Output: lo.ToPtr(float64(s.OutputTokens)), + }, + } +} + +type Status string + +const ( + StatusSuccess Status = "success" + StatusError Status = "error" +) + +type ResultEvent struct { + EventBase + Status Status `json:"status"` + Error *ResultError `json:"error,omitempty"` + Stats *StreamStats `json:"stats,omitempty"` +} + +func (e *ResultEvent) Validate() bool { + return e.Type == EventTypeResult +} + +func (e *ResultEvent) Process(onMessage v1.MessageCallback) { + costSent := false + + // If there is a message to send, send it first. + if messageBuilder.Len() > 0 { + onMessage(e.Attributes(), "") + costSent = true + } + + // If there was an error, send that as well. + if e.Status == StatusError { + onMessage(e.ErrorAttributes(costSent), "") + } +} + +func (e *ResultEvent) Attributes() *console.AgentMessageAttributes { + return &console.AgentMessageAttributes{ + Message: messageBuilder.String(), + Role: console.AiRoleAssistant, + Cost: e.Stats.Attributes(), + } +} + +func (e *ResultEvent) ErrorAttributes(costSent bool) *console.AgentMessageAttributes { + attrs := &console.AgentMessageAttributes{ + Role: console.AiRoleSystem, + Message: e.Error.Message, + } + + if !costSent { + attrs.Cost = e.Stats.Attributes() + } + + return attrs +} + +type ResultError struct { + Type string `json:"type"` + Message string `json:"message"` +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result.go new file mode 100644 index 0000000000..161dd73f52 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result.go @@ -0,0 +1,83 @@ +package events + +import ( + "encoding/json" + + console "github.com/pluralsh/console/go/client" + v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/log" + "github.com/samber/lo" + "k8s.io/klog/v2" +) + +type ToolStatus string + +const ( + ToolStatusSuccess ToolStatus = "success" + ToolStatusError ToolStatus = "error" +) + +func (s ToolStatus) Attributes() *console.AgentMessageToolState { + switch s { + case ToolStatusSuccess: + return lo.ToPtr(console.AgentMessageToolStateCompleted) + case ToolStatusError: + return lo.ToPtr(console.AgentMessageToolStateError) + default: + return lo.ToPtr(console.AgentMessageToolStatePending) + } +} + +type ToolResultEvent struct { + EventBase + ToolID string `json:"tool_id"` + Status ToolStatus `json:"status"` + Output *string `json:"output,omitempty"` + Error *ToolResultError `json:"error,omitempty"` +} + +func (e *ToolResultEvent) Validate() bool { + return e.Type == EventTypeToolResult && e.ToolID != "" +} + +func (e *ToolResultEvent) Process(onMessage v1.MessageCallback) { + onMessage(e.Attributes(), e.ToolID) + klog.V(log.LogLevelDebug).Infof("processed tool result event for %s", e.ToolID) +} + +func (e *ToolResultEvent) Attributes() *console.AgentMessageAttributes { + // Always set output so empty/missing results clear the "running..." placeholder on update. + output := lo.FromPtr(e.Output) + if output == "" && e.Error != nil { + output = e.Error.Message + } + attrs := &console.AgentMessageAttributes{ + Message: "Called tool", + Role: console.AiRoleAssistant, + Metadata: &console.AgentMessageMetadataAttributes{ + Tool: &console.AgentMessageToolAttributes{ + Name: lo.ToPtr(e.ToolID), + State: e.Status.Attributes(), + Output: lo.ToPtr(output), + }, + }, + } + + if toolUse, ok := toolUseCache.Get(e.ToolID); ok { + attrs.Metadata.Tool.Name = lo.ToPtr(toolUse.ToolName) + + input, err := json.Marshal(toolUse.Parameters) + if err != nil { + return attrs + } + + attrs.Metadata.Tool.Input = lo.ToPtr(string(input)) + } + + return attrs +} + +type ToolResultError struct { + Type string `json:"type"` + Message string `json:"message"` +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result_test.go new file mode 100644 index 0000000000..e772f56949 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_result_test.go @@ -0,0 +1,36 @@ +package events + +import ( + "testing" + + console "github.com/pluralsh/console/go/client" + "github.com/stretchr/testify/require" +) + +func TestToolResultAttributesAlwaysSetsOutput(t *testing.T) { + event := &ToolResultEvent{ + EventBase: EventBase{Type: EventTypeToolResult}, + ToolID: "tool_1", + Status: ToolStatusSuccess, + } + + attrs := event.Attributes() + require.NotNil(t, attrs.Metadata) + require.NotNil(t, attrs.Metadata.Tool) + require.Equal(t, console.AgentMessageToolStateCompleted, *attrs.Metadata.Tool.State) + require.NotNil(t, attrs.Metadata.Tool.Output) + require.Equal(t, "", *attrs.Metadata.Tool.Output) +} + +func TestToolResultAttributesUsesErrorMessageWhenOutputMissing(t *testing.T) { + event := &ToolResultEvent{ + EventBase: EventBase{Type: EventTypeToolResult}, + ToolID: "tool_1", + Status: ToolStatusError, + Error: &ToolResultError{Type: "fail", Message: "boom"}, + } + + attrs := event.Attributes() + require.Equal(t, console.AgentMessageToolStateError, *attrs.Metadata.Tool.State) + require.Equal(t, "boom", *attrs.Metadata.Tool.Output) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_use.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_use.go new file mode 100644 index 0000000000..6f567d753e --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events/tool_use.go @@ -0,0 +1,64 @@ +package events + +import ( + "encoding/json" + + cmap "github.com/orcaman/concurrent-map/v2" + console "github.com/pluralsh/console/go/client" + v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/log" + "github.com/samber/lo" + "k8s.io/klog/v2" +) + +var toolUseCache = cmap.New[ToolUseEvent]() + +type ToolUseEvent struct { + EventBase + ToolName string `json:"tool_name"` + ToolID string `json:"tool_id"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +func (e *ToolUseEvent) Validate() bool { + return e.Type == EventTypeToolUse && e.ToolID != "" && e.ToolName != "" +} + +func (e *ToolUseEvent) Process(onMessage v1.MessageCallback) { + // If any of the tools is called, send the current message and reset the builder. + if messageBuilder.Len() > 0 { + onMessage(e.Attributes(), "") + messageBuilder.Reset() + } + + toolUseCache.Set(e.ToolID, lo.FromPtr(e)) + klog.V(log.LogLevelDebug).Infof("saved tool use in the cache: %s", e.ToolName) + onMessage(e.RunningAttributes(), e.ToolID) +} + +func (e *ToolUseEvent) Attributes() *console.AgentMessageAttributes { + return &console.AgentMessageAttributes{ + Message: messageBuilder.String(), + Role: console.AiRoleAssistant, + } +} + +func (e *ToolUseEvent) RunningAttributes() *console.AgentMessageAttributes { + attrs := &console.AgentMessageAttributes{ + Message: "Called tool", + Role: console.AiRoleAssistant, + Metadata: &console.AgentMessageMetadataAttributes{ + Tool: &console.AgentMessageToolAttributes{ + Name: lo.ToPtr(e.ToolName), + State: lo.ToPtr(console.AgentMessageToolStateRunning), + Output: lo.ToPtr(v1.RunningToolOutput), + }, + }, + } + if len(e.Parameters) > 0 { + if input, err := json.Marshal(e.Parameters); err == nil { + attrs.Metadata.Tool.Input = lo.ToPtr(string(input)) + } + } + return attrs +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini.go new file mode 100644 index 0000000000..ec883ce1f5 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini.go @@ -0,0 +1,318 @@ +package gemini + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path" + "strings" + + "k8s.io/klog/v2" + + console "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/console/go/deployment-operator/internal/helpers" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/gemini/events" + v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" + "github.com/pluralsh/console/go/deployment-operator/pkg/log" +) + +// Gemini implements v1.Tool interface. +type Gemini struct { + v1.DefaultTool + + // onMessage is a callback called when a new message is received. + onMessage v1.MessageCallback + + // executable is the Gemini executable used to call CLI. + executable exec.Executable + + // apiKey used to authenticate with the API. + apiKey string + + // model used to generate code. + model Model + + // sessionID is the latest native Gemini session identifier observed in stream events. + sessionID string +} + +func (in *Gemini) BabysitRun(ctx context.Context, bCtx *v1.BabysitContext) bool { + if bCtx == nil { + return false + } + + env := in.env() + if in.Config.Run.Runtime.Config.Gemini.Endpoint != nil { + env = append(env, fmt.Sprintf("GEMINI_API_BASE_URL=%s", *in.Config.Run.Runtime.Config.Gemini.Endpoint)) + } + + in.executable = exec.NewExecutable( + "gemini", + exec.WithArgs(in.args(bCtx.Prompt, true)), + exec.WithDir(in.Config.WorkDir), + exec.WithEnv(env), + exec.WithTimeout(in.Config.Run.Runtime.Config.Gemini.Timeout), + ) + + klog.V(log.LogLevelInfo).InfoS("Gemini executable configured", "timeout", in.Config.Run.Runtime.Config.Gemini.Timeout) + + // Send the initial prompt as a message too + if in.onMessage != nil { + in.onMessage(&console.AgentMessageAttributes{Message: bCtx.Prompt, Role: console.AiRoleUser}, "") + } + + err := in.executable.RunStream(ctx, func(line []byte) { + klog.V(log.LogLevelTrace).InfoS("Gemini stream event", "line", string(line)) + + // This is here to prevent unavoidable log lines being reported as errors. + // TODO: Remove once https://github.com/google-gemini/gemini-cli/issues/15053 is fixed. + trimmed := strings.TrimSpace(string(line)) + if !strings.HasPrefix(trimmed, "{") { + klog.V(log.LogLevelDebug).InfoS("ignoring non-json Gemini stream line", "trimmed", trimmed) + return + } + + event := &events.EventBase{} + if err := json.Unmarshal(line, event); err != nil { + klog.ErrorS(err, "failed to unmarshal Gemini stream event", "line", line) + in.Config.ErrorChan <- err + return + } + in.recordSessionID(line, event.Type) + + if err := event.OnMessage(line, in.onMessage); err != nil { + klog.ErrorS(err, "failed to process Gemini stream event", "line", string(line)) + in.Config.ErrorChan <- err + } + }) + if err != nil { + klog.ErrorS(err, "Gemini execution failed") + in.Config.ErrorChan <- err + return false + } + + return false +} + +// FollowUpRun re-runs the Gemini CLI with the same settings as the initial +// run, using followUpPrompt as the user prompt. Errors are returned to the +// caller and must not be sent on ErrorChan. +func (in *Gemini) FollowUpRun(ctx context.Context, followUpPrompt string) error { + klog.V(log.LogLevelInfo).InfoS( + "follow-up: reprompting gemini", + "prompt_len", len(followUpPrompt), + "resumeSession", in.sessionID != "", + "sessionID", in.sessionID, + ) + + env := in.env() + if in.Config.Run.Runtime.Config.Gemini.Endpoint != nil { + env = append(env, fmt.Sprintf("GEMINI_API_BASE_URL=%s", *in.Config.Run.Runtime.Config.Gemini.Endpoint)) + } + + in.executable = exec.NewExecutable( + "gemini", + exec.WithArgs(in.args(followUpPrompt, true)), + exec.WithDir(in.Config.WorkDir), + exec.WithEnv(env), + exec.WithTimeout(in.Config.Run.Runtime.Config.Gemini.Timeout), + ) + + err := in.executable.RunStream(ctx, func(line []byte) { + klog.V(log.LogLevelTrace).InfoS("Gemini stream event (follow-up)", "line", string(line)) + + trimmed := strings.TrimSpace(string(line)) + if !strings.HasPrefix(trimmed, "{") { + klog.V(log.LogLevelDebug).InfoS("ignoring non-json Gemini stream line", "trimmed", trimmed) + return + } + + event := &events.EventBase{} + if err := json.Unmarshal(line, event); err != nil { + klog.ErrorS(err, "failed to unmarshal Gemini stream event (follow-up)", "line", line) + return + } + in.recordSessionID(line, event.Type) + + if err := event.OnMessage(line, in.onMessage); err != nil { + klog.ErrorS(err, "failed to process Gemini stream event (follow-up)", "line", string(line)) + } + }) + if err != nil { + return fmt.Errorf("gemini follow-up execution failed: %w", err) + } + klog.V(log.LogLevelExtended).InfoS("Gemini follow-up execution finished") + return nil +} + +func (in *Gemini) ConfigureBabysitRun() error { + if err := in.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeGemini); err != nil { + return err + } + + return in.ConfigureSkills(in.skillsPath()) +} + +func (in *Gemini) Run(ctx context.Context, options ...exec.Option) { + go in.start(ctx, options...) +} + +func (in *Gemini) start(ctx context.Context, options ...exec.Option) { + env := in.env() + if in.Config.Run.Runtime.Config.Gemini.Endpoint != nil { + env = append(env, fmt.Sprintf("GEMINI_API_BASE_URL=%s", *in.Config.Run.Runtime.Config.Gemini.Endpoint)) + } + + in.executable = exec.NewExecutable( + "gemini", + append( + options, + exec.WithArgs(in.args("", false)), + exec.WithDir(in.Config.WorkDir), + exec.WithEnv(env), + exec.WithTimeout(in.Config.Run.Runtime.Config.Gemini.Timeout), + )..., + ) + + klog.V(log.LogLevelInfo).InfoS("Gemini executable configured", "timeout", in.Config.Run.Runtime.Config.Gemini.Timeout) + + // Send the initial prompt as a message too + if in.onMessage != nil { + in.onMessage(&console.AgentMessageAttributes{Message: in.Config.Run.Prompt, Role: console.AiRoleUser}, "") + } + + err := in.executable.RunStream(ctx, func(line []byte) { + klog.V(log.LogLevelTrace).InfoS("Gemini stream event", "line", string(line)) + + // This is here to prevent unavoidable log lines being reported as errors. + // TODO: Remove once https://github.com/google-gemini/gemini-cli/issues/15053 is fixed. + trimmed := strings.TrimSpace(string(line)) + if !strings.HasPrefix(trimmed, "{") { + klog.V(log.LogLevelDebug).InfoS("ignoring non-json Gemini stream line", "trimmed", trimmed) + return + } + + event := &events.EventBase{} + if err := json.Unmarshal(line, event); err != nil { + klog.ErrorS(err, "failed to unmarshal Gemini stream event", "line", line) + in.Config.ErrorChan <- err + return + } + in.recordSessionID(line, event.Type) + + if err := event.OnMessage(line, in.onMessage); err != nil { + klog.ErrorS(err, "failed to process Gemini stream event", "line", string(line)) + in.Config.ErrorChan <- err + } + }) + if err != nil { + klog.ErrorS(err, "Gemini execution failed") + in.Config.ErrorChan <- err + return + } + klog.V(log.LogLevelExtended).InfoS("Gemini execution finished") + // FinishedChan is closed by the controller after the babysit loop exits. +} + +func (in *Gemini) args(prompt string, resume bool) []string { + if len(prompt) > 0 { + in.Config.Run.Prompt = prompt + } + + args := []string{"--output-format", "stream-json"} + if in.Config.Run.Mode == console.AgentRunModeWrite { + args = append([]string{"--approval-mode", "yolo"}, args...) + } + if resume && in.sessionID != "" { + return append(args, "--resume", in.sessionID, "--prompt", in.Config.Run.Prompt) + } + return append(args, "--prompt", in.Config.Run.Prompt) +} + +func (in *Gemini) Configure(_, _ string) error { + if err := in.ConfigureSystemPrompt(console.AgentRuntimeTypeGemini); err != nil { + return err + } + if err := in.ConfigureSkills(in.skillsPath()); err != nil { + return err + } + + input := &ConfigTemplateInput{ + RepositoryDir: in.Config.RepositoryDir, + AgentRunID: in.Config.Run.ID, + AgentRunMode: in.Config.Run.Mode, + InactivityTimeout: int64(in.Config.Run.Runtime.Config.Gemini.InactivityTimeout.Seconds()), + Model: in.model, + GitAccessToken: os.Getenv("GIT_ACCESS_TOKEN"), + } + + _, content, err := settings(input) + if err != nil { + return err + } + + if err = helpers.File().Create(in.settingsPath(), content, 0644); err != nil { + return fmt.Errorf("failed configuring Gemini settings file %q: %w", SettingsFileName, err) + } + + klog.V(log.LogLevelExtended).InfoS("Gemini configured", "settings", in.settingsPath(), "inactivityTimeout", in.Config.Run.Runtime.Config.Gemini.InactivityTimeout) + return nil +} + +func (in *Gemini) settingsPath() string { + return path.Join(in.providerPath(), SettingsFileName) +} + +func (in *Gemini) skillsPath() string { + return path.Join(in.providerPath(), "skills") +} + +func (in *Gemini) providerPath() string { + return path.Join(in.Config.WorkDir, ".gemini") +} + +func (in *Gemini) env() []string { + return []string{ + fmt.Sprintf("GEMINI_API_KEY=%s", in.apiKey), + fmt.Sprintf("GEMINI_CLI_TRUST_WORKSPACE=%s", "true"), + fmt.Sprintf("GEMINI_CLI_HOME=%s", in.Config.WorkDir), + } +} + +func (in *Gemini) recordSessionID(line []byte, eventType events.EventType) { + if eventType != events.EventTypeInit { + return + } + initEvent := &events.InitEvent{} + if err := json.Unmarshal(line, initEvent); err != nil || initEvent.SessionID == "" { + return + } + in.sessionID = initEvent.SessionID +} + +func (in *Gemini) OnMessage(f v1.MessageCallback) { + in.onMessage = f +} + +func New(config v1.Config) v1.Tool { + if len(config.WorkDir) == 0 { + klog.Fatalln("working directory is not set") + } + + if len(config.RepositoryDir) == 0 { + klog.Fatalln("repository directory is not set") + } + + if config.Run == nil { + klog.Fatalln("agent run is not set") + } + + return &Gemini{ + DefaultTool: v1.DefaultTool{Config: config}, + apiKey: config.Run.Runtime.Config.Gemini.APIKey, + model: EnsureModel(config.Run.Runtime.Config.Gemini.Model), + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini_args_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini_args_test.go new file mode 100644 index 0000000000..0a613d9178 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/gemini_args_test.go @@ -0,0 +1,68 @@ +package gemini + +import ( + "testing" + + console "github.com/pluralsh/console/go/client" + agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" +) + +func TestGeminiArgs(t *testing.T) { + g := &Gemini{ + DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{ + Run: &agentrunv1.AgentRun{Mode: console.AgentRunModeAnalyze, Prompt: "initial"}, + }}, + } + + args := g.args("analyze repo", false) + want := []string{"--output-format", "stream-json", "--prompt", "analyze repo"} + assertArgsEqual(t, want, args) +} + +func TestGeminiArgsWriteMode(t *testing.T) { + g := &Gemini{ + DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{ + Run: &agentrunv1.AgentRun{Mode: console.AgentRunModeWrite, Prompt: "initial"}, + }}, + } + + args := g.args("implement feature", false) + want := []string{ + "--approval-mode", "yolo", + "--output-format", "stream-json", + "--prompt", "implement feature", + } + assertArgsEqual(t, want, args) +} + +func TestGeminiArgsResume(t *testing.T) { + sessionID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + g := &Gemini{ + DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{ + Run: &agentrunv1.AgentRun{Mode: console.AgentRunModeWrite, Prompt: "initial"}, + }}, + sessionID: sessionID, + } + + args := g.args("follow up", true) + want := []string{ + "--approval-mode", "yolo", + "--output-format", "stream-json", + "--resume", sessionID, + "--prompt", "follow up", + } + assertArgsEqual(t, want, args) +} + +func assertArgsEqual(t *testing.T, want, got []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("expected %d args, got %d: %v", len(want), len(got), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("arg[%d]: expected %q, got %q (full: %v)", i, want[i], got[i], got) + } + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/model.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/model.go new file mode 100644 index 0000000000..a1325b4cfc --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/model.go @@ -0,0 +1,19 @@ +package gemini + +type Model string + +const ( + ModelGemini35Flash Model = "gemini-3.5-flash" + ModelGemini31ProPreview Model = "gemini-3.1-pro-preview" + ModelGemini31FlashLite Model = "gemini-3.1-flash-lite" + ModelGemini3ProPreview Model = "gemini-3-pro-preview" + ModelGemini3FlashPreview Model = "gemini-3-flash-preview" +) + +func EnsureModel(model string) Model { + if len(model) == 0 { + return ModelGemini35Flash + } + + return Model(model) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go deleted file mode 100644 index 1111850c9f..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config.go +++ /dev/null @@ -1,58 +0,0 @@ -package gemini - -import ( - "fmt" - - console "github.com/pluralsh/console/go/client" - agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" - proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" -) - -const defaultModel = "gemini-3.5-flash" - -func (*Agent) resolveModel(model string) string { - if model == "" { - return defaultModel - } - return model -} - -func (agent *Agent) ResolveSettings(run *agentrunv1.AgentRun) (toolv1.Settings, error) { - gemini, err := agent.runConfig(run) - if err != nil { - return toolv1.Settings{}, err - } - model := agent.resolveModel(gemini.Model) - if run.IsProxyEnabled() { - model = proxymodel.ProxyModel(console.AgentRuntimeTypeGemini, model) - } - provider := console.AiProviderVertex - return toolv1.Settings{ - Mode: run.Mode, - Model: toolv1.ModelSelection{Provider: &provider, Name: model}, - Timeout: gemini.Timeout, - Proxy: run.IsProxyEnabled(), - }, nil -} - -func (agent *Agent) resolveModelForSettings(config toolv1.Config, settings toolv1.Settings) string { - model := settings.Model.Name - if model == "" { - model = config.Run.Runtime.Config.Gemini.Model - } - model = agent.resolveModel(model) - if config.Run.IsProxyEnabled() { - model = proxymodel.ProxyModel(console.AgentRuntimeTypeGemini, model) - } - return model -} - -func (*Agent) validateMode(mode console.AgentRunMode) error { - switch mode { - case console.AgentRunModeAnalyze, console.AgentRunModeWrite, console.AgentRunModeReview: - return nil - default: - return fmt.Errorf("unsupported gemini run mode %q", mode) - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go deleted file mode 100644 index 67471b8beb..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/runtime_config_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package gemini - -import ( - "testing" - "time" - - console "github.com/pluralsh/console/go/client" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" -) - -func TestResolveSettingsUsesDefaultModel(t *testing.T) { - run := geminiTestRun(console.AgentRunModeReview, "", nil) - run.Runtime.Config.Gemini.Timeout = 7 * time.Minute - settings, err := NewAgent(toolv1.Config{Run: run}).ResolveSettings(run) - if err != nil { - t.Fatalf("ResolveSettings() error = %v", err) - } - if settings.Model.Provider == nil || *settings.Model.Provider != console.AiProviderVertex { - t.Fatalf("provider = %v, want vertex", settings.Model.Provider) - } - if settings.Model.Name != defaultModel || settings.Timeout != 7*time.Minute || settings.Proxy { - t.Fatalf("settings = %#v", settings) - } -} - -func TestResolveSettingsPreservesExplicitModelAndProxy(t *testing.T) { - const explicitModel = "gemini-custom" - run := geminiTestRun(console.AgentRunModeWrite, explicitModel, nil) - run.Runtime.AiProxy = true - - settings, err := NewAgent(toolv1.Config{Run: run}).ResolveSettings(run) - if err != nil { - t.Fatalf("ResolveSettings() error = %v", err) - } - if settings.Model.Provider == nil || *settings.Model.Provider != console.AiProviderVertex { - t.Fatalf("provider = %v, want vertex", settings.Model.Provider) - } - if settings.Model.Name != "vertex/"+explicitModel { - t.Fatalf("model = %q, want %q", settings.Model.Name, "vertex/"+explicitModel) - } - if !settings.Proxy { - t.Fatalf("proxy = false, want true") - } -} - -func TestValidateMode(t *testing.T) { - agent := NewAgent(toolv1.Config{}) - for _, mode := range []console.AgentRunMode{console.AgentRunModeAnalyze, console.AgentRunModeWrite, console.AgentRunModeReview} { - if err := agent.validateMode(mode); err != nil { - t.Fatalf("validateMode(%q) error = %v", mode, err) - } - } - if err := agent.validateMode("unsupported"); err == nil || err.Error() != `unsupported gemini run mode "unsupported"` { - t.Fatalf("validateMode() error = %v", err) - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go index 2607e39455..33f68a882c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go @@ -16,20 +16,16 @@ var settingsTemplate string const SettingsFileName = "settings.json" type ConfigTemplateInput struct { - Model string + Model Model + RepositoryDir string + AgentRunID string AgentRunMode console.AgentRunMode InactivityTimeout int64 + GitAccessToken string } func settings(input *ConfigTemplateInput) (fileName, content string, err error) { - quote := func(value string) (string, error) { - quoted, err := json.Marshal(value) - return string(quoted), err - } - - tmpl, err := template.New(SettingsFileName).Funcs(template.FuncMap{ - "quote": quote, - }).Parse(settingsTemplate) + tmpl, err := template.New(SettingsFileName).Parse(settingsTemplate) if err != nil { return "", "", err } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go index ffcace3feb..441a532538 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go @@ -12,11 +12,12 @@ import ( //nolint:gocyclo func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { baseInput := &ConfigTemplateInput{ - Model: "gemini-3.1-flash-lite", - InactivityTimeout: 300, + Model: ModelGemini31FlashLite, + RepositoryDir: "/repo", + AgentRunID: "run-123", } - t.Run("plural MCP server uses in-pod streamable HTTP URL", func(t *testing.T) { + t.Run("plural MCP server uses in-pod remote URL", func(t *testing.T) { input := *baseInput input.AgentRunMode = console.AgentRunModeWrite @@ -39,15 +40,12 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { t.Fatal("mcpServers.plural missing or not an object") } - url, ok := plural["httpUrl"].(string) + url, ok := plural["url"].(string) if !ok { - t.Fatal("mcpServers.plural.httpUrl missing or not a string") + t.Fatal("mcpServers.plural.url missing or not a string") } if url != "http://127.0.0.1:8080/mcp" { - t.Errorf("expected mcpServers.plural.httpUrl=http://127.0.0.1:8080/mcp, got %q", url) - } - if _, ok := plural["url"]; ok { - t.Fatal("mcpServers.plural unexpectedly configured with SSE url") + t.Errorf("expected mcpServers.plural.url=http://127.0.0.1:8080/mcp, got %q", url) } }) @@ -76,31 +74,7 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { } }) - t.Run("redacts inherited environment variables from shell tools", func(t *testing.T) { - input := *baseInput - input.AgentRunMode = console.AgentRunModeWrite - - _, content, err := settings(&input) - if err != nil { - t.Fatalf("settings() failed: %v", err) - } - - var out struct { - Security struct { - EnvironmentVariableRedaction struct { - Enabled bool `json:"enabled"` - } `json:"environmentVariableRedaction"` - } `json:"security"` - } - if err := json.Unmarshal([]byte(content), &out); err != nil { - t.Fatalf("generated content is not valid JSON: %v", err) - } - if !out.Security.EnvironmentVariableRedaction.Enabled { - t.Fatal("security.environmentVariableRedaction.enabled = false, want true") - } - }) - - t.Run("tools.core differs by mode", func(t *testing.T) { + t.Run("coreTools differ by mode", func(t *testing.T) { writeInput := *baseInput writeInput.AgentRunMode = console.AgentRunModeWrite _, writeContent, err := settings(&writeInput) @@ -133,79 +107,36 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { t.Fatalf("REVIEW content not valid JSON: %v", err) } - writeTools := writeOut["tools"].(map[string]any) - analyzeTools := analyzeOut["tools"].(map[string]any) - reviewTools := reviewOut["tools"].(map[string]any) - writeCoreTools, _ := writeTools["core"].([]any) - analyzeCoreTools, _ := analyzeTools["core"].([]any) - reviewCoreTools, _ := reviewTools["core"].([]any) + writeCoreTools, _ := writeOut["coreTools"].([]any) + analyzeCoreTools, _ := analyzeOut["coreTools"].([]any) + reviewCoreTools, _ := reviewOut["coreTools"].([]any) hasWriteFile := false for _, t := range writeCoreTools { - if s, ok := t.(string); ok && s == "write_file" { + if s, ok := t.(string); ok && s == "WriteFileTool" { hasWriteFile = true break } } if !hasWriteFile { - t.Error("WRITE mode tools.core should include write_file") + t.Error("WRITE mode coreTools should include WriteFileTool") } hasWriteInAnalyze := false for _, t := range analyzeCoreTools { - if s, ok := t.(string); ok && (s == "write_file" || s == "replace") { + if s, ok := t.(string); ok && (s == "WriteFileTool" || s == "EditTool") { hasWriteInAnalyze = true break } } if hasWriteInAnalyze { - t.Error("ANALYZE mode tools.core should not include write_file or replace") + t.Error("ANALYZE mode coreTools should not include WriteFileTool or EditTool") } for _, tool := range reviewCoreTools { - if tool == "write_file" || tool == "replace" { - t.Error("REVIEW mode tools.core should not include write_file or replace") + if tool == "WriteFileTool" || tool == "EditTool" { + t.Error("REVIEW mode coreTools should not include WriteFileTool or EditTool") } } - if _, ok := writeOut["coreTools"]; ok { - t.Error("settings unexpectedly contains deprecated top-level coreTools") - } - if _, ok := writeOut["excludeTools"]; ok { - t.Error("settings unexpectedly contains deprecated top-level excludeTools") - } - if _, ok := writeTools["exclude"]; ok { - t.Error("settings unexpectedly contains deprecated tools.exclude") - } - if writeTools["shell"].(map[string]any)["inactivityTimeout"] != float64(baseInput.InactivityTimeout) { - t.Errorf("tools.shell.inactivityTimeout = %v, want %d", writeTools["shell"].(map[string]any)["inactivityTimeout"], baseInput.InactivityTimeout) - } - }) - - t.Run("quotes model", func(t *testing.T) { - input := *baseInput - input.Model = "gemini-3.1-\"flash\"" - - _, content, err := settings(&input) - if err != nil { - t.Fatalf("settings() failed: %v", err) - } - - var out struct { - Context struct { - IncludeDirectories []string `json:"includeDirectories"` - } `json:"context"` - Model struct { - Name string `json:"name"` - } `json:"model"` - } - if err := json.Unmarshal([]byte(content), &out); err != nil { - t.Fatalf("generated content is not valid JSON: %v", err) - } - if out.Model.Name != input.Model { - t.Errorf("model = %q, want %q", out.Model.Name, input.Model) - } - if len(out.Context.IncludeDirectories) != 0 { - t.Errorf("context.includeDirectories = %#v, want no auxiliary directories", out.Context.IncludeDirectories) - } }) } @@ -213,8 +144,10 @@ func TestSettingsTemplate_ExternalMCPServer(t *testing.T) { t.Setenv(mcp.EnvServers, `[{"name":"linear","url":"https://mcp.linear.app/mcp","allowedTools":["list_issues"],"headers":{"Authorization":"Bearer secret"}}]`) input := &ConfigTemplateInput{ - Model: "gemini-3.1-flash-lite", - AgentRunMode: console.AgentRunModeWrite, + Model: ModelGemini31FlashLite, + RepositoryDir: "/repo", + AgentRunID: "run-123", + AgentRunMode: console.AgentRunModeWrite, } _, content, err := settings(input) if err != nil { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go deleted file mode 100644 index 4449191bc2..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream.go +++ /dev/null @@ -1,368 +0,0 @@ -package gemini - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "strings" - - console "github.com/pluralsh/console/go/client" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" -) - -const successfulToolResultWithoutDisplay = "Tool completed successfully; Gemini CLI did not expose display output." - -type streamEventType string - -const ( - streamEventInit streamEventType = "init" - streamEventMessage streamEventType = "message" - streamEventToolUse streamEventType = "tool_use" - streamEventToolResult streamEventType = "tool_result" - streamEventError streamEventType = "error" - streamEventResult streamEventType = "result" - - streamRoleUser = "user" - streamRoleAssistant = "assistant" - - streamSeverityWarning = "warning" - streamSeverityError = "error" - - streamStatusSuccess = "success" - streamStatusError = "error" -) - -type streamEvent struct { - Type streamEventType `json:"type"` -} - -type streamInitEvent struct { - SessionID string `json:"session_id"` - Model string `json:"model"` -} - -type streamMessageEvent struct { - Role string `json:"role"` - Content *string `json:"content"` - Delta *bool `json:"delta,omitempty"` -} - -type streamToolUseEvent struct { - ToolName string `json:"tool_name"` - ToolID string `json:"tool_id"` - Parameters json.RawMessage `json:"parameters"` -} - -type streamToolResultEvent struct { - ToolID string `json:"tool_id"` - Status string `json:"status"` - Output *string `json:"output,omitempty"` - Error *streamResultError `json:"error,omitempty"` -} - -type streamErrorEvent struct { - Severity string `json:"severity"` - Message string `json:"message"` -} - -type streamResultEvent struct { - Status string `json:"status"` - Error *streamResultError `json:"error,omitempty"` - Stats *streamStats `json:"stats"` -} - -type streamResultError struct { - Type string `json:"type"` - Message string `json:"message"` -} - -type streamStats struct { - TotalTokens int64 `json:"total_tokens"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CachedTokens int64 `json:"cached"` - DurationMS int64 `json:"duration_ms"` - ToolCalls int64 `json:"tool_calls"` -} - -type streamToolCall struct { - name string - input string -} - -type streamTurn struct { - sink toolv1.TurnSink - sessionID string - err error - streamErrorMessage string - assistant strings.Builder - tools map[string]streamToolCall -} - -func newStreamTurn(sessionID string, sink toolv1.TurnSink) *streamTurn { - return &streamTurn{ - sink: sink, - sessionID: sessionID, - tools: make(map[string]streamToolCall), - } -} - -func (turn *streamTurn) consume(line []byte) { - trimmed := bytes.TrimSpace(line) - if len(trimmed) == 0 || trimmed[0] != '{' { - return - } - - base := streamEvent{} - if err := json.Unmarshal(trimmed, &base); err != nil { - turn.recordError(fmt.Errorf("decode gemini stream event: %w", err)) - return - } - - var err error - switch base.Type { - case streamEventInit: - err = turn.handleInit(trimmed) - case streamEventMessage: - err = turn.handleMessage(trimmed) - case streamEventToolUse: - err = turn.handleToolUse(trimmed) - case streamEventToolResult: - err = turn.handleToolResult(trimmed) - case streamEventError: - err = turn.handleError(trimmed) - case streamEventResult: - err = turn.handleResult(trimmed) - default: - return - } - turn.recordError(err) -} - -func (turn *streamTurn) handleInit(line []byte) error { - event := streamInitEvent{} - if err := json.Unmarshal(line, &event); err != nil { - return fmt.Errorf("decode gemini init event: %w", err) - } - if event.SessionID == "" || event.Model == "" { - return errors.New("invalid gemini init event: session id and model are required") - } - - turn.sessionID = event.SessionID - turn.sink.Session(event.SessionID) - return nil -} - -func (turn *streamTurn) handleMessage(line []byte) error { - event := streamMessageEvent{} - if err := json.Unmarshal(line, &event); err != nil { - return fmt.Errorf("decode gemini message event: %w", err) - } - if event.Content == nil { - return errors.New("invalid gemini message event: content is required") - } - - switch event.Role { - case streamRoleUser: - return nil - case streamRoleAssistant: - if event.Delta == nil || !*event.Delta { - return errors.New("invalid gemini message event: assistant message must be a delta") - } - turn.assistant.WriteString(*event.Content) - return nil - default: - return fmt.Errorf("invalid gemini message event: unsupported role %q", event.Role) - } -} - -func (turn *streamTurn) handleToolUse(line []byte) error { - event := streamToolUseEvent{} - if err := json.Unmarshal(line, &event); err != nil { - return fmt.Errorf("decode gemini tool use event: %w", err) - } - if event.ToolID == "" || event.ToolName == "" { - return errors.New("invalid gemini tool use event: tool id and name are required") - } - if _, exists := turn.tools[event.ToolID]; exists { - return fmt.Errorf("invalid gemini tool use event: tool %q was started twice", event.ToolID) - } - input, err := event.input() - if err != nil { - return err - } - - turn.flushAssistant(nil) - turn.tools[event.ToolID] = streamToolCall{name: event.ToolName, input: input} - state := console.AgentMessageToolStateRunning - output := toolv1.RunningToolOutput - turn.sink.Message(&console.AgentMessageAttributes{ - Role: console.AiRoleAssistant, - Message: "Called tool", - Metadata: &console.AgentMessageMetadataAttributes{ - Tool: &console.AgentMessageToolAttributes{ - Name: &event.ToolName, State: &state, Input: &input, Output: &output, - }, - }, - }, event.ToolID) - return nil -} - -func (turn *streamTurn) handleToolResult(line []byte) error { - event := streamToolResultEvent{} - if err := json.Unmarshal(line, &event); err != nil { - return fmt.Errorf("decode gemini tool result event: %w", err) - } - if event.ToolID == "" { - return errors.New("invalid gemini tool result event: tool id is required") - } - call, exists := turn.tools[event.ToolID] - if !exists { - return fmt.Errorf("invalid gemini tool result event: tool %q was not started", event.ToolID) - } - - state := console.AgentMessageToolStateCompleted - switch event.Status { - case streamStatusSuccess: - case streamStatusError: - state = console.AgentMessageToolStateError - if event.Error == nil || event.Error.Message == "" { - return fmt.Errorf("invalid gemini tool result event: tool %q error is required", event.ToolID) - } - default: - return fmt.Errorf("invalid gemini tool result event: unsupported status %q", event.Status) - } - - output := successfulToolResultWithoutDisplay - if event.Output != nil && *event.Output != "" { - output = *event.Output - } else if event.Error != nil { - output = event.Error.Message - } - turn.sink.Message(&console.AgentMessageAttributes{ - Role: console.AiRoleAssistant, - Message: "Called tool", - Metadata: &console.AgentMessageMetadataAttributes{ - Tool: &console.AgentMessageToolAttributes{ - Name: &call.name, State: &state, Input: &call.input, Output: &output, - }, - }, - }, event.ToolID) - delete(turn.tools, event.ToolID) - return nil -} - -func (turn *streamTurn) handleError(line []byte) error { - event := streamErrorEvent{} - if err := json.Unmarshal(line, &event); err != nil { - return fmt.Errorf("decode gemini error event: %w", err) - } - if event.Message == "" { - return errors.New("invalid gemini error event: message is required") - } - - var prefix string - switch event.Severity { - case streamSeverityWarning: - prefix = "Warning" - case streamSeverityError: - prefix = "Error" - turn.streamErrorMessage = event.Message - default: - return fmt.Errorf("invalid gemini error event: unsupported severity %q", event.Severity) - } - - turn.sink.Message(&console.AgentMessageAttributes{ - Role: console.AiRoleSystem, Message: fmt.Sprintf("%s: %s", prefix, event.Message), - }, "") - return nil -} - -func (turn *streamTurn) handleResult(line []byte) error { - event := streamResultEvent{} - if err := json.Unmarshal(line, &event); err != nil { - return fmt.Errorf("decode gemini result event: %w", err) - } - if event.Status != streamStatusSuccess && event.Status != streamStatusError { - return fmt.Errorf("invalid gemini result event: unsupported status %q", event.Status) - } - if event.Error != nil && event.Error.Message == "" { - return errors.New("invalid gemini result event: error message is required") - } - if event.Stats == nil { - return errors.New("invalid gemini result event: stats are required") - } - if err := event.Stats.validate(); err != nil { - return err - } - - turn.sink.Usage(event.Stats.usage()) - turn.flushAssistant(event.Stats) - if event.Status == streamStatusSuccess { - return nil - } - if event.Error != nil { - return fmt.Errorf("gemini result error: %s", event.Error.Message) - } - if turn.streamErrorMessage != "" { - return fmt.Errorf("gemini result error: %s", turn.streamErrorMessage) - } - return errors.New("gemini result status is error") -} - -func (turn *streamTurn) flushAssistant(stats *streamStats) { - message := turn.assistant.String() - if message == "" { - return - } - turn.assistant.Reset() - - attributes := &console.AgentMessageAttributes{Role: console.AiRoleAssistant, Message: message} - if stats != nil { - input := float64(max(stats.InputTokens, 0)) - output := float64(max(stats.OutputTokens, 0)) - attributes.Cost = &console.AgentMessageCostAttributes{ - Tokens: &console.AgentMessageTokensAttributes{Input: &input, Output: &output}, - } - } - turn.sink.Message(attributes, "") -} - -func (turn *streamTurn) recordError(err error) { - if err != nil { - turn.err = errors.Join(turn.err, err) - } -} - -func (event streamToolUseEvent) input() (string, error) { - parameters := map[string]json.RawMessage{} - if len(event.Parameters) == 0 || json.Unmarshal(event.Parameters, ¶meters) != nil || parameters == nil { - return "", errors.New("invalid gemini tool use event: parameters must be an object") - } - - buffer := new(bytes.Buffer) - if err := json.Compact(buffer, event.Parameters); err != nil { - return "", fmt.Errorf("compact gemini tool input: %w", err) - } - return buffer.String(), nil -} - -func (stats streamStats) validate() error { - if stats.TotalTokens < 0 || stats.InputTokens < 0 || stats.OutputTokens < 0 || - stats.CachedTokens < 0 || stats.DurationMS < 0 || stats.ToolCalls < 0 { - return errors.New("invalid gemini result event: stats cannot be negative") - } - return nil -} - -func (stats streamStats) usage() usage.Record { - total := max(stats.TotalTokens, stats.InputTokens+stats.OutputTokens) - return usage.Record{ - InputTokens: stats.InputTokens, - OutputTokens: stats.OutputTokens, - TotalTokens: total, - CachedTokens: stats.CachedTokens, - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream_test.go deleted file mode 100644 index 171d7aaaab..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/stream_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package gemini - -import ( - "strings" - "testing" -) - -func TestStreamTurnRejectsInvalidKnownEvents(t *testing.T) { - tests := []struct { - name string - line string - want string - }{ - { - name: "init without session", - line: `{"type":"init","model":"gemini"}`, - want: "session id and model are required", - }, - { - name: "assistant message without delta", - line: `{"type":"message","role":"assistant","content":"hello"}`, - want: "assistant message must be a delta", - }, - { - name: "tool use without parameters", - line: `{"type":"tool_use","tool_name":"read_file","tool_id":"call"}`, - want: "parameters must be an object", - }, - { - name: "uncorrelated tool result", - line: `{"type":"tool_result","tool_id":"call","status":"success","output":"ok"}`, - want: "was not started", - }, - { - name: "unknown error severity", - line: `{"type":"error","severity":"fatal","message":"boom"}`, - want: "unsupported severity", - }, - { - name: "result without stats", - line: `{"type":"result","status":"success"}`, - want: "stats are required", - }, - { - name: "negative result stats", - line: `{"type":"result","status":"success","stats":{"total_tokens":-1}}`, - want: "stats cannot be negative", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - turn := newStreamTurn("", newTestSink()) - turn.consume([]byte(test.line)) - if turn.err == nil || !strings.Contains(turn.err.Error(), test.want) { - t.Fatalf("stream error = %v, want %q", turn.err, test.want) - } - }) - } -} - -func TestStreamTurnIgnoresNoiseUnknownEventsAndUserMessages(t *testing.T) { - sink := newTestSink() - turn := newStreamTurn("", sink) - for _, line := range []string{ - "[DEBUG] stderr noise", - `{"type":"future_event","value":"ignored"}`, - `{"type":"message","role":"user","content":"runtime already emitted this"}`, - } { - turn.consume([]byte(line)) - } - - if turn.err != nil { - t.Fatalf("stream error = %v", turn.err) - } - if len(sink.messages) != 0 { - t.Fatalf("messages = %#v, want none", sink.messages) - } -} - -func TestStreamTurnStateIsPerTurn(t *testing.T) { - firstSink := newTestSink() - first := newStreamTurn("", firstSink) - first.consume([]byte(`{"type":"message","role":"assistant","content":"first","delta":true}`)) - - secondSink := newTestSink() - second := newStreamTurn("", secondSink) - second.consume([]byte(`{"type":"message","role":"assistant","content":"second","delta":true}`)) - second.consume([]byte(`{"type":"result","status":"success","stats":{}}`)) - first.consume([]byte(`{"type":"result","status":"success","stats":{}}`)) - - if len(firstSink.messages) != 1 || firstSink.messages[0].attributes.Message != "first" { - t.Fatalf("first turn messages = %#v", firstSink.messages) - } - if len(secondSink.messages) != 1 || secondSink.messages[0].attributes.Message != "second" { - t.Fatalf("second turn messages = %#v", secondSink.messages) - } -} - -func TestStreamTurnResultErrorFallback(t *testing.T) { - tests := []struct { - name string - events []string - want string - doesNotWant string - }{ - { - name: "latest severity error", - events: []string{ - `{"type":"error","severity":"error","message":"first error"}`, - `{"type":"error","severity":"warning","message":"later warning"}`, - `{"type":"error","severity":"error","message":"latest error"}`, - }, - want: "latest error", - }, - { - name: "warning is not a fatal fallback", - events: []string{ - `{"type":"error","severity":"warning","message":"warning only"}`, - }, - want: "gemini result status is error", - doesNotWant: "warning only", - }, - { - name: "explicit result error takes precedence", - events: []string{ - `{"type":"error","severity":"error","message":"stream error"}`, - }, - want: "explicit result error", - doesNotWant: "stream error", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - turn := newStreamTurn("", newTestSink()) - for _, event := range test.events { - turn.consume([]byte(event)) - } - result := `{"type":"result","status":"error","stats":{}}` - if test.name == "explicit result error takes precedence" { - result = `{"type":"result","status":"error","error":{"message":"explicit result error"},"stats":{}}` - } - turn.consume([]byte(result)) - - if turn.err == nil || !strings.Contains(turn.err.Error(), test.want) { - t.Fatalf("stream error = %v, want %q", turn.err, test.want) - } - if test.doesNotWant != "" && strings.Contains(turn.err.Error(), test.doesNotWant) { - t.Fatalf("stream error = %v, does not want %q", turn.err, test.doesNotWant) - } - }) - } -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl index 7f7561d808..5191aa00bf 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl @@ -10,39 +10,41 @@ "context": { "fileName": "AGENTS.md" }, - "tools": { - "core": {{ if eq .AgentRunMode "WRITE" }}[ - "read_file", - "read_many_files", - "write_file", - "replace", - "glob", - "list_directory", - "grep_search", - "run_shell_command", - "google_web_search", - "web_fetch", - "save_memory" + "coreTools": {{ if eq .AgentRunMode "WRITE" }}[ + "ReadFileTool", + "ReadManyFilesTool", + "WriteFileTool", + "EditTool", + "GlobTool", + "LSTool", + "GrepTool", + "ShellTool", + "WebSearchTool", + "WebFetchTool", + "MemoryTool" ]{{ else }}[ - "read_file", - "read_many_files", - "glob", - "list_directory", - "grep_search", - "run_shell_command(ls, cd, pwd, git status, git diff, git branch, git log, git show, git merge-base, git rev-parse, head, tail, cat, grep, rg, find)", - "google_web_search", - "save_memory" + "ReadFileTool", + "ReadManyFilesTool", + "GlobTool", + "LSTool", + "GrepTool", + "ShellTool(ls, cd, pwd, git status, git diff, git branch, git log, git show, git merge-base, git rev-parse, head, tail, cat, grep, rg, find)", + "WebSearchTool", + "MemoryTool" ]{{ end }}, - "shell": { - "inactivityTimeout": {{ .InactivityTimeout }} - } - }, + "excludeTools": [ + "ShellTool(rm -rf)" + ], + "includeDirectories": [ + "/plural/contexts", + "{{ .RepositoryDir }}" + ], "model": { - "name": {{ quote .Model }} + "name": "{{ .Model }}" }, "mcpServers": { "plural": { - "httpUrl": "http://127.0.0.1:8080/mcp", + "url": "http://127.0.0.1:8080/mcp", "description": "Plural MCP Server", "trust": true }, @@ -56,15 +58,15 @@ "trust": true } }, - "security": { - "environmentVariableRedaction": { - "enabled": true - } - }, "privacy": { "usageStatisticsEnabled": false }, "telemetry": { "enabled": false + }, + "tools": { + "shell": { + "inactivityTimeout": {{ .InactivityTimeout }} + } } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/invalid_stream.jsonl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/invalid_stream.jsonl deleted file mode 100644 index 17ea379f7f..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/invalid_stream.jsonl +++ /dev/null @@ -1,3 +0,0 @@ -{"type":"init","session_id":"session-invalid-stream","model":"gemini-custom"} -{"type":"error","severity":"error","message":"response contained only thought content"} -{"type":"result","status":"error","stats":{"total_tokens":5,"input_tokens":5,"output_tokens":0,"duration_ms":200,"tool_calls":0}} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/malformed.jsonl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/malformed.jsonl deleted file mode 100644 index 1e14312d51..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/malformed.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{not valid json -{"type":"init","session_id":"session-after-malformed","model":"gemini-custom"} -{"type":"message","role":"assistant","content":"processed after malformed event","delta":true} -{"type":"result","status":"success","stats":{"total_tokens":3,"input_tokens":2,"output_tokens":1}} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/result_error.jsonl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/result_error.jsonl deleted file mode 100644 index 2a5f6245f1..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/result_error.jsonl +++ /dev/null @@ -1,3 +0,0 @@ -{"type":"init","session_id":"session-error","model":"gemini-custom"} -{"type":"message","role":"assistant","content":"partial response","delta":true} -{"type":"result","status":"error","error":{"type":"FatalToolExecutionError","message":"permission denied"},"stats":{"total_tokens":5,"input_tokens":4,"output_tokens":1}} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/success.jsonl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/success.jsonl deleted file mode 100644 index 7dbab14ba3..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/testdata/success.jsonl +++ /dev/null @@ -1,12 +0,0 @@ -{"type":"init","timestamp":"2026-09-10T12:00:00Z","session_id":"session-success","model":"gemini-custom"} -{"type":"message","timestamp":"2026-09-10T12:00:01Z","role":"user","content":"implement feature with spaces"} -{"type":"message","timestamp":"2026-09-10T12:00:02Z","role":"assistant","content":"I will ","delta":true} -{"type":"message","timestamp":"2026-09-10T12:00:03Z","role":"assistant","content":"inspect.","delta":true} -{"type":"tool_use","timestamp":"2026-09-10T12:00:04Z","tool_name":"read_file","tool_id":"call-1","parameters":{"path":"README.md"}} -{"type":"tool_result","timestamp":"2026-09-10T12:00:05Z","tool_id":"call-1","status":"success","output":""} -{"type":"tool_use","timestamp":"2026-09-10T12:00:06Z","tool_name":"run_shell","tool_id":"call-2","parameters":{"command":"false"}} -{"type":"tool_result","timestamp":"2026-09-10T12:00:07Z","tool_id":"call-2","status":"error","error":{"type":"TOOL_EXECUTION_ERROR","message":"command failed"}} -{"type":"error","timestamp":"2026-09-10T12:00:08Z","severity":"warning","message":"approaching turn limit"} -{"type":"future_event","timestamp":"2026-09-10T12:00:09Z","value":"ignored"} -{"type":"message","timestamp":"2026-09-10T12:00:10Z","role":"assistant","content":"Done.","delta":true} -{"type":"result","timestamp":"2026-09-10T12:00:11Z","status":"success","stats":{"total_tokens":25,"input_tokens":20,"output_tokens":10,"cached":4,"duration_ms":900,"tool_calls":2}} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go deleted file mode 100644 index 0070166668..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport.go +++ /dev/null @@ -1,173 +0,0 @@ -package gemini - -import ( - "context" - "errors" - "fmt" - "path/filepath" - "strings" - - console "github.com/pluralsh/console/go/client" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" -) - -const ( - geminiBinary = "gemini" - geminiOutputFormatFlag = "--output-format" - geminiStreamJSONFormat = "stream-json" - geminiModelFlag = "--model" - geminiApprovalModeFlag = "--approval-mode" - geminiApprovalModeYolo = "yolo" - geminiResumeFlag = "--resume" - geminiPromptFlag = "--prompt" - geminiAPIKeyEnv = "GEMINI_API_KEY" - geminiAPIBaseURLEnv = "GEMINI_API_BASE_URL" - geminiGoogleBaseURLEnv = "GOOGLE_GEMINI_BASE_URL" - geminiTrustWorkspaceEnv = "GEMINI_CLI_TRUST_WORKSPACE" - geminiHomeEnv = "GEMINI_CLI_HOME" - geminiTrustWorkspace = "true" - gitConfigCountEnv = "GIT_CONFIG_COUNT" - gitConfigKeyEnv = "GIT_CONFIG_KEY_0" - gitConfigValueEnv = "GIT_CONFIG_VALUE_0" - gitConfigCount = "1" - gitSafeDirectoryKey = "safe.directory" -) - -type Transport struct { - agent *Agent - repositoryDir string -} - -var _ toolv1.Transport = (*Transport)(nil) - -func NewTransport(agent *Agent) (*Transport, error) { - if agent == nil { - return nil, errors.New("gemini agent is not set") - } - config, err := agent.configWithGemini() - if err != nil { - return nil, err - } - repositoryDir, err := filepath.Abs(config.RepositoryDir) - if err != nil { - return nil, fmt.Errorf("resolve gemini repository directory: %w", err) - } - - return &Transport{agent: agent, repositoryDir: repositoryDir}, nil -} - -func (*Transport) Kind() toolv1.TransportKind { - return toolv1.TransportKindRaw -} - -func (transport *Transport) Capabilities() toolv1.TransportCapabilities { - return toolv1.TransportCapabilities{ - SessionResume: true, - ToolCallOutputStreaming: false, - UsageReporting: true, - FileSystemRead: true, - FileSystemWrite: transport.agent.config.Run.Mode == console.AgentRunModeWrite, - } -} - -func (transport *Transport) Turn(ctx context.Context, request toolv1.TurnRequest, sink toolv1.TurnSink) (toolv1.TurnResult, error) { - if ctx == nil { - ctx = context.Background() - } - if err := ctx.Err(); err != nil { - return toolv1.TurnResult{SessionID: request.SessionID}, err - } - if err := transport.agent.validateMode(request.Settings.Mode); err != nil { - return toolv1.TurnResult{SessionID: request.SessionID}, err - } - - executable, err := transport.executable(request) - if err != nil { - return toolv1.TurnResult{SessionID: request.SessionID}, err - } - - turn := newStreamTurn(request.SessionID, sink) - runErr := executable.RunStream(ctx, turn.consume) - return toolv1.TurnResult{SessionID: turn.sessionID}, errors.Join(runErr, turn.err) -} - -func (transport *Transport) executable(request toolv1.TurnRequest) (exec.Executable, error) { - config := transport.agent.config - gemini, err := transport.agent.runConfig(config.Run) - if err != nil { - return nil, err - } - - launchOptions := append([]exec.Option(nil), request.Options...) - launchOptions = append( - launchOptions, - exec.WithArgs(transport.args(request)), - exec.WithEnv(transport.env(config)), - exec.WithDir(transport.repositoryDir), - exec.WithTimeout(gemini.Timeout), - ) - - return exec.NewExecutable(geminiBinary, launchOptions...), nil -} - -func (transport *Transport) env(config toolv1.Config) []string { - return append(transport.agent.env(config), - fmt.Sprintf("%s=%s", gitConfigCountEnv, gitConfigCount), - fmt.Sprintf("%s=%s", gitConfigKeyEnv, gitSafeDirectoryKey), - fmt.Sprintf("%s=%s", gitConfigValueEnv, transport.repositoryDir), - ) -} - -func (transport *Transport) args(request toolv1.TurnRequest) []string { - model := transport.agent.resolveModel(request.Settings.Model.Name) - args := []string{ - geminiOutputFormatFlag, - geminiStreamJSONFormat, - geminiModelFlag, - model, - } - if request.Settings.Mode == console.AgentRunModeWrite { - args = append(args, geminiApprovalModeFlag, geminiApprovalModeYolo) - } - if request.Kind != toolv1.TurnKindInitial && request.SessionID != "" { - args = append(args, geminiResumeFlag, request.SessionID) - } - return append(args, geminiPromptFlag, request.Prompt) -} - -func (agent *Agent) env(config toolv1.Config) []string { - gemini := config.Run.Runtime.Config.Gemini - apiKey := gemini.APIKey - env := []string{ - fmt.Sprintf("%s=%s", geminiAPIKeyEnv, apiKey), - fmt.Sprintf("%s=%s", geminiTrustWorkspaceEnv, geminiTrustWorkspace), - fmt.Sprintf("%s=%s", geminiHomeEnv, config.WorkDir), - } - - if config.Run.IsProxyEnabled() { - apiKey = agent.consoleToken - env[0] = fmt.Sprintf("%s=%s", geminiAPIKeyEnv, apiKey) - if baseURL := agent.proxyBaseURL(); baseURL != "" { - env = append(env, fmt.Sprintf("%s=%s", geminiGoogleBaseURLEnv, baseURL)) - } - return env - } - - if gemini.Endpoint != nil { - env = append(env, fmt.Sprintf("%s=%s", geminiAPIBaseURLEnv, *gemini.Endpoint)) - } - - return env -} - -func (agent *Agent) proxyBaseURL() string { - consoleURL := strings.TrimSuffix(agent.consoleURL, "/") - consoleURL = strings.TrimSuffix(consoleURL, "/ext/gql") - consoleURL = strings.TrimSuffix(consoleURL, "/gql") - consoleURL = strings.TrimSuffix(consoleURL, "/") - if consoleURL == "" { - return "" - } - return consoleURL + "/ext/ai/gemini" -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go deleted file mode 100644 index cf070e5307..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/transport_test.go +++ /dev/null @@ -1,437 +0,0 @@ -package gemini - -import ( - "context" - "errors" - "os" - "path/filepath" - "reflect" - "strings" - "sync/atomic" - "testing" - - console "github.com/pluralsh/console/go/client" - toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" - "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" - "github.com/pluralsh/console/go/deployment-operator/pkg/harness/exec" - stackv1 "github.com/pluralsh/console/go/deployment-operator/pkg/harness/stackrun/v1" -) - -func TestTransportKindAndCapabilities(t *testing.T) { - transport := newTestTransport(t, console.AgentRunModeWrite, "gemini-custom", nil) - capabilities := transport.Capabilities() - if transport.Kind() != toolv1.TransportKindRaw { - t.Fatalf("Kind() = %q, want raw", transport.Kind()) - } - if !capabilities.SessionResume || capabilities.ToolCallOutputStreaming || !capabilities.UsageReporting || - !capabilities.FileSystemRead || !capabilities.FileSystemWrite { - t.Fatalf("Capabilities() = %#v", capabilities) - } -} - -func TestTransportArgs(t *testing.T) { - transport := newTestTransport(t, console.AgentRunModeAnalyze, "", nil) - tests := []struct { - name string - request toolv1.TurnRequest - want []string - }{ - { - name: "initial analyze turn starts a new session", - request: toolv1.TurnRequest{ - Kind: toolv1.TurnKindInitial, - Prompt: "analyze repository", - SessionID: "old-session", - Settings: toolv1.Settings{Mode: console.AgentRunModeAnalyze}, - }, - want: []string{"--output-format", "stream-json", "--model", defaultModel, "--prompt", "analyze repository"}, - }, - { - name: "followup review turn resumes", - request: toolv1.TurnRequest{ - Kind: toolv1.TurnKindFollowup, - Prompt: "review again", - SessionID: "session-1", - Settings: toolv1.Settings{ - Mode: console.AgentRunModeReview, - Model: toolv1.ModelSelection{Name: "gemini-review"}, - }, - }, - want: []string{"--output-format", "stream-json", "--model", "gemini-review", "--resume", "session-1", "--prompt", "review again"}, - }, - { - name: "babysit write turn enables yolo and resumes", - request: toolv1.TurnRequest{ - Kind: toolv1.TurnKindBabysit, - Prompt: "check pull request", - SessionID: "session-2", - Settings: toolv1.Settings{ - Mode: console.AgentRunModeWrite, - Model: toolv1.ModelSelection{Name: "gemini-write"}, - }, - }, - want: []string{ - "--output-format", "stream-json", "--model", "gemini-write", - "--approval-mode", "yolo", "--resume", "session-2", "--prompt", "check pull request", - }, - }, - { - name: "followup without a session starts fresh", - request: toolv1.TurnRequest{ - Kind: toolv1.TurnKindFollowup, - Prompt: "try again", - Settings: toolv1.Settings{Mode: console.AgentRunModeWrite}, - }, - want: []string{ - "--output-format", "stream-json", "--model", defaultModel, - "--approval-mode", "yolo", "--prompt", "try again", - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := transport.args(test.request); !reflect.DeepEqual(got, test.want) { - t.Fatalf("args() = %q, want %q", got, test.want) - } - }) - } -} - -func TestTransportTurnUsesRepositoryCWDAndPreservesExecutionOptions(t *testing.T) { - binDir := t.TempDir() - writeGeminiBinary(t, binDir) - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("GEMINI_TEST_FIXTURE", fixturePath(t, "success.jsonl")) - launchOutput := filepath.Join(t.TempDir(), "launch") - t.Setenv("GEMINI_TEST_OUTPUT", launchOutput) - t.Setenv("GIT_CONFIG_COUNT", "8") - t.Setenv("GIT_CONFIG_KEY_0", "unsafe.key") - t.Setenv("GIT_CONFIG_VALUE_0", "unsafe-value") - - endpoint := "https://api.example" - transport := newTestTransport(t, console.AgentRunModeWrite, "gemini-custom", &endpoint) - sink := newTestSink() - var preStarts, postStarts atomic.Int32 - result, err := transport.Turn(context.Background(), toolv1.TurnRequest{ - Kind: toolv1.TurnKindInitial, - Prompt: "implement feature with spaces", - Settings: toolv1.Settings{ - Mode: console.AgentRunModeWrite, - Model: toolv1.ModelSelection{Name: "gemini-custom"}, - }, - Options: []exec.Option{ - exec.WithEnv([]string{ - "GIT_CONFIG_COUNT=9", - "GIT_CONFIG_KEY_0=another.unsafe.key", - "GIT_CONFIG_VALUE_0=another-unsafe-value", - }), - exec.WithHook(stackv1.LifecyclePreStart, func() error { - preStarts.Add(1) - return nil - }), - exec.WithHook(stackv1.LifecyclePostStart, func() error { - postStarts.Add(1) - return nil - }), - }, - }, sink) - if err != nil { - t.Fatalf("Turn() error = %v", err) - } - if result.SessionID != "session-success" { - t.Fatalf("Turn() session = %q", result.SessionID) - } - if preStarts.Load() != 1 || postStarts.Load() != 1 { - t.Fatalf("lifecycle hooks = %d/%d, want 1/1", preStarts.Load(), postStarts.Load()) - } - - launch, err := os.ReadFile(launchOutput) - if err != nil { - t.Fatal(err) - } - wantLaunchLines := []string{ - "arg=--output-format", "arg=stream-json", "arg=--model", "arg=gemini-custom", - "arg=--approval-mode", "arg=yolo", "arg=--prompt", "arg=implement feature with spaces", - "key=api-key", "endpoint=https://api.example", "trust=true", - "home=" + transport.agent.config.WorkDir, "cwd=" + transport.repositoryDir, - "git_config_count=1", "git_config_key_0=safe.directory", "git_config_value_0=" + transport.repositoryDir, - "git_safe_directory=" + transport.repositoryDir, - } - for _, want := range wantLaunchLines { - if !strings.Contains(string(launch), want+"\n") { - t.Fatalf("launch output missing %q:\n%s", want, launch) - } - } - - assertSuccessfulStream(t, sink) -} - -func TestTransportEnvUsesConsoleCredentialsForProxy(t *testing.T) { - run := geminiTestRun(console.AgentRunModeWrite, "gemini-custom", nil) - run.Runtime.AiProxy = true - config := toolv1.Config{ - WorkDir: t.TempDir(), - RepositoryDir: t.TempDir(), - Run: run, - } - agent := NewAgent(config) - agent.consoleURL = "https://console.example/gql" - agent.consoleToken = "console-token" - values := make(map[string]string) - for _, item := range agent.env(config) { - key, value, ok := strings.Cut(item, "=") - if ok { - values[key] = value - } - } - - if values[geminiAPIKeyEnv] != "console-token" { - t.Fatalf("proxy API key = %q, want Console token", values[geminiAPIKeyEnv]) - } - if values[geminiGoogleBaseURLEnv] != "https://console.example/ext/ai/gemini" { - t.Fatalf("proxy base URL = %q", values[geminiGoogleBaseURLEnv]) - } - if _, ok := values[geminiAPIBaseURLEnv]; ok { - t.Fatal("proxy environment unexpectedly set legacy direct endpoint") - } - if values[geminiAPIKeyEnv] == run.Runtime.Config.Gemini.APIKey { - t.Fatal("proxy environment used provider API key") - } -} - -func TestTransportEnvDoesNotSubstituteProxyCredential(t *testing.T) { - run := geminiTestRun(console.AgentRunModeWrite, "gemini-custom", nil) - run.Runtime.AiProxy = true - config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: run} - - values := make(map[string]string) - for _, item := range NewAgent(config).env(config) { - key, value, ok := strings.Cut(item, "=") - if ok { - values[key] = value - } - } - - if values[geminiAPIKeyEnv] != "" { - t.Fatalf("proxy API key = %q, want empty when Console token is missing", values[geminiAPIKeyEnv]) - } -} - -func TestTransportTurnReportsStreamErrorsAfterDrain(t *testing.T) { - binDir := t.TempDir() - writeGeminiBinary(t, binDir) - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) - - tests := []struct { - name string - fixture string - wantError string - wantSessionID string - wantMessage string - }{ - { - name: "malformed event", - fixture: "malformed.jsonl", - wantError: "decode gemini stream event", - wantSessionID: "session-after-malformed", - wantMessage: "processed after malformed event", - }, - { - name: "error result", - fixture: "result_error.jsonl", - wantError: "permission denied", - wantSessionID: "session-error", - wantMessage: "partial response", - }, - { - name: "invalid stream result uses preceding error event", - fixture: "invalid_stream.jsonl", - wantError: "response contained only thought content", - wantSessionID: "session-invalid-stream", - wantMessage: "Error: response contained only thought content", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Setenv("GEMINI_TEST_FIXTURE", fixturePath(t, test.fixture)) - transport := newTestTransport(t, console.AgentRunModeAnalyze, "gemini-custom", nil) - sink := newTestSink() - var postStarts atomic.Int32 - result, err := transport.Turn(context.Background(), toolv1.TurnRequest{ - Kind: toolv1.TurnKindInitial, - Prompt: "test errors", - Settings: toolv1.Settings{ - Mode: console.AgentRunModeAnalyze, - Model: toolv1.ModelSelection{Name: "gemini-custom"}, - }, - Options: []exec.Option{exec.WithHook(stackv1.LifecyclePostStart, func() error { - postStarts.Add(1) - return nil - })}, - }, sink) - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("Turn() error = %v, want %q", err, test.wantError) - } - if result.SessionID != test.wantSessionID { - t.Fatalf("Turn() session = %q, want %q", result.SessionID, test.wantSessionID) - } - if postStarts.Load() != 1 { - t.Fatalf("post-start hook calls = %d, want 1", postStarts.Load()) - } - if !sink.hasMessage(test.wantMessage) { - t.Fatalf("messages = %#v, want %q after stream error", sink.messages, test.wantMessage) - } - }) - } -} - -func TestTransportTurnRejectsPreCancelledContext(t *testing.T) { - transport := newTestTransport(t, console.AgentRunModeAnalyze, "", nil) - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - result, err := transport.Turn(ctx, toolv1.TurnRequest{SessionID: "existing"}, nil) - if !errors.Is(err, context.Canceled) { - t.Fatalf("Turn() error = %v, want context canceled", err) - } - if result.SessionID != "existing" { - t.Fatalf("Turn() session = %q", result.SessionID) - } -} - -func assertSuccessfulStream(t *testing.T, sink *testSink) { - t.Helper() - if !reflect.DeepEqual(sink.sessions, []string{"session-success"}) { - t.Fatalf("sessions = %q", sink.sessions) - } - if len(sink.messages) != 7 { - t.Fatalf("messages = %#v", sink.messages) - } - if sink.messages[0].attributes.Message != "I will inspect." || sink.messages[0].callID != "" { - t.Fatalf("first assistant message = %#v", sink.messages[0]) - } - assertToolMessage(t, sink.messages[1], "call-1", "read_file", `{"path":"README.md"}`, toolv1.RunningToolOutput, console.AgentMessageToolStateRunning) - assertToolMessage(t, sink.messages[2], "call-1", "read_file", `{"path":"README.md"}`, "Tool completed successfully; Gemini CLI did not expose display output.", console.AgentMessageToolStateCompleted) - assertToolMessage(t, sink.messages[3], "call-2", "run_shell", `{"command":"false"}`, toolv1.RunningToolOutput, console.AgentMessageToolStateRunning) - assertToolMessage(t, sink.messages[4], "call-2", "run_shell", `{"command":"false"}`, "command failed", console.AgentMessageToolStateError) - if sink.messages[5].attributes.Role != console.AiRoleSystem || sink.messages[5].attributes.Message != "Warning: approaching turn limit" { - t.Fatalf("warning message = %#v", sink.messages[5]) - } - final := sink.messages[6].attributes - if final.Role != console.AiRoleAssistant || final.Message != "Done." || final.Cost == nil || final.Cost.Tokens == nil || - final.Cost.Tokens.Input == nil || *final.Cost.Tokens.Input != 20 || - final.Cost.Tokens.Output == nil || *final.Cost.Tokens.Output != 10 { - t.Fatalf("final assistant message = %#v", final) - } - if !reflect.DeepEqual(sink.usages, []usage.Record{{ - InputTokens: 20, OutputTokens: 10, TotalTokens: 30, CachedTokens: 4, - }}) { - t.Fatalf("usage = %#v", sink.usages) - } -} - -func assertToolMessage( - t *testing.T, - message testSinkMessage, - callID, name, input, output string, - state console.AgentMessageToolState, -) { - t.Helper() - tool := message.attributes.Metadata - if message.callID != callID || tool == nil || tool.Tool == nil || tool.Tool.Name == nil || *tool.Tool.Name != name || - tool.Tool.Input == nil || *tool.Tool.Input != input || tool.Tool.Output == nil || *tool.Tool.Output != output || - tool.Tool.State == nil || *tool.Tool.State != state { - t.Fatalf("tool message = %#v", message) - } -} - -func newTestTransport(t *testing.T, mode console.AgentRunMode, model string, endpoint *string) *Transport { - t.Helper() - config := toolv1.Config{ - WorkDir: t.TempDir(), - RepositoryDir: t.TempDir(), - Run: geminiTestRun(mode, model, endpoint), - } - transport, err := NewTransport(NewAgent(config)) - if err != nil { - t.Fatal(err) - } - return transport -} - -func fixturePath(t *testing.T, name string) string { - t.Helper() - path, err := filepath.Abs(filepath.Join("testdata", name)) - if err != nil { - t.Fatal(err) - } - return path -} - -func writeGeminiBinary(t *testing.T, binDir string) { - t.Helper() - script := `#!/bin/sh -if [ -n "$GEMINI_TEST_OUTPUT" ]; then - : > "$GEMINI_TEST_OUTPUT" - for arg in "$@"; do - printf 'arg=%s\n' "$arg" >> "$GEMINI_TEST_OUTPUT" - done - printf 'key=%s\nendpoint=%s\ngoogle_endpoint=%s\ntrust=%s\nhome=%s\ncwd=%s\ngit_config_count=%s\ngit_config_key_0=%s\ngit_config_value_0=%s\n' "$GEMINI_API_KEY" "$GEMINI_API_BASE_URL" "$GOOGLE_GEMINI_BASE_URL" "$GEMINI_CLI_TRUST_WORKSPACE" "$GEMINI_CLI_HOME" "$PWD" "$GIT_CONFIG_COUNT" "$GIT_CONFIG_KEY_0" "$GIT_CONFIG_VALUE_0" >> "$GEMINI_TEST_OUTPUT" - git config --get-all safe.directory | while IFS= read -r directory; do - printf 'git_safe_directory=%s\n' "$directory" >> "$GEMINI_TEST_OUTPUT" - done -fi -printf '[DEBUG] ignored Gemini CLI stderr noise\n' >&2 -if [ -n "$GEMINI_TEST_FIXTURE" ]; then - while IFS= read -r line || [ -n "$line" ]; do - printf '%s\n' "$line" - done < "$GEMINI_TEST_FIXTURE" -fi -` - if err := os.WriteFile(filepath.Join(binDir, geminiBinary), []byte(script), 0755); err != nil { - t.Fatal(err) - } -} - -type testSinkMessage struct { - attributes *console.AgentMessageAttributes - callID string -} - -type testSink struct { - sessions []string - messages []testSinkMessage - outputs map[string]string - usages []usage.Record -} - -func newTestSink() *testSink { - return &testSink{outputs: make(map[string]string)} -} - -func (sink *testSink) Session(sessionID string) { - sink.sessions = append(sink.sessions, sessionID) -} - -func (sink *testSink) Message(attributes *console.AgentMessageAttributes, callID string) { - sink.messages = append(sink.messages, testSinkMessage{attributes: attributes, callID: callID}) -} - -func (sink *testSink) ToolCallOutput(callID, output string) { - sink.outputs[callID] = output -} - -func (sink *testSink) Usage(record usage.Record) { - sink.usages = append(sink.usages, record) -} - -func (sink *testSink) hasMessage(message string) bool { - for _, candidate := range sink.messages { - if candidate.attributes.Message == message { - return true - } - } - return false -} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go index a98ed6bd63..4635086fc6 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go @@ -41,12 +41,7 @@ func New(runtimeType console.AgentRuntimeType, config v1.Config) (v1.Tool, error } return v1.NewRuntime(config, agent, transport) case console.AgentRuntimeTypeGemini: - agent := gemini.NewAgent(config) - transport, err := gemini.NewTransport(agent) - if err != nil { - return nil, err - } - return v1.NewRuntime(config, agent, transport) + return gemini.New(config), nil case console.AgentRuntimeTypeCodex: agent := codex.NewAgent(config) transport, err := codex.NewTransport(agent) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go index a593e324c5..c4a8e66a0e 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go @@ -6,6 +6,7 @@ import ( console "github.com/pluralsh/console/go/client" agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/gemini" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" ) @@ -67,7 +68,7 @@ func TestNewComposesClaudeRuntime(t *testing.T) { } } -func TestNewComposesGeminiRuntime(t *testing.T) { +func TestNewUsesLegacyGeminiTool(t *testing.T) { config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: &agentrunv1.AgentRun{ Mode: console.AgentRunModeWrite, Runtime: &agentrunv1.AgentRuntime{Config: &agentrunv1.AgentRuntimeConfig{ @@ -78,8 +79,8 @@ func TestNewComposesGeminiRuntime(t *testing.T) { if err != nil { t.Fatalf("New() error = %v", err) } - if _, ok := created.(*toolv1.Runtime); !ok { - t.Fatalf("Gemini factory returned %T, want *v1.Runtime", created) + if _, ok := created.(*gemini.Gemini); !ok { + t.Fatalf("Gemini factory returned %T, want *gemini.Gemini", created) } } From dfe7a10b13dc04ee093e5155a9837896116be896 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 14:52:27 +0200 Subject: [PATCH 41/46] revert: next round revert for remaining gemini changes --- go/nexus/internal/middleware/auth.go | 30 ++------ go/nexus/internal/middleware/auth_test.go | 85 ----------------------- go/nexus/internal/router/gemini.go | 12 ++-- go/nexus/internal/router/gemini_test.go | 32 --------- 4 files changed, 12 insertions(+), 147 deletions(-) delete mode 100644 go/nexus/internal/router/gemini_test.go diff --git a/go/nexus/internal/middleware/auth.go b/go/nexus/internal/middleware/auth.go index 2aff96040a..1fbbf032ae 100644 --- a/go/nexus/internal/middleware/auth.go +++ b/go/nexus/internal/middleware/auth.go @@ -17,7 +17,7 @@ type ConsoleAuthenticator interface { // Auth creates an authentication middleware that validates tokens with Console // FR-3.1: Federated authentication to Console via gRPC -// FR-3.2: Support for Bearer tokens and Gemini API key headers +// FR-3.2: Support for Bearer tokens // FR-3.3: Return 403 for invalid tokens // FR-3.4: Return 401 for missing tokens // FR-3.5: No caching - validate on every request @@ -26,16 +26,17 @@ func Auth(authenticator ConsoleAuthenticator) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, authError := requestToken(r) - if authError != "" { - logger.Error(authError, + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + logger.Error("missing authorization header", zap.String("path", r.URL.Path), zap.String("method", r.Method), ) - writeJSONError(w, http.StatusUnauthorized, authError) + writeJSONError(w, http.StatusUnauthorized, "missing authorization header") return } + token := extractToken(authHeader) if token == "" { logger.Error("invalid authorization header format", zap.String("path", r.URL.Path), @@ -77,25 +78,6 @@ func Auth(authenticator ConsoleAuthenticator) func(http.Handler) http.Handler { } } -// requestToken extracts a Console token from the standard Bearer header or the -// Gemini API key header. Authorization takes precedence when both are set. -func requestToken(r *http.Request) (string, string) { - authHeader := r.Header.Get("Authorization") - if authHeader != "" { - token := extractToken(authHeader) - if token == "" { - return "", "invalid authorization header format" - } - return token, "" - } - - token := strings.TrimSpace(r.Header.Get("X-Goog-Api-Key")) - if token == "" { - return "", "missing authorization header" - } - return token, "" -} - // extractToken extracts the token from Authorization header // Supports: // - "Bearer " diff --git a/go/nexus/internal/middleware/auth_test.go b/go/nexus/internal/middleware/auth_test.go index a3ec1f6314..ccd378d986 100644 --- a/go/nexus/internal/middleware/auth_test.go +++ b/go/nexus/internal/middleware/auth_test.go @@ -65,60 +65,6 @@ func TestAuth_BearerToken(t *testing.T) { assert.Equal(t, "test-bearer-token", authenticator.calledWith) } -func TestAuth_GeminiAPIKeyHeader(t *testing.T) { - authenticator := &mockAuthenticator{authenticated: true} - middleware := Auth(authenticator) - handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - - req := httptest.NewRequest("POST", "/gemini/v1beta/models/gemini:generateContent", nil) - req.Header.Set("x-goog-api-key", "console-token") - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "console-token", authenticator.calledWith) -} - -func TestAuth_BearerHeaderTakesPrecedenceOverGeminiAPIKey(t *testing.T) { - authenticator := &mockAuthenticator{authenticated: true} - middleware := Auth(authenticator) - handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - - req := httptest.NewRequest("POST", "/gemini/v1beta/models/gemini:generateContent", nil) - req.Header.Set("Authorization", "Bearer bearer-token") - req.Header.Set("x-goog-api-key", "gemini-token") - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "bearer-token", authenticator.calledWith) -} - -func TestAuth_InvalidAuthorizationTakesPrecedenceOverGeminiAPIKey(t *testing.T) { - authenticator := &mockAuthenticator{authenticated: true} - middleware := Auth(authenticator) - handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("handler should not be called with invalid Authorization") - })) - - req := httptest.NewRequest("POST", "/gemini/v1beta/models/gemini:generateContent", nil) - req.Header.Set("Authorization", "Basic credentials") - req.Header.Set("x-goog-api-key", "gemini-token") - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusUnauthorized, rec.Code) - assert.Contains(t, rec.Body.String(), "invalid authorization header format") - assert.Empty(t, authenticator.calledWith) -} - // TestAuth_InvalidToken tests FR-3.3: Return 403 for invalid tokens func TestAuth_InvalidToken(t *testing.T) { authenticator := &mockAuthenticator{authenticated: false} @@ -307,37 +253,6 @@ func TestExtractToken(t *testing.T) { } } -func TestRequestToken(t *testing.T) { - testCases := []struct { - name string - authority string - apiKey string - expected string - errorMsg string - }{ - {name: "missing headers", errorMsg: "missing authorization header"}, - {name: "gemini API key", apiKey: " gemini-token ", expected: "gemini-token"}, - {name: "invalid Authorization wins", authority: "Basic credentials", apiKey: "gemini-token", errorMsg: "invalid authorization header format"}, - {name: "bearer wins", authority: "Bearer bearer-token", apiKey: "gemini-token", expected: "bearer-token"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest("POST", "/gemini/v1beta/models/gemini:generateContent", nil) - if tc.authority != "" { - req.Header.Set("Authorization", tc.authority) - } - if tc.apiKey != "" { - req.Header.Set("X-Goog-Api-Key", tc.apiKey) - } - - got, errMessage := requestToken(req) - assert.Equal(t, tc.expected, got) - assert.Equal(t, tc.errorMsg, errMessage) - }) - } -} - // TestAuth_CaseInsensitivePrefix tests that Bearer/Deploy prefixes are case-insensitive func TestAuth_CaseInsensitivePrefix(t *testing.T) { testCases := []struct { diff --git a/go/nexus/internal/router/gemini.go b/go/nexus/internal/router/gemini.go index 0239dd73ae..a4bd0a5985 100644 --- a/go/nexus/internal/router/gemini.go +++ b/go/nexus/internal/router/gemini.go @@ -13,13 +13,13 @@ import ( ) const ( - routeGeminiV1GenerateContent = "/gemini/v1/models/{model:.*}:generateContent" - routeGeminiV1StreamGenerateContent = "/gemini/v1/models/{model:.*}:streamGenerateContent" - routeGeminiV1CountTokens = "/gemini/v1/models/{model:.*}:countTokens" + routeGeminiV1GenerateContent = "/gemini/v1/models/{model}:generateContent" + routeGeminiV1StreamGenerateContent = "/gemini/v1/models/{model}:streamGenerateContent" + routeGeminiV1CountTokens = "/gemini/v1/models/{model}:countTokens" - routeGeminiV1BetaGenerateContent = "/gemini/v1beta/models/{model:.*}:generateContent" - routeGeminiV1BetaStreamGenerateContent = "/gemini/v1beta/models/{model:.*}:streamGenerateContent" - routeGeminiV1BetaCountTokens = "/gemini/v1beta/models/{model:.*}:countTokens" + routeGeminiV1BetaGenerateContent = "/gemini/v1beta/models/{model}:generateContent" + routeGeminiV1BetaStreamGenerateContent = "/gemini/v1beta/models/{model}:streamGenerateContent" + routeGeminiV1BetaCountTokens = "/gemini/v1beta/models/{model}:countTokens" ) type geminiContextKey string diff --git a/go/nexus/internal/router/gemini_test.go b/go/nexus/internal/router/gemini_test.go deleted file mode 100644 index a9e04ca42c..0000000000 --- a/go/nexus/internal/router/gemini_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package router - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-chi/chi/v5" -) - -func TestGeminiRouteCapturesProviderPrefixedModel(t *testing.T) { - router := chi.NewRouter() - router.Post(routeGeminiV1BetaGenerateContent, func(w http.ResponseWriter, r *http.Request) { - if got := chi.URLParam(r, "model"); got != "vertex/gemini-custom" { - t.Errorf("model path parameter = %q, want %q", got, "vertex/gemini-custom") - } - w.WriteHeader(http.StatusNoContent) - }) - - req := httptest.NewRequest( - http.MethodPost, - "/gemini/v1beta/models/vertex/gemini-custom:generateContent", - nil, - ) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusNoContent { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) - } -} From ec9021130e347ea96273e92e61bcb6c064982f1d Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 15:40:34 +0200 Subject: [PATCH 42/46] chore(claude): update default claude model --- .../pkg/agentrun-harness/tool/claude/runtime_config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/runtime_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/runtime_config.go index 24f63faef2..7b3c7cd5de 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/runtime_config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/runtime_config.go @@ -8,7 +8,7 @@ import ( toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" ) -const defaultModel = "claude-sonnet-4-6" +const defaultModel = "claude-sonnet-5" const ( defaultModeID = "default" From 6c529e67f90b4f2f9359159b81303bdc5d35b24a Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 15:49:52 +0200 Subject: [PATCH 43/46] refactor(claude): update template and tests for model and settings changes - Replaced `settings.local.json.gotmpl` with `settings.json.gotmpl` in templates. - Updated tests to reflect the renamed settings file and adjusted expected model references. - Changed default model in tests from `claude-sonnet-4-6` to `claude-sonnet-5`. --- .../pkg/agentrun-harness/tool/claude/agent_test.go | 12 ++++++------ .../pkg/agentrun-harness/tool/claude/templates.go | 6 ++++-- ...ttings.local.json.gotmpl => settings.json.gotmpl} | 0 .../agentrun-harness/tool/claude/transport_test.go | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) rename go/deployment-operator/pkg/agentrun-harness/tool/claude/templates/{settings.local.json.gotmpl => settings.json.gotmpl} (100%) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go index bfd877bc4f..8c75fa05e9 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go @@ -50,12 +50,12 @@ func TestAgentPrepareConfigureAndExport(t *testing.T) { t.Fatal(err) } - native, err := os.ReadFile(filepath.Join(workDir, claudeConfigDir, "settings.local.json")) + native, err := os.ReadFile(filepath.Join(workDir, claudeConfigDir, "settings.json")) if err != nil { t.Fatal(err) } - for _, want := range []string{`"model": "claude-sonnet-4-6"`, `"availableModels": [`, `"Write"`, `"BASH_DEFAULT_TIMEOUT_MS"`} { + for _, want := range []string{`"model": "claude-sonnet-5"`, `"availableModels": [`, `"Write"`, `"BASH_DEFAULT_TIMEOUT_MS"`} { if !strings.Contains(string(native), want) { t.Fatalf("native settings missing %q: %s", want, native) } @@ -72,7 +72,7 @@ func TestAgentPrepareConfigureAndExport(t *testing.T) { t.Fatal(err) } - afterBabysit, err := os.ReadFile(filepath.Join(workDir, claudeConfigDir, "settings.local.json")) + afterBabysit, err := os.ReadFile(filepath.Join(workDir, claudeConfigDir, "settings.json")) if err != nil { t.Fatal(err) } @@ -104,16 +104,16 @@ func TestAgentPrepareConfigureAndExport(t *testing.T) { func TestAgentConfigureReadOnlyPermissions(t *testing.T) { useClaudeSystemTemplates(t) - config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: claudeTestRun(console.AgentRunModeReview, "claude-opus", false)} + config := toolv1.Config{WorkDir: t.TempDir(), RepositoryDir: t.TempDir(), Run: claudeTestRun(console.AgentRunModeReview, "claude-sonnet-4-6", false)} agent := NewAgent(config) if err := agent.Configure(context.Background(), toolv1.ConfigureRequest{Phase: toolv1.ConfigurePhaseInitial}); err != nil { t.Fatal(err) } - settings, err := os.ReadFile(filepath.Join(config.WorkDir, claudeConfigDir, "settings.local.json")) + settings, err := os.ReadFile(filepath.Join(config.WorkDir, claudeConfigDir, "settings.json")) if err != nil { t.Fatal(err) } - for _, want := range []string{`"Edit"`, `"Write"`, `"Bash(rm:*)"`} { + for _, want := range []string{`"model": "claude-sonnet-4-6"`, `"availableModels": ["claude-sonnet-4-6"]`, `"Edit"`, `"Write"`, `"Bash(rm:*)"`} { if !strings.Contains(string(settings), want) { t.Fatalf("settings missing deny %q", want) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates.go index cf47c2b7ad..6798cbd249 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates.go @@ -12,10 +12,12 @@ import ( "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" ) -//go:embed templates/settings.local.json.gotmpl +//go:embed templates/settings.json.gotmpl var settingsTemplateText string -const settingsTemplateFileName = "settings.local.json" +// The ACP adapter loads user settings from CLAUDE_CONFIG_DIR/settings.json. +// Its settings.local.json path is rooted under the ACP session working directory. +const settingsTemplateFileName = "settings.json" type settingsTemplateInput struct { Model string diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates/settings.local.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates/settings.json.gotmpl similarity index 100% rename from go/deployment-operator/pkg/agentrun-harness/tool/claude/templates/settings.local.json.gotmpl rename to go/deployment-operator/pkg/agentrun-harness/tool/claude/templates/settings.json.gotmpl diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go index 0c6e316189..8411d8ca71 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go @@ -113,7 +113,7 @@ func TestTransportProjectsClaudeACP(t *testing.T) { if err != nil { t.Fatal(err) } - if settings.Model.Name != "claude-sonnet-4-6" { + if settings.Model.Name != "claude-sonnet-5" { t.Fatalf("model = %q", settings.Model.Name) } mode, err := transport.agent.modeID(settings.Mode) From a6a44e7e876ebc73072c5728cf4a33cf3c14d3a2 Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 16:15:31 +0200 Subject: [PATCH 44/46] chore: update Claude model version to `claude-sonnet-4-6` - Updated `anthropic.model` and `anthropic.toolModel` in settings.yaml - Changed `config.claude.model` in claude.yaml runtime config - Modified `defaultModel` constant in runtime_config.go --- .../pkg/agentrun-harness/tool/claude/runtime_config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/runtime_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/runtime_config.go index 7b3c7cd5de..24f63faef2 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/runtime_config.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/runtime_config.go @@ -8,7 +8,7 @@ import ( toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" ) -const defaultModel = "claude-sonnet-5" +const defaultModel = "claude-sonnet-4-6" const ( defaultModeID = "default" From 5a4f07c92c5e5734b22bd60044e306ba1126824b Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 16:26:26 +0200 Subject: [PATCH 45/46] chore: update test to use `claude-sonnet-4-6` model reference - Adjusted expected model value in `agent_test.go` from `claude-sonnet-5` to `claude-sonnet-4-6` to match runtime changes. --- .../pkg/agentrun-harness/tool/claude/agent_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go index 8c75fa05e9..7f7a2fac4e 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go @@ -55,7 +55,7 @@ func TestAgentPrepareConfigureAndExport(t *testing.T) { t.Fatal(err) } - for _, want := range []string{`"model": "claude-sonnet-5"`, `"availableModels": [`, `"Write"`, `"BASH_DEFAULT_TIMEOUT_MS"`} { + for _, want := range []string{`"model": "claude-sonnet-4-6"`, `"availableModels": [`, `"Write"`, `"BASH_DEFAULT_TIMEOUT_MS"`} { if !strings.Contains(string(native), want) { t.Fatalf("native settings missing %q: %s", want, native) } From 727c8eab019d1beba5f981564553de75650e2e2c Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Fri, 11 Sep 2026 16:35:36 +0200 Subject: [PATCH 46/46] chore(tests): update expected model to `claude-sonnet-4-6` - Adjusted `transport_test.go` to reflect model change from `claude-sonnet-5` to `claude-sonnet-4-6`. --- .../pkg/agentrun-harness/tool/claude/transport_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go index 8411d8ca71..b9d201289a 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go @@ -11,6 +11,7 @@ import ( "testing" acpsdk "github.com/coder/acp-go-sdk" + console "github.com/pluralsh/console/go/client" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" @@ -113,7 +114,7 @@ func TestTransportProjectsClaudeACP(t *testing.T) { if err != nil { t.Fatal(err) } - if settings.Model.Name != "claude-sonnet-5" { + if settings.Model.Name != "claude-sonnet-4-6" { t.Fatalf("model = %q", settings.Model.Name) } mode, err := transport.agent.modeID(settings.Mode)