diff --git a/.github/workflows/deployment-operator-cd-agent-harness.yaml b/.github/workflows/deployment-operator-cd-agent-harness.yaml index ad4308fbc0..567e04113c 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.17.3 - CODEX_VERSION: 0.104.0 + OPENCODE_VERSION: 1.18.23 + CODEX_VERSION: 0.153.4 PI_VERSION: 0.84.1 outputs: node: ${{ env.NODE_VERSION }} @@ -179,13 +179,13 @@ jobs: matrix: agents: - name: claude - version: 2.1.72 + version: 2.1.236 - name: gemini version: 0.44.1 - name: opencode - version: 1.17.3 + version: 1.18.23 - name: codex - version: 0.104.0 + version: 0.153.4 - name: pi version: 0.84.1 permissions: 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 67cc52654f..84003afe1b 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/codex.Dockerfile @@ -1,57 +1,43 @@ ARG NODE_IMAGE_TAG=24 ARG NODE_IMAGE=node:${NODE_IMAGE_TAG}-slim -ARG AGENT_VERSION=0.104.0 +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 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 pinned Codex ACP adapter and native Codex binaries. 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 ACP_VERSION +ARG AGENT_VERSION +RUN npm install -g "@agentclientprotocol/codex-acp@$ACP_VERSION" "@openai/codex@$AGENT_VERSION" # Verify installation -RUN codex --version +RUN codex-acp --version -# Stage 2: Copy codex CLI 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 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/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 +# 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 && \ + 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 # 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/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/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/go.mod b/go/deployment-operator/go.mod index 7e9780bf2e..e50b6840ad 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/ewhauser/gomonty v0.0.14 diff --git a/go/deployment-operator/go.sum b/go/deployment-operator/go.sum index 251d775b4d..f0c0f896e4 100644 --- a/go/deployment-operator/go.sum +++ b/go/deployment-operator/go.sum @@ -252,6 +252,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 c57cff8a87..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.17.3", - console.AgentRuntimeTypeCodex: "%s-codex-0.104.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/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/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.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go new file mode 100644 index 0000000000..382aad6725 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client.go @@ -0,0 +1,315 @@ +package acp + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" + + acpsdk "github.com/coder/acp-go-sdk" +) + +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 + 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 := client.acquireTextFileRead(ctx); err != nil { + return acpsdk.ReadTextFileResponse{}, err + } + defer client.releaseTextFileRead() + + file, err := client.openTextFile(request.Path) + if err != nil { + return acpsdk.ReadTextFileResponse{}, err + } + + return client.readTextFile(ctx, file, request) +} + +func (client *client) acquireTextFileRead(ctx context.Context) error { + if client.textFileReadSlots == nil { + return errReadGateUninitialized + } + if err := ctx.Err(); err != nil { + return err + } + + select { + case client.textFileReadSlots <- struct{}{}: + if err := ctx.Err(); err != nil { + client.releaseTextFileRead() + return err + } + + return nil + case <-ctx.Done(): + return ctx.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()) + }() + + 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) { + buffered := bufio.NewReader(io.LimitReader(reader, maxTextFileBytes+1)) + exhausted, err := client.skipTextFileLines(buffered, request.Line, request.Path) + if err != nil { + return acpsdk.ReadTextFileResponse{}, err + } + if exhausted { + return acpsdk.ReadTextFileResponse{}, nil + } + + content, err := client.readTextFileContent(buffered, 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 +} + +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) + } + } + + 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 + } + + lines := make([]string, 0, min(*limit, 1024)) + 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 + } + return "", fmt.Errorf("read %s: %w", path, err) + } + } + + return 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 + } + + read, err := reader.reader.Read(buffer) + if contextErr := reader.ctx.Err(); contextErr != nil { + return 0, contextErr + } + + return read, err +} + +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 !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() + } + + 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 +} + +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, 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") +} + +func (*client) CreateTerminal(context.Context, acpsdk.CreateTerminalRequest) (acpsdk.CreateTerminalResponse, error) { + return acpsdk.CreateTerminalResponse{}, errTerminalUnavailable +} + +func (*client) KillTerminal(context.Context, acpsdk.KillTerminalRequest) (acpsdk.KillTerminalResponse, error) { + return acpsdk.KillTerminalResponse{}, errTerminalUnavailable +} + +func (*client) TerminalOutput(context.Context, acpsdk.TerminalOutputRequest) (acpsdk.TerminalOutputResponse, error) { + return acpsdk.TerminalOutputResponse{}, errTerminalUnavailable +} + +func (*client) ReleaseTerminal(context.Context, acpsdk.ReleaseTerminalRequest) (acpsdk.ReleaseTerminalResponse, error) { + return acpsdk.ReleaseTerminalResponse{}, errTerminalUnavailable +} + +func (*client) WaitForTerminalExit(context.Context, acpsdk.WaitForTerminalExitRequest) (acpsdk.WaitForTerminalExitResponse, error) { + return acpsdk.WaitForTerminalExitResponse{}, errTerminalUnavailable +} + +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 +} 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..af36c238cd --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/client_test.go @@ -0,0 +1,811 @@ +package acp + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +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() + root, err := os.OpenRoot(directory) + if err != nil { + t.Fatalf("open test root: %v", err) + } + t.Cleanup(func() { _ = root.Close() }) + engine := NewEngine() + return newClient( + newTurn(engine, &testSink{}, "session-1"), + directory, + root, + fileSystemWrite, + ), directory +} + +func TestClientReadsAndWritesTextFiles(t *testing.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) + } + 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 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(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() + + 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 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("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 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 *blockingReadCloser) Read([]byte) (int, error) { + reader.readStartedOnce.Do(func() { close(reader.readStarted) }) + <-reader.releaseRead + reader.readCount++ + if reader.readCount > 1 { + return 0, io.EOF + } + return reader.read, reader.readErr +} + +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") + + _, 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 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 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 + 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 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} + 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 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 TestClientMapsRawInputAndStartContent(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, 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 TestClientKeepsTerminalStartContentAsOutput(t *testing.T) { + sink := &testSink{} + acpClient := &client{turn: newTurn(NewEngine(), 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 start mapping = %#v", tool) + } + if len(sink.outputs) != 0 { + t.Fatalf("terminal start output events = %v", sink.outputs) + } + if _, exists := acpClient.turn.tools["call-1"]; exists { + t.Fatal("terminal start remained active") + } +} + +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() + 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 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{ + {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, true) + 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) + } +} + +func TestClientRejectsFIFOWithoutBlocking(t *testing.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) + } + + 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, true) + 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, true) + 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 new file mode 100644 index 0000000000..35a98e7f54 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine.go @@ -0,0 +1,226 @@ +// 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 = 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 + 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 { + 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, reasoning string) error { + 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) +} + +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 +} + +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, 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) + + return Result{SessionID: attempt.sessionID}, err +} + +// 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, + } + + 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 new file mode 100644 index 0000000000..7672984814 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/engine_test.go @@ -0,0 +1,864 @@ +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 + loadedSessions []acpsdk.LoadSessionRequest + authentications []acpsdk.AuthenticateRequest + callOrder []string + 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 + authMethods []acpsdk.AuthMethod + authenticateErr error +} + +type testAgent struct { + state *testState + conn *acpsdk.AgentSideConnection +} + +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), AuthMethods: authMethods}, 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) + 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...) + 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, Meta: agent.state.responseMeta}, 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) 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 + 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) + 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 + killed bool + stopped bool + stopReportsKill bool + 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() + 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 = !process.stopReportsKill + process.mu.Unlock() + return process.kill() +} + +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.stop, + 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 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 { + t.Fatalf("default engine = %#v", defaults) + } + + resolver := func(acpsdk.PromptResponse) *acpsdk.Usage { return &acpsdk.Usage{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) + } +} + +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(WithStopTimeout(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() + 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) + } +} + +func TestEngineTurnAdvertisesClientCapabilities(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 + if !capabilities.Fs.ReadTextFile || capabilities.Fs.WriteTextFile != test.fileSystemWrite { + t.Fatalf("filesystem capabilities = %#v", capabilities.Fs) + } + 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) + } + }) + } +} + +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 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{ + {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() + _, 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 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().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 + 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().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 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().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(WithStopTimeout(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") + } +} + +func TestEngineTurnIgnoresCleanupKillAfterSuccessfulPrompt(t *testing.T) { + state := newTestState() + _, process, cleanup := newTestAgentProcess(state, false) + cleanup.stopReportsKill = true + _, err := NewEngine(WithStopTimeout(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(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)) { + 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(WithStopTimeout(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 new file mode 100644 index 0000000000..25d9c97867 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session.go @@ -0,0 +1,346 @@ +package acp + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "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" +) + +type sessionAttempt struct { + 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 { + sessionID string + modes *acpsdk.SessionModeState + configOptions []acpsdk.SessionConfigOption +} + +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()) + } + 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 { + 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) + if err = attempt.turn.err(); err != nil { + return attempt.fail(err, attempt.cancelled()) + } + if err = attempt.stopIfCancelled(); err != nil { + return err + } + + resultErr := attempt.promptResult(response.StopReason) + stopErr := attempt.stop(false) + if resultErr != nil { + return attempt.processFailure(resultErr, stopErr) + } + if stopErr != nil { + klog.V(log.LogLevelDebug).InfoS("ACP process cleanup failed after completed prompt", "error", stopErr) + } + + 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 == "" { + attempt.turn.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", + Version: "1", + }, + ClientCapabilities: acpsdk.ClientCapabilities{ + Fs: acpsdk.FileSystemCapabilities{ + ReadTextFile: true, + WriteTextFile: attempt.fileSystemWrite, + }, + Auth: acpsdk.AuthCapabilities{}, + }, + }) +} + +func (attempt *sessionAttempt) openSession(cwd string) (sessionDetails, error) { + existingSession := attempt.priorSessionID + if existingSession == "" { + return attempt.createSession(cwd) + } + return attempt.resumeSession(cwd, existingSession) +} + +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) + if provisionalID := attempt.turn.sessionID(); provisionalID != "" && provisionalID != sessionID { + return sessionDetails{}, attempt.turn.sessionUpdateMismatch(acpsdk.SessionId(provisionalID), sessionID) + } + attempt.turn.setSessionID(sessionID) + attempt.sessionID = sessionID + attempt.turn.sink.Session(sessionID) + return sessionDetails{ + sessionID: sessionID, + modes: created.Modes, + configOptions: created.ConfigOptions, + }, nil +} + +func (attempt *sessionAttempt) resumeSession(cwd, sessionID string) (sessionDetails, error) { + 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 restore: %w", err) + } + attempt.turn.setSessionID(sessionID) + attempt.sessionID = sessionID + attempt.turn.sink.Session(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)}, + }) +} + +func (attempt *sessionAttempt) finishTurn(response acpsdk.PromptResponse) { + attempt.turn.emitAssistant(attempt.engine.usageResolver(response)) +} + +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() +} + +func (attempt *sessionAttempt) promptFailure(err error) error { + cancelled := attempt.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 attempt.processFailure(fmt.Errorf("acp session/prompt: %w", err), stopErr) +} + +func (attempt *sessionAttempt) fail(err error, cancel bool) error { + 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 +} + +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) +} + +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() + } + // 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.engine.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.engine.stopTimeout) + defer timer.Stop() + select { + case waitErr := <-waitCh: + return waitErr + case <-timer.C: + return attempt.stopAndWait(waitCh) + } +} + +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 +} + +func (attempt *sessionAttempt) 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", reason) + default: + return fmt.Errorf("acp prompt returned unexpected stop reason %q", reason) + } +} + +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) + protocolClient := newClient( + turn, + request.Cwd, + root, + request.FileSystemWrite, + ) + attempt := &sessionAttempt{ + engine: engine, + ctx: ctx, + process: process, + connection: acpsdk.NewClientSideConnection(protocolClient, 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, nil +} 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..56202fc04c --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/session_test.go @@ -0,0 +1,96 @@ +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().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{ + {SessionId: "session-1", Update: acpsdk.UpdateAgentMessageText("early ")}, + } + state.promptUpdates = []acpsdk.SessionUpdate{acpsdk.UpdateAgentMessageText("response")} + sink := &testSink{} + _, process, _ := newTestAgentProcess(state, true) + 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() + 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().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().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/tool_call.go b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go new file mode 100644 index 0000000000..cf5f0e6c52 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call.go @@ -0,0 +1,221 @@ +package acp + +import ( + "encoding/json" + "fmt" + "strings" + + acpsdk "github.com/coder/acp-go-sdk" + + console "github.com/pluralsh/console/go/client" +) + +const runningToolOutput = "running..." + +type toolCall struct { + id string + name string + title string + kind acpsdk.ToolKind + input string + output string + state console.AgentMessageToolState +} + +type toolOutputValue struct { + text string + delta bool +} + +func (call *toolCall) addOutput(output string) { + if output == "" || output == call.output { + return + } + call.output = output +} + +func (call *toolCall) appendOutput(output string) { + if output != "" { + 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, 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 { + 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 + } + formatted, ok := envelope["formatted_output"].(string) + return formatted, ok +} + +type toolUpdateEvents struct { + message *console.AgentMessageAttributes + output string + streamOutput bool + terminal bool +} + +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 + } + message := &console.AgentMessageAttributes{ + Role: console.AiRoleAssistant, + Message: "Called tool", + Metadata: &console.AgentMessageMetadataAttributes{ + Tool: &console.AgentMessageToolAttributes{ + Name: new(name), State: &state, Output: new(output), + }, + }, + } + if call.input != "" { + message.Metadata.Tool.Input = new(call.input) + } + return message +} + +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 call.title != "": + return call.title + case call.kind != "": + return string(call.kind) + default: + return "tool" + } +} + +func (call *toolCall) updateMetadata(update *acpsdk.SessionToolCallUpdate) bool { + changed := false + 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 { + input := call.formatValue(update.RawInput) + if call.input != input { + call.input = input + changed = true + } + } + return changed +} + +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 (*toolCall) status(status *acpsdk.ToolCallStatus) (console.AgentMessageToolState, bool, error) { + if status == nil { + return "", false, nil + } + 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, fmt.Errorf("acp tool call has unknown status %q", *status) + } + terminal := state == console.AgentMessageToolStateCompleted || state == console.AgentMessageToolStateError + return state, terminal, 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 new file mode 100644 index 0000000000..25940b08c6 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/tool_call_test.go @@ -0,0 +1,125 @@ +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, nil, map[string]string{"result": "raw"}); got.text != "displayed" { + t.Fatalf("content output = %q", got.text) + } + 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) { + t.Fatalf("fallback tool name = %q", call.name) + } +} + +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"}}} + 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() + 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) + } + 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) { + 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..d12c82be8f --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/types.go @@ -0,0 +1,129 @@ +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" +) + +// SessionSettings are the ACP-native identifiers projected by a provider. +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. +type Request struct { + Cwd string + Prompt string + SessionID string + Settings SessionSettings + FileSystemWrite bool +} + +// 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) +} + +// 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 + } + } +} + +// 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) { + 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 new file mode 100644 index 0000000000..bc80b10324 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/acp/updates.go @@ -0,0 +1,456 @@ +package acp + +import ( + "errors" + "fmt" + "strings" + "sync" + + 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 { + engine *Engine + sink Sink + mu sync.Mutex + toolMu sync.Mutex + sessionIDValue string + errValue error + assistant strings.Builder + reasoning strings.Builder + tools map[string]*toolCall + cost float64 + restoring bool +} + +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 (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 { + 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) 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() + 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 err := turn.bindNotification(notification.SessionId); err != nil { + return err + } + if turn.isRestoring() { + return nil + } + + update := notification.Update + switch { + case update.AgentMessageChunk != nil: + return turn.appendTextChunk(update.AgentMessageChunk.Content, &turn.assistant, "agent message") + case update.AgentThoughtChunk != nil: + return turn.appendTextChunk(update.AgentThoughtChunk.Content, &turn.reasoning, "agent thought") + 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 := turn.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) 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") + } + + 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()) + } + + if message != nil { + 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 { + 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 + } + + toolOutputValue := call.toolOutput(update.Content, update.Meta, update.RawOutput) + call.applyOutput(toolOutputValue) + if call.isTerminal() { + return call.message(), "", nil + } + turn.tools[id] = call + 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() + if err != nil { + return turn.fail(err.Error()) + } + + if message != nil { + turn.sink.Message(message, string(update.ToolCallId)) + } + if 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 { + if update.ToolCallId == "" { + return turn.fail("acp tool call update has an empty id") + } + + 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 +} + +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, + streamOutput: streamOutput, + terminal: terminal, + }, 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) + } + + if events.message != nil { + turn.sink.Message(events.message, string(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 := turn.normalizeUsage(responseUsage) + turn.sink.Usage(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.sink.Message(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.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() +} + +func (turn *turnState) fail(message string) error { + err := errors.New(message) + turn.setErr(err) + return err +} + +func newTurn(engine *Engine, sink Sink, sessionID string) *turnState { + return &turnState{ + engine: engine, + sink: sink, + sessionIDValue: sessionID, + tools: make(map[string]*toolCall), + } +} 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/acp_environment.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go new file mode 100644 index 0000000000..963ab69d2d --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/acp_environment.go @@ -0,0 +1,42 @@ +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" + claudeSandboxEnv = "IS_SANDBOX" + 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), + // 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() { + 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..785b3b883e --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent.go @@ -0,0 +1,211 @@ +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) + found, err := artifacts.StageSessionDirectory(ctx, source, request.OutputDir) + if err != nil { + return toolv1.ExportResult{}, fmt.Errorf("stage claude projects: %w", err) + } + if !found { + return toolv1.ExportResult{}, nil + } + + 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..9e21de8c19 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_config.go @@ -0,0 +1,51 @@ +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 := &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, + } + _, 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 new file mode 100644 index 0000000000..7f7a2fac4e --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agent_test.go @@ -0,0 +1,139 @@ +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.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.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-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.json")) + if err != nil { + t.Fatal(err) + } + 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) + } + } +} + +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_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/claude_templates.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates.go similarity index 50% rename from go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_templates.go rename to go/deployment-operator/pkg/agentrun-harness/tool/claude/templates.go index 804e0dcf31..6798cbd249 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude_templates.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates.go @@ -1,99 +1,69 @@ package claude import ( + _ "embed" "encoding/json" "fmt" "os" "path/filepath" -) + "strings" + "text/template" -// PluralMCPToolsWildcard allows any tool exposed by the in-pod plural MCP server. -const ( - PluralMCPToolsWildcard = "mcp__plural__*" - CodebaseMemoryMCPToolsWildcard = "mcp__codebase-memory-mcp__*" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" ) -type SettingsBuilder struct { - settings Settings -} +//go:embed templates/settings.json.gotmpl +var settingsTemplateText string -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"` -} +// 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 Permissions struct { - Allow []string `json:"allow"` - Deny []string `json:"deny"` +type settingsTemplateInput struct { + Model string + BashDefaultTimeoutMS string + BashMaxTimeoutMS string + ExternalMCPServers []mcp.Server + ReadOnly bool } -func NewSettingsBuilder(model Model) *SettingsBuilder { - return &SettingsBuilder{ - settings: Settings{ - Model: string(model), - Temperature: 0.1, - EnableAllProjectMcpServers: true, - Permissions: Permissions{ - Allow: []string{}, - Deny: []string{}, - }, - Env: make(map[string]string), - Custom: make(map[string]interface{}), - }, +func settingsTemplate(input *settingsTemplateInput) (string, error) { + quote := func(value string) (string, error) { + quoted, err := json.Marshal(value) + return string(quoted), err } -} -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 -} + tmpl, err := template.New(settingsTemplateFileName).Funcs(template.FuncMap{ + "quote": quote, + }).Parse(settingsTemplateText) + if err != nil { + return "", err + } -func (b *SettingsBuilder) WithEnv(key, value string) *SettingsBuilder { - b.settings.Env[key] = value - return b -} + output := new(strings.Builder) + if err := tmpl.Execute(output, input); err != nil { + return "", err + } -func (b *SettingsBuilder) Build() Settings { - return b.settings + return output.String(), nil } -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) +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) } - // Marshal with indentation - data, err := json.MarshalIndent(b.settings, "", " ") + content, err := settingsTemplate(input) if err != nil { - return fmt.Errorf("failed to marshal settings: %w", err) + return "", fmt.Errorf("render settings template: %w", err) } - // Write to file - if err := os.WriteFile(path, data, 0644); err != nil { - return fmt.Errorf("failed to write file: %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 nil + return filePath, nil } type MCPConfig struct { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates/settings.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates/settings.json.gotmpl new file mode 100644 index 0000000000..f98597a959 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/templates/settings.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 +} 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..b8bf29a73d --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport.go @@ -0,0 +1,87 @@ +package claude + +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 claudeACPBinary = "claude-agent-acp" + +type Transport struct { + agent *Agent + engine *acp.Engine + repositoryDir 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 + } + repositoryDir, err := filepath.Abs(config.RepositoryDir) + if err != nil { + return nil, fmt.Errorf("resolve claude repository directory: %w", err) + } + return &Transport{agent: agent, engine: acp.NewEngine(), repositoryDir: repositoryDir}, nil +} + +func (*Transport) Kind() toolv1.TransportKind { + return toolv1.TransportKindACP +} +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 + } + 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.repositoryDir, + 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 +} + +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.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 new file mode 100644 index 0000000000..b9d201289a --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/transport_test.go @@ -0,0 +1,261 @@ +package claude + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "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 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, claudeSandboxEnv + "=1", anthropicAPIKeyEnv + "=api-key", anthropicBaseURLEnv + "=" + endpoint} { + if !strings.Contains(string(content), want) { + t.Fatalf("environment missing %q: %s", want, content) + } + } +} + +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)) + if err != nil { + t.Fatal(err) + } + 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) + 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\\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) + } +} + +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 { + key, value, ok := strings.Cut(item, "=") + if ok { + values[key] = value + } + } + return values +} 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..282097f618 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent.go @@ -0,0 +1,205 @@ +package codex + +import ( + "context" + "fmt" + "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" + codexSessionsDir = "sessions" + 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. +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) + found, err := artifacts.StageSessionDirectory(ctx, source, request.OutputDir) + if err != nil { + return toolv1.ExportResult{}, fmt.Errorf("stage codex sessions: %w", err) + } + if !found { + return toolv1.ExportResult{}, nil + } + 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") + } + 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 +} + +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..d6749ba4a9 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/agent_config.go @@ -0,0 +1,138 @@ +package codex + +import ( + "os" + + "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" +) + +func (agent *Agent) writeNativeConfig(config toolv1.Config, model string) error { + external, err := mcpcfg.Load() + if err != nil { + return err + } + + if _, err := agent.resolveACPMode(config.Run.Mode, ""); err != nil { + return err + } + 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, + Settings: configTemplateSettings{ + 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(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, + URL: common.AgentMCPServerURL, + }, { + Name: common.CodebaseMemoryMCPServerName, + Command: common.CodebaseMemoryMCPCommand, + Env: agent.templateKeyValues(map[string]string{common.CodebaseMemoryCacheEnv: common.CodebaseMemoryCacheDir}), + }} + 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), + } + 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..df9fea2295 --- /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.OpenAiMethodResponses + 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 != 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 != responsesWireAPI { + 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..f73a8f72a4 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config.go @@ -0,0 +1,147 @@ +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" +) + +// 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" + 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) wireAPI(method string) string { + switch console.OpenAiMethod(method) { + 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..c225d3bc2d --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/runtime_config_test.go @@ -0,0 +1,106 @@ +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 TestCodexACPSettings(t *testing.T) { + agent := NewAgent(toolv1.Config{Run: codexTestRun(console.AgentRunModeWrite, "gpt-5.4", false)}) + 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"}}) + 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.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) + } + } +} + +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.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..bad0c1364f --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates/config.toml.gotmpl @@ -0,0 +1,97 @@ +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] +{{ if .Settings.EnableWebSearch }} +web_search_request = true +{{ end }} +{{ if .Settings.EnableShellCache }} +shell_snapshot = true +{{ end }} + +[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 }} + +{{ range .MCPServers }} +[mcp_servers.{{ quote .Name }}] +{{ 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 .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..d684b39abf --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/templates_test.go @@ -0,0 +1,236 @@ +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 TestConfigTemplateProxyResponses(t *testing.T) { + doc := renderConfigTemplate(t, ConfigTemplateInput{ + RepositoryDir: "/repo", + Settings: configTemplateSettings{ + 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: responsesWireAPI, + }}, + }) + + features := tableValue(t, doc, "features") + projects := tableValue(t, doc, "projects") + if tableValue(t, projects, "/repo")["trust_level"] != "trusted" { + t.Fatalf("projects = %#v, expected trusted repository", projects) + } + 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) + } + 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"] != responsesWireAPI { + t.Fatalf("provider = %#v", provider) + } +} + +func TestConfigTemplateCustomEndpointAndAutoOmission(t *testing.T) { + custom := renderConfigTemplate(t, ConfigTemplateInput{ + RepositoryDir: "/repo", + Settings: configTemplateSettings{ + 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", + Settings: configTemplateSettings{ + Model: "gpt-5.4", + SandboxMode: sandboxModeHarness, + ApprovalPolicy: approvalPolicyNever, + ModelReasoningEffort: defaultReasoning, + }, + }) + 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 := auto["shell_environment_policy"]; ok { + t.Fatalf("empty shell policy unexpectedly emitted: %#v", auto) + } +} + +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", + Settings: configTemplateSettings{ + Model: "gpt-5.4", + SandboxMode: sandboxModeHarness, + ApprovalPolicy: approvalPolicyNever, + ModelReasoningEffort: defaultReasoning, + ShellEnvironmentPolicy: shell, + }, + }) + 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"]) + } + 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", + 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"}, + }) + doc := renderConfigTemplate(t, ConfigTemplateInput{ + RepositoryDir: "/repo", + Settings: configTemplateSettings{Model: "gpt-5.4"}, + MCPServers: servers, + }) + mcps := tableValue(t, doc, "mcp_servers") + plural := tableValue(t, mcps, pluralProvider) + if plural["url"] != common.AgentMCPServerURL { + t.Fatalf("plural MCP = %#v", plural) + } + codebase := tableValue(t, mcps, common.CodebaseMemoryMCPServerName) + if 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" { + 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" + model := "vendor\\model\n\"name\a\v" + key := "X-Header\\name" + value := "line 1\nline 2 with \"quotes\"" + doc := renderConfigTemplate(t, ConfigTemplateInput{ + RepositoryDir: repository, + Settings: configTemplateSettings{ + Model: model, + SandboxMode: sandboxModeHarness, + ApprovalPolicy: approvalPolicyNever, + ModelReasoningEffort: defaultReasoning, + }, + MCPServers: []configTemplateMCP{{ + Name: "mcp\\\"server", + 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"]) + } + 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 { + 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..f674e01630 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport.go @@ -0,0 +1,110 @@ +package codex + +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" +) + +// 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(), + 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 *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 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}, + FileSystemWrite: transport.Capabilities().FileSystemWrite, + }, 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..6fe2a1ccd4 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/transport_test.go @@ -0,0 +1,139 @@ +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()) + } + 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) + } + 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..86d61c326d --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/types.go @@ -0,0 +1,53 @@ +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 + Settings configTemplateSettings + Providers []configTemplateProvider + MCPServers []configTemplateMCP +} + +type configTemplateSettings struct { + 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 + URL string + Command string + Args []string + Env []configTemplateKeyValue + Headers []configTemplateKeyValue + HTTPHeaders []configTemplateKeyValue + EnvHTTPHeaders []configTemplateKeyValue + EnabledTools []string + DisabledTools []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 new file mode 100644 index 0000000000..ff290f07d7 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent.go @@ -0,0 +1,148 @@ +package opencode + +import ( + "context" + "fmt" + "path/filepath" + + 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" +) + +// 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 +} + +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, + }} +} + +// 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 + } + + // 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, + 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 = 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") + } + 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 +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_config.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_config.go new file mode 100644 index 0000000000..769ac864f5 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_config.go @@ -0,0 +1,91 @@ +package opencode + +import ( + "fmt" + "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 { + 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 *Agent) providerPath(config toolv1.Config) string { + return filepath.Join(config.WorkDir, opencodeHomeDir) +} + +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), opencodeSkillsDir) +} + +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 *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_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 new file mode 100644 index 0000000000..7bb40caf7f --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/agent_test.go @@ -0,0 +1,84 @@ +package opencode + +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 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 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 61b4361f34..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/artifacts.go +++ /dev/null @@ -1,74 +0,0 @@ -package opencode - -import ( - "bytes" - "context" - "fmt" - "os" - stdexec "os/exec" - "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") - } - - 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 -} 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 525deba3ea..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode.go +++ /dev/null @@ -1,487 +0,0 @@ -package opencode - -import ( - "context" - "encoding/json" - "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/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" -) - -func (in *Opencode) Run(ctx context.Context, options ...exec.Option) { - go in.start(ctx, options...) -} - -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 -} - -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 path.Join(in.providerPath(), ConfigFileName) -} - -func (in *Opencode) skillsPath() string { - return path.Join(in.providerPath(), "skills") -} - -func (in *Opencode) providerPath() string { - return filepath.Join(in.Config.WorkDir, ".opencode") -} - -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 []string{ - fmt.Sprintf("OPENCODE_CONFIG=%s", configFilePath), - fmt.Sprintf("XDG_CONFIG_HOME=%s", in.configHome()), - fmt.Sprintf("XDG_DATA_HOME=%s", in.dataHome()), - } -} - -func (in *Opencode) configHome() string { - return filepath.Join(in.Config.WorkDir, ".config") -} - -func (in *Opencode) dataPath() string { - return filepath.Join(in.dataHome(), "opencode") -} - -func (in *Opencode) dataHome() string { - return filepath.Join(in.Config.WorkDir, ".local", "share") -} - -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_args_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_args_test.go deleted file mode 100644 index 444a332281..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_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_settings_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_settings_test.go deleted file mode 100644 index 6a9f83cd30..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_settings_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package opencode - -import "testing" - -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: "native provider passes through slug", - provider: "anthropic", - model: "claude-sonnet-4-5", - wantProvider: ProviderAnthropic, - wantModel: "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) - 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/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 03a822dfb5..0000000000 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_types.go +++ /dev/null @@ -1,354 +0,0 @@ -package opencode - -import ( - "encoding/json" - "time" - - 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" - 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/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/opencode_templates.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates.go similarity index 98% 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 index 3a27f15639..acad36a6fd 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/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/opencode.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl index d60da092f2..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 @@ -2,6 +2,7 @@ "$schema": "https://opencode.ai/config.json", "autoupdate": false, "snapshot": false, + "model": "{{ .Provider }}/{{ .Model }}", "permission": { "skill": { "*": "allow" @@ -118,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 92% 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..5b64cdf51a 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 @@ -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" ) @@ -26,7 +23,7 @@ func baseInput(mode console.AgentRunMode) *ConfigTemplateInput { ConsoleToken: testConsoleToken, AgentRunID: testAgentRunID, Provider: ProviderOpenAI, - Model: string(ModelGPT52), + Model: "gpt-5.2", Token: testToken, Mode: mode, } @@ -160,26 +157,6 @@ func TestConfigTemplate_AllowsSkillLoading(t *testing.T) { } } -func TestEnvUsesHarnessHomeAndConfigHome(t *testing.T) { - workDir := t.TempDir() - tool := &Opencode{DefaultTool: toolv1.DefaultTool{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") - 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) @@ -328,13 +305,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..55293a194e --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport.go @@ -0,0 +1,117 @@ +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" +) + +// 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 + 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(), + 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 *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 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(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, + FileSystemWrite: transport.Capabilities().FileSystemWrite, + }, sink) + + return toolv1.TurnResult{SessionID: result.SessionID}, 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 { + 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...) +} 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..f9583281a5 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/transport_test.go @@ -0,0 +1,127 @@ +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" + 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") + 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([]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) + } +} + +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/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 db9b48ed86..4635086fc6 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/tool.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool.go @@ -1,8 +1,11 @@ package tool import ( + "errors" "fmt" + "k8s.io/klog/v2" + console "github.com/pluralsh/console/go/client" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/claude" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/codex" @@ -11,25 +14,48 @@ import ( "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 // 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 { case console.AgentRuntimeTypeOpencode: - return opencode.New(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 + 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: - 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 + 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 new file mode 100644 index 0000000000..c4a8e66a0e --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/tool_test.go @@ -0,0 +1,114 @@ +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" + "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" +) + +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) + } +} + +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 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 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{ + 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.(*gemini.Gemini); !ok { + t.Fatalf("Gemini factory returned %T, want *gemini.Gemini", created) + } +} + +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") + } +} + +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") + } +} 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..88f44b04dc --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime.go @@ -0,0 +1,306 @@ +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 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..c53e2707a8 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/v1/runtime_types.go @@ -0,0 +1,166 @@ +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 is the Console AI provider selected for the turn. + Provider *console.AiProvider + + // 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. +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 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 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/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/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", 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 18367d0110..89937ebd77 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/v1/tool.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/v1/tool.go @@ -33,22 +33,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 { - 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) +func (in *DefaultTool) ConfigureSystemPrompt(runtime console.AgentRuntimeType) error { templateFile := systemPromptTemplateDir switch in.Config.Run.Mode { @@ -60,20 +45,14 @@ func (in DefaultTool) ConfigureSystemPrompt(runtime console.AgentRuntimeType) er 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) - } + return in.configureSystemPrompt(runtime, templateFile) +} - klog.V(log.LogLevelExtended).InfoS("system prompt/context file configured", "output", outputFile) - return nil +func (in *DefaultTool) ConfigureSystemPromptForBabysitRun(runtime console.AgentRuntimeType) error { + return in.configureSystemPrompt(runtime, path.Join(systemPromptTemplateDir, systemPromptBabysitTemplateFile)) } -func (in DefaultTool) ConfigureSystemPromptForBabysitRun(runtime console.AgentRuntimeType) error { +func (in *DefaultTool) configureSystemPrompt(runtime console.AgentRuntimeType, templateFile string) error { providerDir := "" switch runtime { case console.AgentRuntimeTypeClaude: @@ -89,21 +68,20 @@ func (in DefaultTool) ConfigureSystemPromptForBabysitRun(runtime console.AgentRu } 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 } -func (in DefaultTool) systemPromptInput() *SystemPromptTemplateInput { +func (in *DefaultTool) systemPromptInput() *SystemPromptTemplateInput { branch := "" if in.Config.Run.Branch != nil { branch = *in.Config.Run.Branch @@ -124,7 +102,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..e0c1e7002f 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. @@ -38,6 +38,7 @@ func New(existing *console.AgentRunUsage) *Usage { if existing == nil { return u } + if existing.InputTokens != nil { u.inputTokens = *existing.InputTokens } @@ -86,6 +87,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 +135,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) +} diff --git a/go/deployment-operator/pkg/harness/exec/exec.go b/go/deployment-operator/pkg/harness/exec/exec.go index b2e4ec5422..901de78399 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 { @@ -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/deployment-operator/pkg/harness/exec/exec_stdio.go b/go/deployment-operator/pkg/harness/exec/exec_stdio.go new file mode 100644 index 0000000000..0f521a1bcf --- /dev/null +++ b/go/deployment-operator/pkg/harness/exec/exec_stdio.go @@ -0,0 +1,187 @@ +package exec + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + "time" + + "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" +) + +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 + } + + 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 + } + 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() + return nil, err + } + + 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 +} + +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 runtime.closeErr +} + +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) + } + if err := runtime.executable.runLifecycleFunction(v1.LifecyclePostStart); err != nil { + runtime.waitErr = errors.Join(runtime.waitErr, err) + } + }) + return runtime.waitErr +} + +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 +} + +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 +// 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..7c7697ee20 --- /dev/null +++ b/go/deployment-operator/pkg/harness/exec/exec_stdio_test.go @@ -0,0 +1,94 @@ +package exec + +import ( + "context" + "errors" + "io" + "sync/atomic" + "testing" + "time" + + 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) +} + +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 new file mode 100644 index 0000000000..c985afaabb --- /dev/null +++ b/go/deployment-operator/pkg/harness/exec/exec_stdio_types.go @@ -0,0 +1,96 @@ +package exec + +import ( + "context" + "io" +) + +// StdioProcess is a running executable with bidirectional standard input and +// 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 + 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 + 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, + 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 { + 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) +} 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")) +}