Merge pull request #1527 from trheyi/main

feat(sandbox): add opencode runner and enhance attachment processing
This commit is contained in:
Max 2026-04-25 11:12:35 +08:00 committed by GitHub
commit 72d7ed6f80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 3895 additions and 216 deletions

View file

@ -2,224 +2,19 @@ package claude
import (
"context"
"fmt"
"path/filepath"
"strings"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/attachment"
"github.com/yaoapp/yao/agent/sandbox/v2/shared"
workspace "github.com/yaoapp/yao/tai/workspace"
)
// prepareAttachments resolves __yao.attachment:// URLs in messages,
// copies actual files into the workspace .attachments/{chatID}/ directory via ws.Copy,
// and replaces multimodal content parts with text references.
//
// Delegates to shared.PrepareAttachments; the returned text-replaced messages
// are used directly by the Claude CLI (which reads local files via text refs).
func prepareAttachments(ctx context.Context, messages []agentContext.Message, chatID string, ws workspace.FS) ([]agentContext.Message, error) {
usedNames := make(map[string]int)
attachDir := ".attachments/" + chatID
result := make([]agentContext.Message, len(messages))
copy(result, messages)
for i, msg := range result {
if msg.Role != "user" {
continue
}
parts, ok := msg.Content.([]interface{})
if !ok {
if typedParts, ok := msg.Content.([]agentContext.ContentPart); ok {
iparts := make([]interface{}, len(typedParts))
for j, p := range typedParts {
m := map[string]interface{}{"type": string(p.Type)}
if p.Text != "" {
m["text"] = p.Text
}
if p.ImageURL != nil {
m["image_url"] = map[string]interface{}{
"url": p.ImageURL.URL,
"detail": string(p.ImageURL.Detail),
}
}
if p.File != nil {
m["file"] = map[string]interface{}{
"url": p.File.URL,
"filename": p.File.Filename,
}
}
iparts[j] = m
}
parts = iparts
} else {
continue
}
}
if len(parts) == 0 {
continue
}
var textParts []string
for _, item := range parts {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
partType, _ := m["type"].(string)
switch partType {
case "text":
if text, ok := m["text"].(string); ok && text != "" {
textParts = append(textParts, text)
}
case "image_url":
imgData, _ := m["image_url"].(map[string]interface{})
if imgData == nil {
continue
}
url, _ := imgData["url"].(string)
if url == "" {
continue
}
uploaderName, fileID, isWrapper := attachment.Parse(url)
if !isWrapper {
textParts = append(textParts, fmt.Sprintf("[Image: %s]", url))
continue
}
ref, err := resolveAttachment(ctx, uploaderName, fileID, "", attachDir, usedNames, ws)
if err != nil {
textParts = append(textParts, "[Attached image: failed to load]")
continue
}
textParts = append(textParts, ref)
case "file":
fileData, _ := m["file"].(map[string]interface{})
if fileData == nil {
continue
}
url, _ := fileData["url"].(string)
hintName, _ := fileData["filename"].(string)
if url == "" {
continue
}
uploaderName, fileID, isWrapper := attachment.Parse(url)
if !isWrapper {
textParts = append(textParts, fmt.Sprintf("[File: %s]", url))
continue
}
ref, err := resolveAttachment(ctx, uploaderName, fileID, hintName, attachDir, usedNames, ws)
if err != nil {
textParts = append(textParts, "[Attached file: failed to load]")
continue
}
textParts = append(textParts, ref)
}
}
if len(textParts) > 0 {
newMsg := result[i]
newMsg.Content = strings.Join(textParts, "\n\n")
result[i] = newMsg
}
}
return result, nil
}
// resolveAttachment gets the local path of an attachment and copies it into
// the workspace via ws.Copy("local:///abs/path", ".attachments/{chatID}/filename").
func resolveAttachment(
ctx context.Context,
uploaderName, fileID, hintName, attachDir string,
usedNames map[string]int,
ws workspace.FS,
) (string, error) {
manager, exists := attachment.Managers[uploaderName]
if !exists {
return "", fmt.Errorf("attachment manager not found: %s", uploaderName)
}
fileInfo, err := manager.Info(ctx, fileID)
if err != nil {
return "", fmt.Errorf("failed to get file info: %w", err)
}
absPath, _, err := manager.LocalPath(ctx, fileID)
if err != nil {
return "", fmt.Errorf("failed to get local path: %w", err)
}
filename := fileInfo.Filename
if filename == "" && hintName != "" {
filename = hintName
}
if filename == "" {
ext := extensionFromContentType(fileInfo.ContentType)
filename = fileID + ext
}
baseName := filename
if count, exists := usedNames[baseName]; exists {
ext := filepath.Ext(filename)
name := strings.TrimSuffix(filename, ext)
filename = fmt.Sprintf("%s_%d%s", name, count+1, ext)
usedNames[baseName] = count + 1
} else {
usedNames[baseName] = 0
}
dstPath := attachDir + "/" + filename
src := "local:///" + absPath
if _, err := ws.Copy(src, dstPath); err != nil {
return "", fmt.Errorf("failed to copy attachment to workspace: %w", err)
}
sizeStr := formatFileSize(fileInfo.Bytes)
return fmt.Sprintf("[Attached file: %s (%s, %s)]", dstPath, fileInfo.ContentType, sizeStr), nil
}
func extensionFromContentType(contentType string) string {
switch contentType {
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "image/svg+xml":
return ".svg"
case "application/pdf":
return ".pdf"
case "text/plain":
return ".txt"
case "text/html":
return ".html"
case "text/css":
return ".css"
case "text/javascript", "application/javascript":
return ".js"
case "application/json":
return ".json"
case "application/zip":
return ".zip"
default:
return ""
}
}
func formatFileSize(bytes int) string {
switch {
case bytes >= 1024*1024:
return fmt.Sprintf("%.1fMB", float64(bytes)/(1024*1024))
case bytes >= 1024:
return fmt.Sprintf("%.1fKB", float64(bytes)/1024)
default:
return fmt.Sprintf("%dB", bytes)
}
processed, _, err := shared.PrepareAttachments(ctx, messages, chatID, ws)
return processed, err
}

View file

@ -2,6 +2,7 @@ package sandboxv2
import (
"github.com/yaoapp/yao/agent/sandbox/v2/claude"
"github.com/yaoapp/yao/agent/sandbox/v2/opencode"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
yaorunner "github.com/yaoapp/yao/agent/sandbox/v2/yao"
)
@ -9,5 +10,7 @@ import (
func init() {
Register("claude", func() types.Runner { return claude.New() })
Register("claude/cli", func() types.Runner { return claude.New() })
Register("opencode", func() types.Runner { return opencode.New() })
Register("opencode/cli", func() types.Runner { return opencode.New() })
Register("yao", func() types.Runner { return yaorunner.New() })
}

View file

@ -0,0 +1,405 @@
package opencode
import (
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/kun/str"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
)
var (
yaoSessionNS = uuid.MustParse("e37bc21a-72dd-4a8f-b567-1f02c3d4e590")
safeNameRe = regexp.MustCompile(`[^a-zA-Z0-9_\-.]`)
)
type command struct {
shell []string
env map[string]string
stdin string
workDir string
}
func chatIDToSessionID(assistantID, chatID string) string {
return uuid.NewSHA1(yaoSessionNS, []byte(assistantID+":"+chatID)).String()
}
func sanitizeSessionName(chatID string) string {
return "yao-oc-" + safeNameRe.ReplaceAllString(chatID, "_")
}
func chatSessionExists(storeKey string) bool {
s, err := store.Get("__yao.store")
if err != nil {
return false
}
return s.Has(storeKey)
}
func markChatSession(storeKey, sessionID string, ttl time.Duration) {
s, err := store.Get("__yao.store")
if err != nil {
return
}
s.Set(storeKey, sessionID, ttl)
}
func (r *Runner) buildCommand(req *types.StreamRequest, p platform, attachmentPaths []string) command {
workDir := req.Computer.GetWorkDir()
assistantID := req.AssistantID
chatID := req.ChatID
var isContinuation bool
if chatID != "" {
storeKey := "opencode-session:" + assistantID + ":" + chatID
isContinuation = chatSessionExists(storeKey)
}
env := buildEnv(req, p)
args := buildArgs(req, r, isContinuation, chatID)
stdinMsg := buildStdinMessage(req.Messages, attachmentPaths)
script := shellQuoteForPlatform(p, "opencode", args...)
return command{
shell: p.ShellCmd(script),
env: env,
stdin: stdinMsg,
workDir: workDir,
}
}
func buildEnv(req *types.StreamRequest, p platform) map[string]string {
env := make(map[string]string)
workDir := req.Computer.GetWorkDir()
ws := req.Computer.Workplace()
if ws != nil {
workspaceID, err := ws.GetID()
if err == nil {
env["CTX_WORKSPACE_ID"] = workspaceID
}
}
for k, v := range p.HomeEnv(workDir) {
env[k] = v
}
env["WORKDIR"] = workDir
assistantID := req.AssistantID
prefix := p.PathJoin(workDir, ".yao", "assistants", assistantID, "opencode")
if assistantID == "" {
prefix = p.PathJoin(workDir, ".opencode-data")
}
if assistantID != "" {
env["CTX_ASSISTANT_ID"] = assistantID
env["CTX_SKILLS_DIR"] = p.PathJoin(workDir, ".yao", "assistants", assistantID, "skills")
}
env["OPENCODE_DATA_DIR"] = p.PathJoin(prefix, "data")
env["OPENCODE_CACHE_DIR"] = p.PathJoin(prefix, "cache")
env["OPENCODE_STATE_DIR"] = p.PathJoin(prefix, "state")
env["OPENCODE_CONFIG_DIR"] = p.PathJoin(prefix, "config")
env["OPENCODE_DISABLE_AUTOUPDATE"] = "true"
env["OPENCODE_DISABLE_MODELS_FETCH"] = "true"
env["OPENCODE_DISABLE_LSP_DOWNLOAD"] = "true"
env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "true"
env["OPENCODE_DISABLE_TERMINAL_TITLE"] = "true"
env["OPENCODE_DISABLE_MOUSE"] = "true"
env["OPENCODE_DISABLE_CLAUDE_CODE"] = "true"
env["OPENCODE_CLIENT"] = "cli"
// Lower bash default timeout from 120s to 30s. Long-running commands
// like browsers should be nohup'd; this prevents accidental 2-min hangs.
env["OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"] = "30000"
if req.Connector != nil {
setting := req.Connector.Setting()
key, _ := setting["key"].(string)
if key != "" {
env["YAO_PROVIDER_KEY"] = key
}
if req.Connector.Is(connector.ANTHROPIC) {
apiKey, _ := setting["key"].(string)
if apiKey != "" {
env["ANTHROPIC_API_KEY"] = apiKey
}
}
}
injectRoleEnvVars(env, req)
if req.Config != nil && len(req.Config.Secrets) > 0 {
for k, v := range req.Config.Secrets {
env[k] = str.EnvVar(v)
}
}
if req.Token != nil {
if req.Token.Token != "" {
env["YAO_TOKEN"] = req.Token.Token
}
if req.Token.RefreshToken != "" {
env["YAO_REFRESH_TOKEN"] = req.Token.RefreshToken
}
}
return env
}
func buildArgs(req *types.StreamRequest, r *Runner, isContinuation bool, chatID string) []string {
args := []string{"run", "--format", "json"}
permMode := ""
if req.Config != nil && req.Config.Runner.Options != nil {
if v, ok := req.Config.Runner.Options["permission_mode"]; ok {
permMode = fmt.Sprintf("%v", v)
}
}
if permMode == "bypassPermissions" {
args = append(args, "--dangerously-skip-permissions")
}
if chatID != "" && isContinuation {
sessionID := chatIDToSessionID(req.AssistantID, chatID)
args = append(args, "--continue", "--session", sessionID)
}
if req.Connector != nil {
if mid := connectorModelID(req.Connector); mid != "" {
args = append(args, "--model", mid)
}
}
// User message and attachments are passed via stdin (heredoc pipe),
// NOT as positional args. This avoids shell escaping issues with
// special characters, CJK text, long messages, and --file ambiguity.
return args
}
// buildStdinMessage builds the text piped to `opencode run` via stdin.
// It combines the user's text message with attachment references so OpenCode
// receives everything through stdin — no positional args, no --file flags.
// This mirrors the Claude runner approach and avoids shell escaping pitfalls.
func buildStdinMessage(messages []agentContext.Message, attachmentPaths []string) string {
var parts []string
if len(attachmentPaths) > 0 {
parts = append(parts, "The user has attached the following files — read them to understand context:")
for _, p := range attachmentPaths {
parts = append(parts, fmt.Sprintf(" - %s", p))
}
parts = append(parts, "")
}
text := lastUserText(messages)
if text != "" {
parts = append(parts, text)
}
return strings.Join(parts, "\n")
}
// lastUserText extracts the plain text from the last user message,
// handling string, []ContentPart, and []any (generic JSON) content types.
func lastUserText(messages []agentContext.Message) string {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role != "user" {
continue
}
switch c := messages[i].Content.(type) {
case string:
return c
case []agentContext.ContentPart:
var texts []string
for _, part := range c {
if part.Type == agentContext.ContentText && part.Text != "" {
texts = append(texts, part.Text)
}
}
return strings.Join(texts, "\n")
case []any:
var texts []string
for _, item := range c {
if m, ok := item.(map[string]any); ok {
if t, _ := m["type"].(string); t == "text" {
if text, _ := m["text"].(string); text != "" {
texts = append(texts, text)
}
}
}
}
return strings.Join(texts, "\n")
}
}
return ""
}
func buildSandboxEnvPrompt(p platform, workDir string) string {
osName := p.OS()
if osName == "" {
osName = "linux"
}
shell := p.Shell()
if shell == "" {
shell = "bash"
}
envVarSyntax := "$VAR_NAME"
if osName == "windows" {
envVarSyntax = "$env:VAR_NAME"
}
return fmt.Sprintf(`## Sandbox Environment
- **Operating System**: %[2]s
- **Shell**: %[3]s
- **Working Directory**: %[1]s
- **File Access**: You have full read/write access to %[1]s
- **Environment variable syntax**: `+"`%[4]s`"+`
## User Attachments
User-uploaded files are placed in %[1]s/.attachments/{chatID}/
Each chat session has its own subdirectory.
When the user attaches files, their paths are listed at the top of the message.
**Read these files yourself** using the Read or Bash tool they are NOT passed as CLI arguments.
`, workDir, osName, shell, envVarSyntax)
}
func getProviderPrefix(conn connector.Connector) string {
if conn != nil && conn.Is(connector.ANTHROPIC) {
return "anthropic"
}
return "openai"
}
// resolveRoleConnector determines which connector to use for a given role.
func resolveRoleConnector(
role string,
roleConnectors map[string]*types.RoleConnector,
userExplicit bool,
getConnector func(id string) connector.Connector,
) connector.Connector {
rc, ok := roleConnectors[role]
if !ok || rc == nil {
return nil
}
if rc.Override == "user" && userExplicit {
return nil
}
return getConnector(rc.Connector)
}
func getRoleConnectors(req *types.StreamRequest) map[string]*types.RoleConnector {
if req.Config == nil {
return nil
}
return req.Config.Runner.Connectors
}
// shellQuoteForPlatform builds a shell-safe command string. On Windows
// (PowerShell) it uses single quotes with ” escaping; on POSIX it uses
// single quotes with '\” escaping.
func shellQuoteForPlatform(p platform, program string, args ...string) string {
if p.OS() == "windows" {
return shellQuotePowerShell(program, args...)
}
return shellQuote(program, args...)
}
// shellQuote builds a POSIX shell-safe command string from program and args.
func shellQuote(program string, args ...string) string {
parts := make([]string, 0, 1+len(args))
parts = append(parts, program)
for _, a := range args {
if a == "" || strings.ContainsAny(a, " \t\n\"'\\$`!#&|;(){}[]<>?*~") {
parts = append(parts, "'"+strings.ReplaceAll(a, "'", `'\''`)+"'")
} else {
parts = append(parts, a)
}
}
return strings.Join(parts, " ")
}
// shellQuotePowerShell builds a PowerShell-safe command string. In PowerShell,
// single-quoted strings escape embedded single quotes by doubling them (”).
func shellQuotePowerShell(program string, args ...string) string {
parts := make([]string, 0, 1+len(args))
parts = append(parts, program)
for _, a := range args {
if a == "" || strings.ContainsAny(a, " \t\n\"'\\$`!#&|;(){}[]<>?*~") {
parts = append(parts, "'"+strings.ReplaceAll(a, "'", "''")+"'")
} else {
parts = append(parts, a)
}
}
return strings.Join(parts, " ")
}
// connectorModelID returns the "provider/model" string matching the
// provider ID used in opencode.json (see buildProviderConfig).
func connectorModelID(c connector.Connector) string {
setting := c.Setting()
modelName, _ := setting["model"].(string)
host, _ := setting["host"].(string)
if c.Is(connector.ANTHROPIC) {
return "anthropic/" + modelName
}
if host == "" || isNativeOpenAI(host) {
return "openai/" + modelName
}
return "custom/" + modelName
}
// injectRoleEnvVars adds API key, base URL, and model environment variables
// for each role connector defined in openCodeRoleMap. These env vars are
// consumed by opencode.json provider blocks (via {env:...} references) and
// by the custom read.ts tool (for vision API calls).
func injectRoleEnvVars(env map[string]string, req *types.StreamRequest) {
if req.Config == nil || req.Config.Runner.Connectors == nil {
return
}
for role, spec := range openCodeRoleMap {
if spec.EnvKeyPrefix == "" {
continue
}
rc, ok := req.Config.Runner.Connectors[role]
if !ok || rc == nil || rc.Connector == "" {
continue
}
c, exists := connector.Connectors[rc.Connector]
if !exists || c == nil {
continue
}
setting := c.Setting()
if key, _ := setting["key"].(string); key != "" {
env[spec.EnvKeyPrefix+"_KEY"] = key
}
if host, _ := setting["host"].(string); host != "" {
env[spec.EnvKeyPrefix+"_BASE_URL"] = normalizeBaseURL(host)
}
if model, _ := setting["model"].(string); model != "" {
env[spec.EnvKeyPrefix+"_MODEL"] = model
}
}
}
func connectorHost(c connector.Connector) string {
if c == nil {
return ""
}
host, _ := c.Setting()["host"].(string)
return strings.TrimSpace(host)
}

View file

@ -0,0 +1,145 @@
package opencode
import (
"testing"
agentContext "github.com/yaoapp/yao/agent/context"
)
func TestChatIDToSessionID(t *testing.T) {
id1 := chatIDToSessionID("assistant-1", "chat-1")
id2 := chatIDToSessionID("assistant-1", "chat-1")
id3 := chatIDToSessionID("assistant-1", "chat-2")
if id1 != id2 {
t.Error("same inputs should produce same session ID")
}
if id1 == id3 {
t.Error("different chatIDs should produce different session IDs")
}
if id1 == "" {
t.Error("session ID should not be empty")
}
}
func TestSanitizeSessionName(t *testing.T) {
cases := []struct {
input, want string
}{
{"simple-chat", "yao-oc-simple-chat"},
{"chat with spaces", "yao-oc-chat_with_spaces"},
{"chat/with/slashes", "yao-oc-chat_with_slashes"},
{"chat@special#chars", "yao-oc-chat_special_chars"},
}
for _, tc := range cases {
got := sanitizeSessionName(tc.input)
if got != tc.want {
t.Errorf("sanitizeSessionName(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestLastUserText(t *testing.T) {
cases := []struct {
name string
messages []agentContext.Message
want string
}{
{
name: "empty",
messages: nil,
want: "",
},
{
name: "single user message",
messages: []agentContext.Message{
{Role: "user", Content: "hello"},
},
want: "hello",
},
{
name: "last user wins",
messages: []agentContext.Message{
{Role: "user", Content: "first"},
{Role: "assistant", Content: "reply"},
{Role: "user", Content: "second"},
},
want: "second",
},
{
name: "no user messages",
messages: []agentContext.Message{
{Role: "assistant", Content: "only assistant"},
},
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := lastUserText(tc.messages)
if got != tc.want {
t.Errorf("lastUserText() = %q, want %q", got, tc.want)
}
})
}
}
func TestBuildStdinMessage(t *testing.T) {
msgs := []agentContext.Message{
{Role: "user", Content: "把这个会议纪要的关键内容提取出来"},
}
t.Run("no attachments", func(t *testing.T) {
got := buildStdinMessage(msgs, nil)
if got != "把这个会议纪要的关键内容提取出来" {
t.Errorf("unexpected: %q", got)
}
})
t.Run("with attachments", func(t *testing.T) {
got := buildStdinMessage(msgs, []string{"/workspace/.attachments/abc/test.txt"})
if !strContains(got, "/workspace/.attachments/abc/test.txt") {
t.Error("should contain attachment path")
}
if !strContains(got, "把这个会议纪要的关键内容提取出来") {
t.Error("should contain user message")
}
})
t.Run("empty message", func(t *testing.T) {
got := buildStdinMessage(nil, []string{"/workspace/file.txt"})
if !strContains(got, "/workspace/file.txt") {
t.Error("should contain attachment path even without message")
}
})
}
func TestBuildSandboxEnvPrompt(t *testing.T) {
p := &posixBase{os: "linux", shell: "bash"}
prompt := buildSandboxEnvPrompt(p, "/workspace")
if prompt == "" {
t.Error("prompt should not be empty")
}
if !strContains(prompt, "/workspace") {
t.Error("prompt should mention workspace path")
}
if !strContains(prompt, "linux") {
t.Error("prompt should mention OS")
}
}
func TestGetProviderPrefix(t *testing.T) {
if p := getProviderPrefix(nil); p != "openai" {
t.Errorf("nil connector should give openai, got %s", p)
}
}
func strContains(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View file

@ -0,0 +1,364 @@
package opencode
import (
"encoding/json"
"strings"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
)
type roleSpec struct {
EnvKeyPrefix string
TopLevel string
Modalities map[string][]string
}
var openCodeRoleMap = map[string]roleSpec{
"light": {
EnvKeyPrefix: "YAO_LIGHT",
TopLevel: "small_model",
},
"vision": {
EnvKeyPrefix: "YAO_VISION",
Modalities: map[string][]string{
"input": {"text", "image"},
"output": {"text"},
},
},
"heavy": {
EnvKeyPrefix: "YAO_HEAVY",
},
"subagent": {
EnvKeyPrefix: "YAO_SUBAGENT",
},
}
// buildOpenCodeConfig generates the opencode.json project configuration.
// All provider configuration is direct (no a2o proxy).
func buildOpenCodeConfig(req *types.PrepareRequest, mcpServers []types.MCPServer) []byte {
cfg := map[string]any{
"$schema": "https://opencode.ai/config.json",
"autoupdate": false,
"snapshot": false,
"share": "disabled",
"watcher": map[string]any{"ignore": []string{".yao/**", ".attachments/**"}},
"permission": map[string]any{"*": "allow"},
}
if req.Connector != nil {
providerID, providerCfg, modelStr := buildProviderConfig(req.Connector)
cfg["provider"] = map[string]any{providerID: providerCfg}
cfg["model"] = modelStr
cfg["enabled_providers"] = []string{providerID}
}
injectRoleProviders(cfg, req)
if len(mcpServers) > 0 {
cfg["mcp"] = buildMCPConfig(mcpServers)
}
prefix := ".yao/assistants/" + req.AssistantID
if req.AssistantID == "" {
prefix = ".opencode"
}
cfg["instructions"] = []string{prefix + "/system-prompt.md"}
data, _ := json.MarshalIndent(cfg, "", " ")
return data
}
// buildProviderConfig maps a Yao connector to an OpenCode provider configuration.
// Anthropic connectors map directly; OpenAI/OpenAI-compatible map to "openai".
//
// OpenCode appends its own endpoint paths (e.g. /responses) to baseURL,
// so we must NOT include /chat/completions. For native OpenAI (api.openai.com)
// we omit baseURL entirely and let OpenCode use its built-in default.
// For custom hosts (OpenAI-compatible proxies), we pass the bare host URL.
func buildProviderConfig(conn connector.Connector) (providerID string, cfg map[string]any, model string) {
setting := conn.Setting()
host, _ := setting["host"].(string)
modelName, _ := setting["model"].(string)
opts := map[string]any{
"apiKey": "{env:YAO_PROVIDER_KEY}",
}
if conn.Is(connector.ANTHROPIC) {
if host != "" {
opts["baseURL"] = host
}
return "anthropic", map[string]any{"options": opts}, "anthropic/" + modelName
}
// Native OpenAI (api.openai.com): use built-in "openai" provider which
// already knows all official models — no models declaration needed.
if host == "" || isNativeOpenAI(host) {
return "openai", map[string]any{"options": opts}, "openai/" + modelName
}
// OpenAI-compatible provider (DeepSeek, Moonshot, etc.): must use
// @ai-sdk/openai-compatible and explicitly declare models, otherwise
// OpenCode throws ProviderModelNotFoundError.
opts["baseURL"] = normalizeBaseURL(host)
modelCfg := map[string]any{
"name": modelName,
}
// DeepSeek (and similar) thinking models return reasoning_content in
// assistant messages. OpenCode must be told to preserve and replay
// this field on conversation continuation, otherwise the API returns:
// "The reasoning_content in the thinking mode must be passed back to the API."
// Adding "interleaved" is safe for non-thinking models (no-op if absent).
modelCfg["interleaved"] = map[string]any{"field": "reasoning_content"}
// Pass through thinking configuration from the Yao connector so OpenCode
// sends it to the upstream API. DeepSeek defaults thinking to "enabled";
// without explicitly sending {"thinking":{"type":"disabled"}}, the API
// returns reasoning_content that OpenCode (AI SDK bug) fails to replay.
modelOpts := buildModelOptions(setting)
if len(modelOpts) > 0 {
modelCfg["options"] = modelOpts
}
return "custom", map[string]any{
"npm": "@ai-sdk/openai-compatible",
"options": opts,
"models": map[string]any{
modelName: modelCfg,
},
}, "custom/" + modelName
}
// buildModelOptions extracts connector-level model options (thinking, etc.)
// and maps them to the OpenCode model options format.
func buildModelOptions(setting map[string]any) map[string]any {
opts := map[string]any{}
// Forward thinking configuration as-is (e.g. {"type":"disabled"}).
// DeepSeek V4 models default thinking to "enabled"; the only way to
// suppress reasoning_content is to explicitly send {"type":"disabled"}.
if thinking, ok := setting["thinking"]; ok && thinking != nil {
opts["thinking"] = thinking
}
return opts
}
// isNativeOpenAI returns true if host points to official OpenAI API,
// where OpenCode already knows the correct base URL.
func isNativeOpenAI(host string) bool {
h := strings.TrimRight(strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://"), "/")
return h == "api.openai.com" ||
strings.HasPrefix(h, "api.openai.com/")
}
// normalizeBaseURL strips trailing /chat/completions or /v1/chat/completions
// that Yao connectors may include, because OpenCode appends its own paths.
func normalizeBaseURL(host string) string {
u := strings.TrimRight(host, "/")
for _, suffix := range []string{"/chat/completions", "/completions"} {
if strings.HasSuffix(u, suffix) {
u = strings.TrimSuffix(u, suffix)
break
}
}
return strings.TrimRight(u, "/")
}
// injectRoleProviders iterates openCodeRoleMap and injects provider blocks
// for every role that has a configured connector. For the "light" role it
// also sets the top-level "small_model" field. This replaces the old
// buildSmallModel function and adds support for vision/heavy/subagent roles.
func injectRoleProviders(cfg map[string]any, req *types.PrepareRequest) {
if req.Config == nil || req.Config.Runner.Connectors == nil {
return
}
providers, _ := cfg["provider"].(map[string]any)
if providers == nil {
providers = map[string]any{}
cfg["provider"] = providers
}
enabledSlice, _ := cfg["enabled_providers"].([]string)
enabledSet := map[string]bool{}
for _, e := range enabledSlice {
enabledSet[e] = true
}
primaryHost := ""
primaryType := ""
if req.Connector != nil {
primaryHost = connectorHost(req.Connector)
if req.Connector.Is(connector.ANTHROPIC) {
primaryType = "anthropic"
} else {
primaryType = "openai"
}
}
for role, spec := range openCodeRoleMap {
rc, ok := req.Config.Runner.Connectors[role]
if !ok || rc == nil || rc.Connector == "" {
continue
}
c, exists := connector.Connectors[rc.Connector]
if !exists || c == nil {
continue
}
setting := c.Setting()
modelName, _ := setting["model"].(string)
if modelName == "" {
continue
}
roleHost := connectorHost(c)
roleType := "openai"
if c.Is(connector.ANTHROPIC) {
roleType = "anthropic"
}
sameProvider := roleType == primaryType && roleHost == primaryHost
if sameProvider && primaryHost != "" {
sameProvider = true
} else if sameProvider && primaryHost == "" && roleHost == "" {
sameProvider = true
} else if roleHost != primaryHost {
sameProvider = false
}
var providerID string
var modelRef string
if sameProvider {
providerID = resolveExistingProviderID(providers, primaryType)
modelRef = providerID + "/" + modelName
mergeModelIntoProvider(providers, providerID, modelName, spec.Modalities)
} else {
providerID = role
providerCfg := buildRoleProviderConfig(c, spec.EnvKeyPrefix, spec.Modalities)
providers[providerID] = providerCfg
modelRef = providerID + "/" + modelName
}
if !enabledSet[providerID] {
enabledSlice = append(enabledSlice, providerID)
enabledSet[providerID] = true
}
if spec.TopLevel != "" {
cfg[spec.TopLevel] = modelRef
}
}
cfg["enabled_providers"] = enabledSlice
}
// resolveExistingProviderID finds the actual provider ID key used in the
// providers map for a given type. For "openai" type, it could be "openai"
// or "custom" (for openai-compatible). Returns the type as fallback.
func resolveExistingProviderID(providers map[string]any, pType string) string {
if _, ok := providers[pType]; ok {
return pType
}
if pType == "openai" {
if _, ok := providers["custom"]; ok {
return "custom"
}
}
return pType
}
// mergeModelIntoProvider adds a model entry to an existing provider block.
func mergeModelIntoProvider(providers map[string]any, providerID, modelName string, modalities map[string][]string) {
block, ok := providers[providerID].(map[string]any)
if !ok {
return
}
models, _ := block["models"].(map[string]any)
if models == nil {
models = map[string]any{}
block["models"] = models
}
modelCfg := map[string]any{"name": modelName}
if len(modalities) > 0 {
modelCfg["modalities"] = modalities
}
models[modelName] = modelCfg
}
// buildRoleProviderConfig creates a provider configuration block for a
// non-primary role connector. Uses the role's env key prefix for API key
// and base URL references.
func buildRoleProviderConfig(conn connector.Connector, envKeyPrefix string, modalities map[string][]string) map[string]any {
setting := conn.Setting()
modelName, _ := setting["model"].(string)
host, _ := setting["host"].(string)
opts := map[string]any{
"apiKey": "{env:" + envKeyPrefix + "_KEY}",
}
modelCfg := map[string]any{"name": modelName}
if len(modalities) > 0 {
modelCfg["modalities"] = modalities
}
if conn.Is(connector.ANTHROPIC) {
if host != "" {
opts["baseURL"] = host
}
return map[string]any{
"options": opts,
"models": map[string]any{modelName: modelCfg},
}
}
if host == "" || isNativeOpenAI(host) {
return map[string]any{
"options": opts,
"models": map[string]any{modelName: modelCfg},
}
}
opts["baseURL"] = normalizeBaseURL(host)
modelCfg["interleaved"] = map[string]any{"field": "reasoning_content"}
return map[string]any{
"npm": "@ai-sdk/openai-compatible",
"options": opts,
"models": map[string]any{modelName: modelCfg},
}
}
// buildMCPConfig produces the "mcp" object for opencode.json.
// OpenCode uses "command" as an array (not command + args like Claude).
func buildMCPConfig(servers []types.MCPServer) map[string]any {
result := make(map[string]any, len(servers))
for _, s := range servers {
name := s.ServerID
if name == "" {
continue
}
result[name] = map[string]any{
"type": "local",
"command": []string{"tai", "mcp", name},
"enabled": true,
"environment": map[string]string{"YAO_TOKEN": "{env:YAO_TOKEN}"},
}
}
if len(result) == 0 {
result["yao"] = map[string]any{
"type": "local",
"command": []string{"tai", "mcp"},
"enabled": true,
"environment": map[string]string{"YAO_TOKEN": "{env:YAO_TOKEN}"},
}
}
return result
}

View file

@ -0,0 +1,125 @@
package opencode
import (
"encoding/json"
"testing"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
)
func TestBuildOpenCodeConfig_Defaults(t *testing.T) {
req := &types.PrepareRequest{
AssistantID: "test-assistant",
Config: &types.SandboxConfig{},
}
data := buildOpenCodeConfig(req, nil)
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if cfg["autoupdate"] != false {
t.Errorf("autoupdate should be false, got %v", cfg["autoupdate"])
}
if cfg["snapshot"] != false {
t.Errorf("snapshot should be false, got %v", cfg["snapshot"])
}
if cfg["share"] != "disabled" {
t.Errorf("share should be disabled, got %v", cfg["share"])
}
instructions, ok := cfg["instructions"].([]any)
if !ok || len(instructions) == 0 {
t.Fatal("instructions should be a non-empty array")
}
if instructions[0] != ".yao/assistants/test-assistant/system-prompt.md" {
t.Errorf("instructions[0] = %q, want .yao/assistants/test-assistant/system-prompt.md", instructions[0])
}
watcher, ok := cfg["watcher"].(map[string]any)
if !ok {
t.Fatal("watcher should be a map")
}
ignore, ok := watcher["ignore"].([]any)
if !ok || len(ignore) < 2 {
t.Errorf("watcher.ignore should have at least 2 entries, got %v", ignore)
}
}
func TestBuildOpenCodeConfig_NoAssistantID(t *testing.T) {
req := &types.PrepareRequest{
AssistantID: "",
Config: &types.SandboxConfig{},
}
data := buildOpenCodeConfig(req, nil)
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
instructions := cfg["instructions"].([]any)
if instructions[0] != ".opencode/system-prompt.md" {
t.Errorf("instructions[0] = %q, want .opencode/system-prompt.md", instructions[0])
}
}
func TestBuildOpenCodeConfig_WithMCP(t *testing.T) {
req := &types.PrepareRequest{
AssistantID: "test",
Config: &types.SandboxConfig{},
}
servers := []types.MCPServer{
{ServerID: "my-server"},
{ServerID: "another-server"},
}
data := buildOpenCodeConfig(req, servers)
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
mcp, ok := cfg["mcp"].(map[string]any)
if !ok {
t.Fatal("mcp should be a map")
}
if _, exists := mcp["my-server"]; !exists {
t.Error("mcp should contain my-server")
}
if _, exists := mcp["another-server"]; !exists {
t.Error("mcp should contain another-server")
}
}
func TestBuildMCPConfig_Default(t *testing.T) {
result := buildMCPConfig(nil)
if _, ok := result["yao"]; !ok {
t.Error("empty server list should produce default 'yao' entry")
}
}
func TestBuildMCPConfig_WithServers(t *testing.T) {
servers := []types.MCPServer{
{ServerID: "server-a"},
{ServerID: "server-b"},
{ServerID: ""},
}
result := buildMCPConfig(servers)
if _, ok := result["server-a"]; !ok {
t.Error("should contain server-a")
}
if _, ok := result["server-b"]; !ok {
t.Error("should contain server-b")
}
if len(result) != 2 {
t.Errorf("should only have 2 entries (empty ID skipped), got %d", len(result))
}
serverA := result["server-a"].(map[string]any)
cmd := serverA["command"].([]string)
if len(cmd) != 3 || cmd[0] != "tai" || cmd[1] != "mcp" || cmd[2] != "server-a" {
t.Errorf("command should be [tai mcp server-a], got %v", cmd)
}
}

View file

@ -0,0 +1,533 @@
package opencode
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/output/message"
)
// streamParser handles OpenCode's JSONL output (--format json).
//
// OpenCode emits one JSON object per line with a "type" field. Unlike Claude's
// streaming events which provide incremental deltas, OpenCode events arrive at
// completion boundaries:
//
// step_start agent step begins (contains sessionID, part metadata)
// text completed text block (full text, only emitted when part.time.end is set)
// tool_use completed tool call (status: completed|error, contains full input/output)
// step_finish step ended (reason: stop|tool-calls; includes token/cost info)
// reasoning thinking/reasoning output
// error error event
//
// Because tool_use events only arrive after the tool finishes, the parser
// emits a "running" execute chunk at step_start so the frontend has immediate
// feedback that work is in progress. It also tracks intermediate tool_use
// events (status=completed) so they are shown as soon as they arrive, even
// before step_finish.
type streamParser struct {
handler message.StreamFunc
completed bool
toolIndex int
// textActive tracks whether a text message group is currently open.
textActive bool
textMsgID string
// pendingExec tracks tools that were announced at step_start but haven't
// received their completed tool_use event yet. Key = step ID.
pendingExec map[string]string // stepID -> msgID
}
func newStreamParser(handler message.StreamFunc) *streamParser {
return &streamParser{
handler: handler,
pendingExec: make(map[string]string),
}
}
func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
doneParsing := make(chan struct{})
defer close(doneParsing)
go func() {
select {
case <-ctx.Done():
stdout.Close()
case <-doneParsing:
}
}()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
startTime := time.Now()
lineCount := 0
lastHeartbeat := time.Now()
log.Trace("[opencode-parse] stream started")
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
lineCount++
if time.Since(lastHeartbeat) > 30*time.Second {
log.Trace("[opencode-parse] heartbeat: lines=%d elapsed=%v",
lineCount, time.Since(startTime).Round(time.Second))
lastHeartbeat = time.Now()
}
var msg map[string]any
if err := json.Unmarshal([]byte(line), &msg); err != nil {
if len(line) > 200 {
log.Trace("[opencode-parse] JSON unmarshal error: %v (line len=%d)", err, len(line))
} else {
log.Trace("[opencode-parse] JSON unmarshal error: %v (line=%q)", err, line)
}
continue
}
msgType, _ := msg["type"].(string)
switch msgType {
case "step_start":
if p.handleStepStart(msg) {
return nil
}
case "text":
if p.handleText(msg) {
return nil
}
case "tool_use":
if p.handleToolUse(msg) {
return nil
}
case "step_finish":
if err := p.handleStepFinish(msg); err != nil {
return err
}
if p.completed {
log.Trace("[opencode-parse] stream completed: lines=%d elapsed=%v",
lineCount, time.Since(startTime).Round(time.Second))
return nil
}
log.Trace("[opencode-parse] step_finish (intermediate, reason!=stop): continuing parse loop")
case "reasoning":
p.handleReasoning(msg)
case "error":
log.Trace("[opencode-parse] error event: lines=%d elapsed=%v",
lineCount, time.Since(startTime).Round(time.Second))
return p.handleError(msg)
default:
log.Trace("[opencode-parse] unknown event type: %s", msgType)
}
}
log.Trace("[opencode-parse] stream ended: lines=%d elapsed=%v completed=%v scanErr=%v",
lineCount, time.Since(startTime).Round(time.Second), p.completed, scanner.Err())
if err := scanner.Err(); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
return nil
}
// --- Message lifecycle helpers (mirroring Claude parser) ---
func (p *streamParser) beginMessageWithID(id, msgType string) (stopped bool) {
startData := message.EventMessageStartData{
MessageID: id,
Type: msgType,
Timestamp: time.Now().UnixMilli(),
}
sd, _ := json.Marshal(startData)
return p.handler != nil && p.handler(message.ChunkMessageStart, sd) != 0
}
func (p *streamParser) beginMessage(msgType string) (messageID string, stopped bool) {
id := fmt.Sprintf("sandbox-%s-%s", msgType, message.GenerateNanoID())
return id, p.beginMessageWithID(id, msgType)
}
func (p *streamParser) endMessage() {
if p.handler != nil {
p.handler(message.ChunkMessageEnd, nil)
}
}
func (p *streamParser) closeTextMessage() {
if p.textActive {
p.endMessage()
p.textActive = false
p.textMsgID = ""
}
}
func (p *streamParser) ensureTextMessage() (stopped bool) {
if !p.textActive {
id, stopped := p.beginMessage("text")
if stopped {
return true
}
p.textActive = true
p.textMsgID = id
}
return false
}
func (p *streamParser) emitText(text string) (stopped bool) {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
return p.handler != nil && p.handler(message.ChunkText, []byte(text)) != 0
}
func (p *streamParser) emitExecute(props map[string]any) (stopped bool) {
data, _ := json.Marshal(props)
return p.handler != nil && p.handler(message.ChunkExecute, data) != 0
}
func (p *streamParser) emitMetadata(data map[string]any) {
if p.handler == nil {
return
}
encoded, _ := json.Marshal(data)
p.handler(message.ChunkMetadata, encoded)
}
// --- Event handlers ---
func (p *streamParser) handleStepStart(msg map[string]any) (stopped bool) {
if p.handler == nil {
return false
}
sessionID, _ := msg["sessionID"].(string)
meta := map[string]any{"opencode_session_id": sessionID}
part, _ := msg["part"].(map[string]any)
if part != nil {
if id, ok := part["id"].(string); ok {
meta["step_id"] = id
}
}
p.emitMetadata(meta)
// Record the step ID so that a subsequent tool_use can be correlated.
// We do NOT emit a "running" execute here because we can't tell yet
// whether the step will involve tool calls or just text output. For
// pure text responses an empty execute widget would look wrong. The
// "running" indicator is instead emitted lazily in handleToolUse when
// the first tool event actually arrives.
if part != nil {
if stepID, ok := part["id"].(string); ok && stepID != "" {
p.pendingExec[stepID] = "" // placeholder — msgID assigned later
}
}
return false
}
func (p *streamParser) handleText(msg map[string]any) (stopped bool) {
if p.handler == nil {
return false
}
part, _ := msg["part"].(map[string]any)
if part == nil {
return false
}
content, _ := part["text"].(string)
if content == "" {
content, _ = part["content"].(string)
}
if content == "" {
return false
}
// Close any pending execute message before text output.
p.closePendingExec(msg)
if p.ensureTextMessage() {
return true
}
if p.emitText(content) {
p.closeTextMessage()
return true
}
p.closeTextMessage()
return false
}
func (p *streamParser) handleToolUse(msg map[string]any) (stopped bool) {
if p.handler == nil {
return false
}
part, _ := msg["part"].(map[string]any)
if part == nil {
return false
}
state, _ := part["state"].(map[string]any)
if state == nil {
return false
}
toolName, _ := part["toolName"].(string)
if toolName == "" {
toolName, _ = part["tool"].(string)
}
toolID, _ := part["toolCallId"].(string)
if toolID == "" {
toolID = fmt.Sprintf("oc-tool_%d_%d", p.toolIndex, time.Now().UnixNano())
}
p.toolIndex++
status, _ := state["status"].(string)
isError := status == "error"
p.closeTextMessage()
// Clear any pending step placeholder (no msgID to reuse since we didn't
// emit anything at step_start).
p.closePendingExec(msg)
// Build execute properties.
execProps := map[string]any{
"tool": toolName,
"tool_id": toolID,
"status": status,
"runner": "opencode-cli",
}
if isError {
execProps["is_error"] = true
}
var inputStr string
if input, ok := state["input"].(string); ok && input != "" {
inputStr = input
} else if inputObj, ok := state["input"].(map[string]any); ok {
inputJSON, _ := json.Marshal(inputObj)
inputStr = string(inputJSON)
}
if inputStr != "" {
execProps["input"] = json.RawMessage(inputStr)
summary := extractSummary(toolName, inputStr)
if summary != "" {
execProps["summary"] = summary
}
}
if output, ok := state["output"].(string); ok && output != "" {
execProps["output"] = output
} else if outputObj := state["output"]; outputObj != nil {
execProps["output"] = outputObj
}
// Single message group with the complete tool result.
if _, stopped := p.beginMessage("execute"); stopped {
return true
}
if p.emitExecute(execProps) {
p.endMessage()
return true
}
p.endMessage()
return false
}
func (p *streamParser) handleStepFinish(msg map[string]any) error {
p.closeTextMessage()
p.closePendingExec(msg)
part, _ := msg["part"].(map[string]any)
reason := ""
if part != nil {
reason, _ = part["reason"].(string)
if reason == "" {
reason, _ = part["finishReason"].(string)
}
}
if reason == "stop" || reason == "end_turn" {
p.completed = true
if p.handler != nil {
finishMeta := map[string]any{
"result_summary": map[string]any{
"finish_reason": reason,
},
}
// Include token/cost info if available.
if part != nil {
if tokens, ok := part["tokens"].(map[string]any); ok {
finishMeta["result_summary"].(map[string]any)["tokens"] = tokens
}
if cost, ok := part["cost"]; ok {
finishMeta["result_summary"].(map[string]any)["cost"] = cost
}
}
p.emitMetadata(finishMeta)
}
return nil
}
// reason == "tool-calls" or other intermediate reasons: not final.
// Emit metadata so the frontend knows a new round is starting.
if p.handler != nil && reason != "" {
p.emitMetadata(map[string]any{
"step_transition": map[string]any{
"reason": reason,
},
})
}
return nil
}
func (p *streamParser) handleReasoning(msg map[string]any) {
if p.handler == nil {
return
}
part, _ := msg["part"].(map[string]any)
if part == nil {
return
}
content, _ := part["text"].(string)
if content == "" {
content, _ = part["content"].(string)
}
if content == "" {
return
}
p.emitMetadata(map[string]any{
"reasoning": content,
})
}
func (p *streamParser) handleError(msg map[string]any) error {
p.closePendingExec(msg)
var errMsg string
if part, ok := msg["part"].(map[string]any); ok {
errMsg, _ = part["error"].(string)
if errMsg == "" {
errMsg, _ = part["message"].(string)
}
}
if errMsg == "" {
switch e := msg["error"].(type) {
case string:
errMsg = e
case map[string]any:
errMsg, _ = e["message"].(string)
if errMsg == "" {
if data, ok := e["data"].(map[string]any); ok {
errMsg, _ = data["message"].(string)
}
}
if errMsg == "" {
name, _ := e["name"].(string)
if name != "" {
errMsg = name
}
}
}
}
if errMsg == "" {
errMsg = "unknown OpenCode error"
}
if p.handler != nil {
p.handler(message.ChunkError, []byte(errMsg))
}
return fmt.Errorf("OpenCode CLI error: %s", errMsg)
}
// --- Pending exec helpers ---
// closePendingExec clears step placeholders recorded at step_start.
func (p *streamParser) closePendingExec(msg map[string]any) {
if len(p.pendingExec) == 0 {
return
}
// Best-effort: clear matching step or all if we can't match.
part, _ := msg["part"].(map[string]any)
if part != nil {
for _, key := range []string{"id", "messageID"} {
if id, _ := part[key].(string); id != "" {
if _, ok := p.pendingExec[id]; ok {
delete(p.pendingExec, id)
return
}
}
}
}
// Fallback: clear the single pending entry (most common).
if len(p.pendingExec) == 1 {
for k := range p.pendingExec {
delete(p.pendingExec, k)
}
}
}
// --- Utility ---
// extractSummary builds a short human-readable summary from the tool input.
func extractSummary(toolName string, inputJSON string) string {
if inputJSON == "" {
return ""
}
var obj map[string]any
if err := json.Unmarshal([]byte(inputJSON), &obj); err != nil {
return ""
}
switch strings.ToLower(toolName) {
case "bash", "execute":
if cmd, ok := obj["command"].(string); ok {
return truncate(cmd, 80)
}
case "write", "create":
if fp, ok := obj["file_path"].(string); ok {
return fp
}
case "read":
if fp, ok := obj["file_path"].(string); ok {
return fp
}
case "edit":
if fp, ok := obj["file_path"].(string); ok {
return fp
}
}
for _, key := range []string{"path", "file_path", "command", "url", "query"} {
if v, ok := obj[key].(string); ok {
return truncate(v, 80)
}
}
return ""
}
func truncate(s string, max int) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > max {
return s[:max] + "..."
}
return s
}

View file

@ -0,0 +1,528 @@
package opencode
import (
"context"
"encoding/json"
"io"
"strings"
"sync"
"testing"
"github.com/yaoapp/yao/agent/output/message"
)
type chunkRecord struct {
eventType message.StreamChunkType
data string
}
func collectHandler(records *[]chunkRecord, mu *sync.Mutex) message.StreamFunc {
return func(chunkType message.StreamChunkType, data []byte) int {
mu.Lock()
defer mu.Unlock()
*records = append(*records, chunkRecord{eventType: chunkType, data: string(data)})
return 0
}
}
func makeJSONL(events ...map[string]any) string {
var lines []string
for _, e := range events {
data, _ := json.Marshal(e)
lines = append(lines, string(data))
}
return strings.Join(lines, "\n") + "\n"
}
func TestParse_StepStartEmitsMetadata(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "step_start",
"timestamp": 1000,
"sessionID": "ses_123",
"part": map[string]any{
"id": "prt_abc",
"type": "step-start",
"messageID": "msg_xyz",
"sessionID": "ses_123",
},
},
map[string]any{
"type": "text",
"timestamp": 2000,
"sessionID": "ses_123",
"part": map[string]any{"text": "Hi!"},
},
map[string]any{
"type": "step_finish",
"timestamp": 3000,
"sessionID": "ses_123",
"part": map[string]any{"reason": "stop"},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("parse error: %v", err)
}
mu.Lock()
defer mu.Unlock()
// step_start should only emit metadata (no execute widget for pure text).
hasMeta := false
hasRunningExec := false
for _, r := range records {
if r.eventType == message.ChunkMetadata {
var meta map[string]any
json.Unmarshal([]byte(r.data), &meta)
if _, ok := meta["opencode_session_id"]; ok {
hasMeta = true
}
}
if r.eventType == message.ChunkExecute {
var props map[string]any
json.Unmarshal([]byte(r.data), &props)
if props["status"] == "running" {
hasRunningExec = true
}
}
}
if !hasMeta {
t.Error("step_start should emit metadata with session ID")
}
if hasRunningExec {
t.Error("step_start should NOT emit a running execute for pure text steps")
}
}
func TestParse_TextEvent(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "text",
"timestamp": 1000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "text",
"content": "Hello, world!",
},
},
map[string]any{
"type": "step_finish",
"timestamp": 2000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "step-finish",
"finishReason": "stop",
},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if !parser.completed {
t.Error("parser should be completed")
}
mu.Lock()
defer mu.Unlock()
hasText := false
for _, r := range records {
if r.eventType == message.ChunkText {
hasText = true
if r.data != "Hello, world!" {
t.Errorf("text data = %q, want 'Hello, world!'", r.data)
}
}
}
if !hasText {
t.Error("should have emitted a ChunkText event")
}
}
func TestParse_ToolUseEvent(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "tool_use",
"timestamp": 1000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "tool",
"toolName": "bash",
"toolCallId": "call_abc",
"state": map[string]any{
"status": "completed",
"input": `{"command":"ls -la"}`,
"output": "file1.txt\nfile2.txt",
},
},
},
map[string]any{
"type": "step_finish",
"timestamp": 2000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "step-finish",
"finishReason": "stop",
},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("parse error: %v", err)
}
mu.Lock()
defer mu.Unlock()
hasCompletedExec := false
for _, r := range records {
if r.eventType == message.ChunkExecute {
var props map[string]any
json.Unmarshal([]byte(r.data), &props)
if props["tool"] == "bash" && props["status"] == "completed" {
hasCompletedExec = true
if props["runner"] != "opencode-cli" {
t.Errorf("runner = %v, want opencode-cli", props["runner"])
}
}
}
}
if !hasCompletedExec {
t.Error("should have emitted a ChunkExecute with tool=bash status=completed")
}
}
func TestParse_ErrorEvent(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "error",
"timestamp": 1000,
"sessionID": "ses_123",
"error": "something went wrong",
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err == nil {
t.Fatal("expected error from parse")
}
if !strings.Contains(err.Error(), "something went wrong") {
t.Errorf("error should contain message, got: %v", err)
}
mu.Lock()
defer mu.Unlock()
hasError := false
for _, r := range records {
if r.eventType == message.ChunkError {
hasError = true
}
}
if !hasError {
t.Error("should have emitted a ChunkError event")
}
}
func TestParse_ErrorEvent_Nested(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "error",
"timestamp": 1000,
"sessionID": "ses_123",
"error": map[string]any{
"name": "APIError",
"data": map[string]any{
"message": "Authentication Fails",
"statusCode": 401,
},
},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err == nil {
t.Fatal("expected error from parse")
}
if !strings.Contains(err.Error(), "Authentication Fails") {
t.Errorf("error should contain nested message, got: %v", err)
}
}
func TestParse_StepFinishToolCalls(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "step_finish",
"timestamp": 1000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "step-finish",
"finishReason": "tool-calls",
},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if parser.completed {
t.Error("tool-calls finish reason should NOT mark as completed")
}
mu.Lock()
defer mu.Unlock()
hasTransition := false
for _, r := range records {
if r.eventType == message.ChunkMetadata {
var meta map[string]any
json.Unmarshal([]byte(r.data), &meta)
if _, ok := meta["step_transition"]; ok {
hasTransition = true
}
}
}
if !hasTransition {
t.Error("tool-calls step_finish should emit step_transition metadata")
}
}
func TestParse_MultiStepToolThenText(t *testing.T) {
input := makeJSONL(
map[string]any{"type": "step_start", "sessionID": "ses_1", "part": map[string]any{"id": "step-1"}},
map[string]any{
"type": "tool_use", "sessionID": "ses_1",
"part": map[string]any{
"toolName": "bash", "toolCallId": "call_1",
"state": map[string]any{"status": "completed", "input": `{"command":"echo hi"}`, "output": "hi"},
},
},
map[string]any{
"type": "step_finish", "sessionID": "ses_1",
"part": map[string]any{"finishReason": "tool-calls"},
},
map[string]any{"type": "step_start", "sessionID": "ses_1", "part": map[string]any{"id": "step-2"}},
map[string]any{
"type": "text", "sessionID": "ses_1",
"part": map[string]any{"text": "The output was: hi"},
},
map[string]any{
"type": "step_finish", "sessionID": "ses_1",
"part": map[string]any{"reason": "stop"},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if !parser.completed {
t.Error("multi-step stream should complete on final stop")
}
mu.Lock()
defer mu.Unlock()
var (
completedCount int
hasText bool
)
for _, r := range records {
if r.eventType == message.ChunkExecute {
var props map[string]any
json.Unmarshal([]byte(r.data), &props)
if props["status"] == "completed" {
completedCount++
}
}
if r.eventType == message.ChunkText && r.data == "The output was: hi" {
hasText = true
}
}
if completedCount < 1 {
t.Error("should have at least 1 completed exec from tool_use")
}
if !hasText {
t.Error("should have emitted ChunkText for final text reply")
}
}
func TestParse_ReasoningEvent(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "reasoning",
"timestamp": 1000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "reasoning",
"text": "Let me think about this...",
},
},
map[string]any{
"type": "step_finish",
"timestamp": 2000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "step-finish",
"finishReason": "stop",
},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("parse error: %v", err)
}
mu.Lock()
defer mu.Unlock()
hasReasoning := false
for _, r := range records {
if r.eventType == message.ChunkMetadata {
var meta map[string]any
json.Unmarshal([]byte(r.data), &meta)
if _, ok := meta["reasoning"]; ok {
hasReasoning = true
}
}
}
if !hasReasoning {
t.Error("should have emitted reasoning metadata")
}
}
func TestParse_UnknownEventType(t *testing.T) {
input := makeJSONL(
map[string]any{
"type": "future_event_type",
"timestamp": 1000,
"sessionID": "ses_123",
},
map[string]any{
"type": "step_finish",
"timestamp": 2000,
"sessionID": "ses_123",
"part": map[string]any{
"type": "step-finish",
"finishReason": "stop",
},
},
)
var records []chunkRecord
var mu sync.Mutex
handler := collectHandler(&records, &mu)
parser := newStreamParser(handler)
reader := io.NopCloser(strings.NewReader(input))
err := parser.parse(context.Background(), reader)
if err != nil {
t.Fatalf("unknown event type should not cause error: %v", err)
}
if !parser.completed {
t.Error("parser should still complete")
}
}
func TestExtractSummary(t *testing.T) {
cases := []struct {
tool, input, want string
}{
{"bash", `{"command":"ls -la /tmp"}`, "ls -la /tmp"},
{"read", `{"file_path":"main.go"}`, "main.go"},
{"write", `{"file_path":"output.txt"}`, "output.txt"},
{"unknown", `{"query":"select * from users"}`, "select * from users"},
{"bash", `{"no_command_key": true}`, ""},
{"bash", `invalid json`, ""},
{"bash", "", ""},
}
for _, tc := range cases {
got := extractSummary(tc.tool, tc.input)
if got != tc.want {
t.Errorf("extractSummary(%q, %q) = %q, want %q", tc.tool, tc.input, got, tc.want)
}
}
}
func TestTruncate(t *testing.T) {
short := "hello"
if truncate(short, 80) != "hello" {
t.Error("short string should not be truncated")
}
long := strings.Repeat("a", 100)
result := truncate(long, 80)
if len(result) != 83 { // 80 + "..."
t.Errorf("truncated len = %d, want 83", len(result))
}
withNewlines := "line1\nline2\nline3"
result = truncate(withNewlines, 80)
if strings.Contains(result, "\n") {
t.Error("truncate should replace newlines with spaces")
}
}

View file

@ -0,0 +1,76 @@
package opencode
import (
"fmt"
"strings"
)
// windowsPlatform implements the platform interface for Windows containers.
// Aligned with the Claude runner's plat_win.go: uses PowerShell for shell
// commands, backslash path joins, and full HOME-related env vars.
type windowsPlatform struct {
workDir string
shell string
}
func newWindowsPlatform(workDir, shell string) *windowsPlatform {
if shell == "" {
shell = "pwsh"
}
return &windowsPlatform{workDir: workDir, shell: shell}
}
func (w *windowsPlatform) OS() string { return "windows" }
func (w *windowsPlatform) Shell() string { return w.shell }
func (w *windowsPlatform) PathJoin(parts ...string) string {
return strings.Join(parts, `\`)
}
// HomeEnv sets HOME, USERPROFILE, HOMEDRIVE, and HOMEPATH so that Git for
// Windows, npm, and other tools resolve ~ correctly inside the container.
// See Claude runner plat_win.go and anthropics/claude-code#13138.
func (w *windowsPlatform) HomeEnv(workDir string) map[string]string {
env := map[string]string{
"HOME": workDir,
"USERPROFILE": workDir,
}
if len(workDir) >= 2 && workDir[1] == ':' {
env["HOMEDRIVE"] = workDir[:2]
env["HOMEPATH"] = workDir[2:]
}
return env
}
func (w *windowsPlatform) ShellCmd(script string) []string {
shell := strings.ToLower(w.shell)
switch shell {
case "pwsh":
return []string{"pwsh", "-NoProfile", "-Command", script}
case "powershell":
return []string{"powershell", "-NoProfile", "-Command", script}
case "cmd.exe", "cmd":
return []string{"cmd.exe", "/C", script}
default:
return []string{"pwsh", "-NoProfile", "-Command", script}
}
}
func (w *windowsPlatform) KillCmd(pattern string) []string {
script := fmt.Sprintf(
"Get-Process -ErrorAction SilentlyContinue | Where-Object {$_.ProcessName -like '*%s*'} | "+
"ForEach-Object { taskkill /F /T /PID $_.Id 2>$null }; "+
"Get-Process -ErrorAction SilentlyContinue | Where-Object {$_.ProcessName -like '*%s*'} | "+
"Stop-Process -Force -ErrorAction SilentlyContinue",
pattern, pattern)
return w.ShellCmd(script)
}
func (w *windowsPlatform) KillSessionCmd(sessionName string) []string {
script := fmt.Sprintf(
"Get-Process -ErrorAction SilentlyContinue | "+
"Where-Object { $_.CommandLine -like '*%s*' } | "+
"ForEach-Object { taskkill /F /T /PID $_.Id 2>$null }",
sessionName)
return w.ShellCmd(script)
}

View file

@ -0,0 +1,66 @@
package opencode
import (
"fmt"
"path"
"strings"
infra "github.com/yaoapp/yao/sandbox/v2"
)
// platform encapsulates OS-dependent behaviors for the target environment.
type platform interface {
OS() string
Shell() string
HomeEnv(workDir string) map[string]string
PathJoin(parts ...string) string
ShellCmd(script string) []string
KillCmd(pattern string) []string
KillSessionCmd(sessionName string) []string
}
type posixBase struct {
os string
workDir string
shell string
}
func (b *posixBase) OS() string { return b.os }
func (b *posixBase) Shell() string { return b.shell }
func (b *posixBase) PathJoin(parts ...string) string { return path.Join(parts...) }
func (b *posixBase) HomeEnv(workDir string) map[string]string {
return map[string]string{"HOME": workDir}
}
func (b *posixBase) ShellCmd(script string) []string {
return []string{"bash", "-c", script}
}
func (b *posixBase) KillCmd(pattern string) []string {
return []string{"sh", "-c", fmt.Sprintf("pkill -f '%s' || true", pattern)}
}
func (b *posixBase) KillSessionCmd(sessionName string) []string {
return []string{"sh", "-c", fmt.Sprintf("pkill -9 -f '%s' || true", sessionName)}
}
func resolvePlatform(computer infra.Computer) platform {
sys := computer.ComputerInfo().System
osName := strings.ToLower(sys.OS)
workDir := computer.GetWorkDir()
shell := sys.Shell
if osName == "windows" {
return newWindowsPlatform(workDir, shell)
}
base := posixBase{os: osName, workDir: workDir, shell: shell}
if base.shell == "" {
base.shell = "bash"
}
if base.os == "" {
base.os = "linux"
}
return &base
}

View file

@ -0,0 +1,220 @@
package opencode
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// POSIX posixBase tests
// ---------------------------------------------------------------------------
func TestPosixBase_Accessors(t *testing.T) {
b := &posixBase{os: "linux", workDir: "/workspace", shell: "bash"}
assert.Equal(t, "linux", b.OS())
assert.Equal(t, "bash", b.Shell())
assert.Equal(t, "/workspace/.yao/config", b.PathJoin("/workspace", ".yao", "config"))
}
func TestPosixBase_HomeEnv(t *testing.T) {
b := &posixBase{}
env := b.HomeEnv("/workspace")
assert.Equal(t, "/workspace", env["HOME"])
assert.Len(t, env, 1)
}
func TestPosixBase_ShellCmd(t *testing.T) {
b := &posixBase{}
cmd := b.ShellCmd("echo hello")
assert.Equal(t, []string{"bash", "-c", "echo hello"}, cmd)
}
func TestPosixBase_KillCmd(t *testing.T) {
b := &posixBase{}
cmd := b.KillCmd("opencode")
require.Len(t, cmd, 3)
assert.Equal(t, "sh", cmd[0])
assert.Contains(t, cmd[2], "pkill")
assert.Contains(t, cmd[2], "opencode")
}
func TestPosixBase_KillSessionCmd(t *testing.T) {
b := &posixBase{}
cmd := b.KillSessionCmd("yao-oc-session123")
require.Len(t, cmd, 3)
assert.Equal(t, "sh", cmd[0])
assert.Contains(t, cmd[2], "pkill -9 -f")
assert.Contains(t, cmd[2], "yao-oc-session123")
}
// ---------------------------------------------------------------------------
// Windows windowsPlatform tests
// ---------------------------------------------------------------------------
func TestWindows_NewDefaults(t *testing.T) {
w := newWindowsPlatform(`C:\workspace`, "")
assert.Equal(t, "pwsh", w.Shell())
}
func TestWindows_Accessors(t *testing.T) {
w := newWindowsPlatform(`C:\workspace`, "pwsh")
assert.Equal(t, "windows", w.OS())
assert.Equal(t, "pwsh", w.Shell())
}
func TestWindows_PathJoin(t *testing.T) {
w := newWindowsPlatform(`C:\workspace`, "pwsh")
assert.Equal(t, `C:\workspace\.yao\config`, w.PathJoin(`C:\workspace`, ".yao", "config"))
assert.Equal(t, `a\b\c`, w.PathJoin("a", "b", "c"))
}
func TestWindows_HomeEnv(t *testing.T) {
w := newWindowsPlatform(`C:\workspace`, "pwsh")
env := w.HomeEnv(`C:\workspace`)
assert.Equal(t, `C:\workspace`, env["HOME"])
assert.Equal(t, `C:\workspace`, env["USERPROFILE"])
assert.Equal(t, `C:`, env["HOMEDRIVE"])
assert.Equal(t, `\workspace`, env["HOMEPATH"])
assert.Len(t, env, 4)
}
func TestWindows_HomeEnv_NoDrive(t *testing.T) {
w := newWindowsPlatform("X", "pwsh")
env := w.HomeEnv("X")
assert.Equal(t, "X", env["HOME"])
assert.Equal(t, "X", env["USERPROFILE"])
_, hasDrive := env["HOMEDRIVE"]
assert.False(t, hasDrive, "should not set HOMEDRIVE for path without drive letter")
assert.Len(t, env, 2)
}
func TestWindows_ShellCmd_Pwsh(t *testing.T) {
w := newWindowsPlatform(`C:\ws`, "pwsh")
cmd := w.ShellCmd("echo hello")
assert.Equal(t, []string{"pwsh", "-NoProfile", "-Command", "echo hello"}, cmd)
}
func TestWindows_ShellCmd_Powershell(t *testing.T) {
w := newWindowsPlatform(`C:\ws`, "powershell")
cmd := w.ShellCmd("echo hello")
assert.Equal(t, "powershell", cmd[0])
assert.Equal(t, "-NoProfile", cmd[1])
}
func TestWindows_ShellCmd_Cmd(t *testing.T) {
w := newWindowsPlatform(`C:\ws`, "cmd.exe")
cmd := w.ShellCmd("echo hello")
assert.Equal(t, []string{"cmd.exe", "/C", "echo hello"}, cmd)
}
func TestWindows_ShellCmd_Default(t *testing.T) {
w := newWindowsPlatform(`C:\ws`, "unknown-shell")
cmd := w.ShellCmd("echo")
assert.Equal(t, "pwsh", cmd[0], "unknown shell should fall back to pwsh")
}
func TestWindows_KillCmd(t *testing.T) {
w := newWindowsPlatform(`C:\ws`, "pwsh")
cmd := w.KillCmd("opencode")
require.Len(t, cmd, 4)
assert.Equal(t, "pwsh", cmd[0])
assert.Contains(t, cmd[3], "opencode")
assert.Contains(t, cmd[3], "taskkill")
assert.Contains(t, cmd[3], "Stop-Process")
}
func TestWindows_KillSessionCmd(t *testing.T) {
w := newWindowsPlatform(`C:\ws`, "pwsh")
cmd := w.KillSessionCmd("yao-oc-session123")
require.Len(t, cmd, 4)
assert.Equal(t, "pwsh", cmd[0])
assert.Contains(t, cmd[3], "CommandLine")
assert.Contains(t, cmd[3], "yao-oc-session123")
assert.Contains(t, cmd[3], "taskkill")
}
// ---------------------------------------------------------------------------
// shellQuote / shellQuoteForPlatform tests
// ---------------------------------------------------------------------------
func TestShellQuote_POSIX(t *testing.T) {
result := shellQuote("opencode", "run", "--format", "json")
assert.Equal(t, "opencode run --format json", result)
}
func TestShellQuote_POSIX_SpecialChars(t *testing.T) {
result := shellQuote("opencode", "run", "hello world", "it's")
assert.Contains(t, result, "'hello world'")
assert.Contains(t, result, `'\''`)
}
func TestShellQuotePowerShell(t *testing.T) {
result := shellQuotePowerShell("opencode", "run", "--format", "json")
assert.Equal(t, "opencode run --format json", result)
}
func TestShellQuotePowerShell_SpecialChars(t *testing.T) {
result := shellQuotePowerShell("opencode", "run", "hello world", "it's")
assert.Contains(t, result, "'hello world'")
assert.Contains(t, result, "'it''s'")
assert.NotContains(t, result, `'\''`, "PowerShell should use '' not '\\''")
}
func TestShellQuoteForPlatform_POSIX(t *testing.T) {
p := &posixBase{os: "linux"}
result := shellQuoteForPlatform(p, "opencode", "it's")
assert.Contains(t, result, `'\''`)
}
func TestShellQuoteForPlatform_Windows(t *testing.T) {
p := newWindowsPlatform(`C:\ws`, "pwsh")
result := shellQuoteForPlatform(p, "opencode", "it's")
assert.Contains(t, result, "''s'")
assert.NotContains(t, result, `'\''`)
}
// ---------------------------------------------------------------------------
// buildSandboxEnvPrompt tests
// ---------------------------------------------------------------------------
func TestBuildSandboxEnvPrompt_Linux(t *testing.T) {
p := &posixBase{os: "linux", shell: "bash"}
prompt := buildSandboxEnvPrompt(p, "/workspace")
assert.Contains(t, prompt, "linux")
assert.Contains(t, prompt, "bash")
assert.Contains(t, prompt, "/workspace")
assert.Contains(t, prompt, "$VAR_NAME")
assert.NotContains(t, prompt, "$env:")
}
func TestBuildSandboxEnvPrompt_Windows(t *testing.T) {
p := newWindowsPlatform(`C:\workspace`, "pwsh")
prompt := buildSandboxEnvPrompt(p, `C:\workspace`)
assert.Contains(t, prompt, "windows")
assert.Contains(t, prompt, "pwsh")
assert.Contains(t, prompt, `C:\workspace`)
assert.Contains(t, prompt, "$env:VAR_NAME")
}
// ---------------------------------------------------------------------------
// Vision read.ts copy step generation logic
// ---------------------------------------------------------------------------
func TestVisionCopyStep_Linux(t *testing.T) {
p := &posixBase{os: "linux", workDir: "/workspace", shell: "bash"}
cmd := visionCopyCmd(p)
assert.Contains(t, cmd, "mkdir -p")
assert.Contains(t, cmd, "opencode-tools")
assert.NotContains(t, cmd, "PowerShell")
}
func TestVisionCopyStep_Windows(t *testing.T) {
p := newWindowsPlatform(`C:\workspace`, "pwsh")
cmd := visionCopyCmd(p)
assert.Contains(t, cmd, "Test-Path")
assert.Contains(t, cmd, "Copy-Item")
assert.Contains(t, cmd, `opencode-tools`)
assert.NotContains(t, cmd, "mkdir -p")
}

View file

@ -0,0 +1,493 @@
package opencode
import (
"encoding/json"
"testing"
"github.com/yaoapp/gou/connector"
gouTypes "github.com/yaoapp/gou/types"
"github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/xun/dbal/schema"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
)
type fakeConn struct {
id string
typ int
settings map[string]interface{}
}
func (f *fakeConn) Register(string, string, []byte) error { return nil }
func (f *fakeConn) Query() (query.Query, error) { return nil, nil }
func (f *fakeConn) Schema() (schema.Schema, error) { return nil, nil }
func (f *fakeConn) Close() error { return nil }
func (f *fakeConn) ID() string { return f.id }
func (f *fakeConn) Is(t int) bool { return f.typ == t }
func (f *fakeConn) Setting() map[string]interface{} { return f.settings }
func (f *fakeConn) GetMetaInfo() gouTypes.MetaInfo { return gouTypes.MetaInfo{} }
func newFakeOpenAI(id, host, model, key string) *fakeConn {
return &fakeConn{
id: id,
typ: connector.OPENAI,
settings: map[string]interface{}{
"host": host,
"model": model,
"key": key,
},
}
}
func newFakeAnthropic(id, host, model, key string) *fakeConn {
return &fakeConn{
id: id,
typ: connector.ANTHROPIC,
settings: map[string]interface{}{
"host": host,
"model": model,
"key": key,
},
}
}
func registerFakeConnectors(t *testing.T, conns map[string]connector.Connector) func() {
t.Helper()
for id, c := range conns {
connector.Connectors[id] = c
}
return func() {
for id := range conns {
delete(connector.Connectors, id)
}
}
}
// ---------------------------------------------------------------------------
// injectRoleProviders tests
// ---------------------------------------------------------------------------
func TestInjectRoleProviders_VisionCustomProvider(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"vision-conn": visionConn})
defer cleanup()
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{
"provider": map[string]any{"custom": map[string]any{"npm": "@ai-sdk/openai-compatible"}},
"model": "custom/deepseek-v4-flash",
"enabled_providers": []string{"custom"},
}
req := &types.PrepareRequest{
Connector: primaryConn,
Config: &types.SandboxConfig{
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"vision": {Connector: "vision-conn", Override: "force"},
},
},
},
}
injectRoleProviders(cfg, req)
providers := cfg["provider"].(map[string]any)
visionBlock, ok := providers["vision"]
if !ok {
t.Fatal("should have injected 'vision' provider block")
}
vBlock := visionBlock.(map[string]any)
models := vBlock["models"].(map[string]any)
modelCfg := models["gpt-4o-mini"].(map[string]any)
mods, ok := modelCfg["modalities"].(map[string][]string)
if !ok {
t.Fatal("vision model should have modalities declared")
}
if len(mods["input"]) != 2 || mods["input"][0] != "text" || mods["input"][1] != "image" {
t.Errorf("modalities.input = %v, want [text, image]", mods["input"])
}
enabled := cfg["enabled_providers"].([]string)
hasVision := false
for _, e := range enabled {
if e == "vision" {
hasVision = true
}
}
if !hasVision {
t.Error("enabled_providers should contain 'vision'")
}
}
func TestInjectRoleProviders_VisionNativeOpenAI(t *testing.T) {
visionConn := newFakeOpenAI("vis", "", "gpt-4o-mini", "sk-oai")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"oai-vision": visionConn})
defer cleanup()
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{
"provider": map[string]any{"custom": map[string]any{"npm": "@ai-sdk/openai-compatible"}},
"model": "custom/deepseek-v4-flash",
"enabled_providers": []string{"custom"},
}
req := &types.PrepareRequest{
Connector: primaryConn,
Config: &types.SandboxConfig{
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"vision": {Connector: "oai-vision", Override: "force"},
},
},
},
}
injectRoleProviders(cfg, req)
providers := cfg["provider"].(map[string]any)
visionBlock, ok := providers["vision"]
if !ok {
t.Fatal("should have separate 'vision' provider (different host from primary)")
}
vBlock := visionBlock.(map[string]any)
models := vBlock["models"].(map[string]any)
modelCfg := models["gpt-4o-mini"].(map[string]any)
if _, ok := modelCfg["modalities"]; !ok {
t.Error("native OpenAI vision model should still declare modalities")
}
}
func TestInjectRoleProviders_LightWithDifferentHost(t *testing.T) {
lightConn := newFakeOpenAI("moonshot", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"moonshot-conn": lightConn})
defer cleanup()
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{
"provider": map[string]any{"custom": map[string]any{"npm": "@ai-sdk/openai-compatible"}},
"model": "custom/deepseek-v4-flash",
"enabled_providers": []string{"custom"},
}
req := &types.PrepareRequest{
Connector: primaryConn,
Config: &types.SandboxConfig{
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"light": {Connector: "moonshot-conn", Override: "force"},
},
},
},
}
injectRoleProviders(cfg, req)
providers := cfg["provider"].(map[string]any)
if _, ok := providers["light"]; !ok {
t.Fatal("light role should have its own provider block when host differs from primary")
}
smallModel, ok := cfg["small_model"].(string)
if !ok || smallModel == "" {
t.Fatal("small_model should be set for light role")
}
if smallModel != "light/moonshot-v1-8k" {
t.Errorf("small_model = %q, want 'light/moonshot-v1-8k'", smallModel)
}
enabled := cfg["enabled_providers"].([]string)
hasLight := false
for _, e := range enabled {
if e == "light" {
hasLight = true
}
}
if !hasLight {
t.Error("enabled_providers should contain 'light'")
}
}
func TestInjectRoleProviders_LightSameHostAsPrimary(t *testing.T) {
lightConn := newFakeOpenAI("ds-light", "https://api.deepseek.com", "deepseek-chat", "sk-ds")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"ds-light-conn": lightConn})
defer cleanup()
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
primaryProviderID, primaryCfg, modelStr := buildProviderConfig(primaryConn)
cfg := map[string]any{
"provider": map[string]any{primaryProviderID: primaryCfg},
"model": modelStr,
"enabled_providers": []string{primaryProviderID},
}
req := &types.PrepareRequest{
Connector: primaryConn,
Config: &types.SandboxConfig{
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"light": {Connector: "ds-light-conn", Override: "force"},
},
},
},
}
injectRoleProviders(cfg, req)
providers := cfg["provider"].(map[string]any)
if _, ok := providers["light"]; ok {
t.Error("light should merge into primary block when same host, not create separate block")
}
customBlock := providers["custom"].(map[string]any)
models := customBlock["models"].(map[string]any)
if _, ok := models["deepseek-chat"]; !ok {
t.Error("light model should be merged into primary's 'custom' provider models")
}
smallModel := cfg["small_model"].(string)
if smallModel != "custom/deepseek-chat" {
t.Errorf("small_model = %q, want 'custom/deepseek-chat'", smallModel)
}
}
func TestInjectRoleProviders_NoConnectors(t *testing.T) {
cfg := map[string]any{
"provider": map[string]any{"openai": map[string]any{}},
"model": "openai/gpt-4o",
"enabled_providers": []string{"openai"},
}
req := &types.PrepareRequest{
Config: &types.SandboxConfig{},
}
injectRoleProviders(cfg, req)
enabled := cfg["enabled_providers"].([]string)
if len(enabled) != 1 || enabled[0] != "openai" {
t.Errorf("enabled_providers should be unchanged: %v", enabled)
}
}
func TestInjectRoleProviders_AnthropicVision(t *testing.T) {
visionConn := newFakeAnthropic("claude-vis", "https://api.anthropic.com", "claude-sonnet-4-5-20250929", "sk-ant")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"anthropic-vision": visionConn})
defer cleanup()
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
cfg := map[string]any{
"provider": map[string]any{"custom": map[string]any{"npm": "@ai-sdk/openai-compatible"}},
"model": "custom/deepseek-v4-flash",
"enabled_providers": []string{"custom"},
}
req := &types.PrepareRequest{
Connector: primaryConn,
Config: &types.SandboxConfig{
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"vision": {Connector: "anthropic-vision", Override: "force"},
},
},
},
}
injectRoleProviders(cfg, req)
providers := cfg["provider"].(map[string]any)
visionBlock, ok := providers["vision"]
if !ok {
t.Fatal("should inject 'vision' provider for Anthropic connector")
}
vBlock := visionBlock.(map[string]any)
if vBlock["npm"] != nil {
t.Error("Anthropic provider should NOT have npm field")
}
}
// ---------------------------------------------------------------------------
// buildEnv role injection tests
// ---------------------------------------------------------------------------
func TestInjectRoleEnvVars_Vision(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis-key")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"vision-conn": visionConn})
defer cleanup()
req := &types.StreamRequest{
Config: &types.SandboxConfig{
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"vision": {Connector: "vision-conn", Override: "force"},
},
},
},
}
env := map[string]string{}
injectRoleEnvVars(env, req)
if env["YAO_VISION_KEY"] != "sk-vis-key" {
t.Errorf("YAO_VISION_KEY = %q, want 'sk-vis-key'", env["YAO_VISION_KEY"])
}
if env["YAO_VISION_BASE_URL"] != "https://api.mymaas.com/v1" {
t.Errorf("YAO_VISION_BASE_URL = %q, want 'https://api.mymaas.com/v1'", env["YAO_VISION_BASE_URL"])
}
if env["YAO_VISION_MODEL"] != "gpt-4o-mini" {
t.Errorf("YAO_VISION_MODEL = %q, want 'gpt-4o-mini'", env["YAO_VISION_MODEL"])
}
}
func TestInjectRoleEnvVars_Light(t *testing.T) {
lightConn := newFakeOpenAI("moon", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{"moon-conn": lightConn})
defer cleanup()
req := &types.StreamRequest{
Config: &types.SandboxConfig{
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"light": {Connector: "moon-conn", Override: "force"},
},
},
},
}
env := map[string]string{}
injectRoleEnvVars(env, req)
if env["YAO_LIGHT_KEY"] != "sk-moon" {
t.Errorf("YAO_LIGHT_KEY = %q, want 'sk-moon'", env["YAO_LIGHT_KEY"])
}
if env["YAO_LIGHT_BASE_URL"] != "https://api.moonshot.cn/v1" {
t.Errorf("YAO_LIGHT_BASE_URL = %q, want 'https://api.moonshot.cn/v1'", env["YAO_LIGHT_BASE_URL"])
}
if env["YAO_LIGHT_MODEL"] != "moonshot-v1-8k" {
t.Errorf("YAO_LIGHT_MODEL = %q, want 'moonshot-v1-8k'", env["YAO_LIGHT_MODEL"])
}
}
func TestInjectRoleEnvVars_NoConnectors(t *testing.T) {
req := &types.StreamRequest{
Config: &types.SandboxConfig{},
}
env := map[string]string{}
injectRoleEnvVars(env, req)
for _, prefix := range []string{"YAO_VISION", "YAO_LIGHT", "YAO_HEAVY", "YAO_SUBAGENT"} {
for _, suffix := range []string{"_KEY", "_BASE_URL", "_MODEL"} {
if v, ok := env[prefix+suffix]; ok {
t.Errorf("unexpected env %s=%s with no connectors", prefix+suffix, v)
}
}
}
}
func TestInjectRoleEnvVars_MultipleRoles(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.vision.com", "vis-model", "sk-vis")
lightConn := newFakeOpenAI("light-c", "https://api.light.com", "light-model", "sk-light")
heavyConn := newFakeOpenAI("heavy-c", "https://api.heavy.com", "heavy-model", "sk-heavy")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{
"vis-c": visionConn,
"light-c": lightConn,
"heavy-c": heavyConn,
})
defer cleanup()
req := &types.StreamRequest{
Config: &types.SandboxConfig{
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"vision": {Connector: "vis-c", Override: "force"},
"light": {Connector: "light-c", Override: "force"},
"heavy": {Connector: "heavy-c", Override: "force"},
},
},
},
}
env := map[string]string{}
injectRoleEnvVars(env, req)
if env["YAO_VISION_KEY"] != "sk-vis" {
t.Errorf("YAO_VISION_KEY = %q", env["YAO_VISION_KEY"])
}
if env["YAO_LIGHT_KEY"] != "sk-light" {
t.Errorf("YAO_LIGHT_KEY = %q", env["YAO_LIGHT_KEY"])
}
if env["YAO_HEAVY_KEY"] != "sk-heavy" {
t.Errorf("YAO_HEAVY_KEY = %q", env["YAO_HEAVY_KEY"])
}
}
// ---------------------------------------------------------------------------
// Full integration: buildOpenCodeConfig with role connectors
// ---------------------------------------------------------------------------
func TestBuildOpenCodeConfig_WithVisionAndLight(t *testing.T) {
visionConn := newFakeOpenAI("vis", "https://api.mymaas.com/v1", "gpt-4o-mini", "sk-vis")
lightConn := newFakeOpenAI("moon", "https://api.moonshot.cn/v1", "moonshot-v1-8k", "sk-moon")
cleanup := registerFakeConnectors(t, map[string]connector.Connector{
"vision-conn": visionConn,
"light-conn": lightConn,
})
defer cleanup()
primaryConn := newFakeOpenAI("primary", "https://api.deepseek.com", "deepseek-v4-flash", "sk-ds")
req := &types.PrepareRequest{
AssistantID: "test-assistant",
Connector: primaryConn,
Config: &types.SandboxConfig{
Runner: types.RunnerConfig{
Connectors: map[string]*types.RoleConnector{
"vision": {Connector: "vision-conn", Override: "force"},
"light": {Connector: "light-conn", Override: "force"},
},
},
},
}
data := buildOpenCodeConfig(req, nil)
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
providers := cfg["provider"].(map[string]any)
if _, ok := providers["custom"]; !ok {
t.Error("should have 'custom' provider for primary DeepSeek")
}
if _, ok := providers["vision"]; !ok {
t.Error("should have 'vision' provider block")
}
if _, ok := providers["light"]; !ok {
t.Error("should have 'light' provider block (different host from primary)")
}
if cfg["model"] != "custom/deepseek-v4-flash" {
t.Errorf("model = %v, want custom/deepseek-v4-flash", cfg["model"])
}
if cfg["small_model"] != "light/moonshot-v1-8k" {
t.Errorf("small_model = %v, want light/moonshot-v1-8k", cfg["small_model"])
}
enabled := cfg["enabled_providers"].([]any)
enabledSet := map[string]bool{}
for _, e := range enabled {
enabledSet[e.(string)] = true
}
for _, want := range []string{"custom", "vision", "light"} {
if !enabledSet[want] {
t.Errorf("enabled_providers should contain %q", want)
}
}
}

View file

@ -0,0 +1,246 @@
package opencode
import (
"context"
"fmt"
"strings"
"time"
"github.com/yaoapp/kun/log"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/sandbox/v2/shared"
"github.com/yaoapp/yao/agent/sandbox/v2/types"
infra "github.com/yaoapp/yao/sandbox/v2"
)
// Runner implements the sandbox Runner interface for OpenCode CLI.
type Runner struct {
mode string
hasMCP bool
mcpServers []types.MCPServer
lastCompleted bool
lastChatID string
logger *agentContext.RequestLogger
}
// New creates a new OpenCode Runner.
func New() *Runner {
return &Runner{mode: "cli"}
}
// Name returns the runner identifier. Must NOT be "yao" (see agent.go branching).
func (r *Runner) Name() string { return "opencode" }
// Prepare executes user-defined and runner-specific prepare steps.
func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error {
r.mode = req.Config.Runner.Mode
if r.mode == "" {
r.mode = "cli"
}
assistantID := req.AssistantID
prefix := ".yao/assistants/" + assistantID
if assistantID == "" {
prefix = ".opencode"
}
steps := append([]types.PrepareStep{}, req.Config.Prepare...)
// 1. Skills copy (aligned with Claude Runner)
if req.SkillsDir != "" {
ws := req.Computer.Workplace()
if ws != nil {
src := "local:///" + req.SkillsDir
dst := prefix + "/skills"
if _, err := ws.Copy(src, dst); err != nil {
log.Warn("[opencode-runner] copy skills %s -> %s: %v", src, dst, err)
}
}
}
// 2. MCP servers -> stored for opencode.json generation
if len(req.MCPServers) > 0 {
r.hasMCP = true
r.mcpServers = req.MCPServers
}
// 3. Create OPENCODE_*_DIR directories (data/config/state/cache) via
// workspace.FS so directory ownership matches the workspace mount.
// OpenCode writes files (e.g. .gitignore, SQLite DB) into these dirs
// on first startup and crashes if they don't exist.
if ws := req.Computer.Workplace(); ws != nil {
for _, sub := range []string{"data", "config", "state", "cache"} {
ws.MkdirAll(prefix+"/opencode/"+sub, 0777)
}
}
// 4. Copy custom tools (e.g. read.ts for vision) into OpenCode global
// config dir ($HOME/.config/opencode/tools/). Only needed when a
// vision connector is configured — the custom read tool overrides the
// built-in read to route image files through the vision API.
if req.Config != nil && req.Config.Runner.Connectors != nil {
if vc, ok := req.Config.Runner.Connectors["vision"]; ok && vc != nil && vc.Connector != "" {
p := resolvePlatform(req.Computer)
steps = append(steps, types.PrepareStep{
Action: "exec",
Cmd: visionCopyCmd(p),
Once: true,
IgnoreError: true,
})
}
}
// 5. Generate opencode.json (project config at workspace root)
configJSON := buildOpenCodeConfig(req, r.mcpServers)
steps = append(steps, types.PrepareStep{
Action: "file",
Path: "opencode.json",
Content: configJSON,
})
// 5. System prompt file
// Written via a prepare step so configHash dedup applies.
// opencode.json instructions field references this path.
// (System prompt is injected at Stream time if not a continuation.)
// 6. Execute all prepare steps via RunPrepareSteps (configHash dedup)
if req.RunSteps != nil && len(steps) > 0 {
if err := req.RunSteps(ctx, steps, req.Computer, req.AssistantID, req.ConfigHash, req.AssistantDir); err != nil {
return fmt.Errorf("opencode prepare steps: %w", err)
}
}
return nil
}
// Stream executes the OpenCode CLI and streams output to handler.
func (r *Runner) Stream(ctx context.Context, req *types.StreamRequest, handler message.StreamFunc) error {
computer := req.Computer
if computer == nil {
return fmt.Errorf("computer is nil")
}
p := resolvePlatform(computer)
// Resolve attachments (shared with Claude runner).
var attachmentPaths []string
if req.ChatID != "" {
if ws := computer.Workplace(); ws != nil {
_, resolved, err := shared.PrepareAttachments(ctx, req.Messages, req.ChatID, ws)
if err != nil {
return fmt.Errorf("prepareAttachments: %w", err)
}
workDir := computer.GetWorkDir()
for _, ar := range resolved {
attachmentPaths = append(attachmentPaths, p.PathJoin(workDir, ar.Path))
}
}
}
// Write system prompt if this is the first turn.
assistantID := req.AssistantID
chatID := req.ChatID
storeKey := "opencode-session:" + assistantID + ":" + chatID
isContinuation := chatID != "" && chatSessionExists(storeKey)
if !isContinuation && req.SystemPrompt != "" {
if ws := computer.Workplace(); ws != nil {
prefix := ".yao/assistants/" + assistantID
if assistantID == "" {
prefix = ".opencode"
}
promptPath := prefix + "/system-prompt.md"
envPrompt := buildSandboxEnvPrompt(p, computer.GetWorkDir())
fullPrompt := req.SystemPrompt + "\n\n" + envPrompt
ws.MkdirAll(prefix, 0755)
ws.WriteFile(promptPath, []byte(fullPrompt), 0644)
}
}
cmd := r.buildCommand(req, p, attachmentPaths)
r.logger = req.Logger
if r.logger == nil {
r.logger = agentContext.NoopLogger()
}
r.lastChatID = chatID
log.Trace("[opencode-runner] Stream started: assistantID=%s chatID=%s", assistantID, chatID)
r.logger.Debug("env vars passed to session (%d total):", len(cmd.env))
for k, v := range cmd.env {
if strings.HasPrefix(k, "CTX_") || k == "OPENCODE_DATA_DIR" || k == "HOME" || k == "WORKDIR" {
r.logger.Debug(" %s=%s", k, v)
} else {
r.logger.Debug(" %s=(set, len=%d)", k, len(v))
}
}
sess, err := startSession(ctx, computer, p, cmd, chatID, r.logger)
if err != nil {
return err
}
streamStart := time.Now()
completed, err := sess.runStream(handler)
r.lastCompleted = completed
elapsed := time.Since(streamStart).Round(time.Second)
log.Trace("[opencode-runner] Stream finished: assistantID=%s chatID=%s completed=%v elapsed=%v err=%v",
assistantID, chatID, completed, elapsed, err)
r.logger.Debug("Stream: runStream returned completed=%v err=%v elapsed=%v", completed, err, elapsed)
// Mark session in store after a clean run (err==nil) so future
// requests can use --continue --session to resume. This covers both
// completed=true (single-step stop) and completed=false (multi-step
// with tool-calls where the process exited normally).
if err == nil && chatID != "" {
sessionID := chatIDToSessionID(assistantID, chatID)
markChatSession(storeKey, sessionID, 90*24*time.Hour)
}
if completed || err == nil {
sess.shutdown()
}
return err
}
// Cleanup kills any remaining opencode processes. If the stream completed
// normally (received step_finish with stop), child processes are preserved.
func (r *Runner) Cleanup(ctx context.Context, computer infra.Computer) error {
if computer == nil {
return nil
}
log.Trace("[opencode-runner] Cleanup: chatID=%s lastCompleted=%v", r.lastChatID, r.lastCompleted)
if r.lastCompleted {
if r.logger != nil {
r.logger.Info("cleanup: stream completed normally, preserving child processes")
}
return nil
}
if r.mode != "service" {
p := resolvePlatform(computer)
if r.lastChatID != "" {
computer.Exec(ctx, p.KillSessionCmd(sanitizeSessionName(r.lastChatID)))
} else {
computer.Exec(ctx, p.KillCmd("opencode"))
}
}
return nil
}
// visionCopyCmd returns the shell command to copy custom OpenCode tools
// (e.g. read.ts for vision) from the container image path into the user's
// config directory. Platform-aware: bash for POSIX, PowerShell for Windows.
func visionCopyCmd(p platform) string {
if p.OS() == "windows" {
return `$d = Join-Path $env:USERPROFILE '.config\opencode\tools'; ` +
`if (!(Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }; ` +
`Copy-Item 'C:\opt\opencode-tools\*.ts' $d -Force -ErrorAction SilentlyContinue`
}
return `mkdir -p $HOME/.config/opencode/tools && for f in /opt/opencode-tools/*.ts; do [ -f "$f" ] && cp -f "$f" $HOME/.config/opencode/tools/; done`
}

View file

@ -0,0 +1,162 @@
package opencode_test
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/caller"
agentcontext "github.com/yaoapp/yao/agent/context"
sandboxtestutils "github.com/yaoapp/yao/agent/sandbox/v2/testutils"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
)
const defaultTimeout = 3 * time.Minute
// ---------------------------------------------------------------------------
// Scenario 1: Oneshot — new container per request, no session persistence
// ---------------------------------------------------------------------------
func TestOpenCode_Oneshot(t *testing.T) {
sandboxtestutils.Prepare(t)
defer sandboxtestutils.Clean(t)
require.NotNil(t, caller.AgentGetterFunc)
const assistantID = "tests.sandbox-v2.opencode-oneshot-cli"
agent, err := caller.AgentGetterFunc(assistantID)
require.NoError(t, err)
chatID := fmt.Sprintf("e2e-oneshot-%d", time.Now().UnixMilli())
ctx := agentcontext.New(
context.Background(),
&oauthtypes.AuthorizedInfo{TeamID: "test-team-e2e", UserID: "test-user-e2e"},
chatID,
)
resp := streamAndWait(t, agent, ctx, "Reply exactly with: hello opencode sandbox", defaultTimeout)
require.NotNil(t, resp.Completion)
assert.Equal(t, "assistant", resp.Completion.Role)
content := contentString(t, resp)
t.Logf("Oneshot response: %s", content)
assert.Contains(t, strings.ToLower(content), "hello opencode sandbox")
}
// ---------------------------------------------------------------------------
// Scenario 2 & 3: Session — first turn (new conversation) + continuation
// ---------------------------------------------------------------------------
func TestOpenCode_Session(t *testing.T) {
sandboxtestutils.Prepare(t)
defer sandboxtestutils.Clean(t)
require.NotNil(t, caller.AgentGetterFunc)
const assistantID = "tests.sandbox-v2.opencode-session-cli"
agent, err := caller.AgentGetterFunc(assistantID)
require.NoError(t, err)
chatID := fmt.Sprintf("e2e-session-%d", time.Now().UnixMilli())
// ── Turn 1: first message — creates a new session ──────────────────
t.Run("turn1_new_session", func(t *testing.T) {
ctx := agentcontext.New(
context.Background(),
&oauthtypes.AuthorizedInfo{TeamID: "test-team-e2e", UserID: "test-user-e2e"},
chatID,
)
resp := streamAndWait(t, agent, ctx,
"Remember this secret code: PINEAPPLE-42. Reply with: understood",
defaultTimeout,
)
require.NotNil(t, resp.Completion)
assert.Equal(t, "assistant", resp.Completion.Role)
content := contentString(t, resp)
t.Logf("Turn 1 response: %s", content)
assert.Contains(t, strings.ToLower(content), "understood")
})
// ── Turn 2: continuation — reuses the session ──────────────────────
t.Run("turn2_continue_session", func(t *testing.T) {
ctx := agentcontext.New(
context.Background(),
&oauthtypes.AuthorizedInfo{TeamID: "test-team-e2e", UserID: "test-user-e2e"},
chatID,
)
resp := streamAndWait(t, agent, ctx,
"What was the secret code I told you? Reply with just the code.",
defaultTimeout,
)
require.NotNil(t, resp.Completion)
assert.Equal(t, "assistant", resp.Completion.Role)
content := contentString(t, resp)
t.Logf("Turn 2 response: %s", content)
assert.Contains(t, strings.ToLower(content), "pineapple-42",
"continuation should recall secret from turn 1")
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func streamAndWait(
t *testing.T,
agent caller.AgentCaller,
ctx *agentcontext.Context,
prompt string,
timeout time.Duration,
) *agentcontext.Response {
t.Helper()
messages := []agentcontext.Message{{Role: "user", Content: prompt}}
done := make(chan struct{})
var resp *agentcontext.Response
var streamErr error
go func() {
defer close(done)
resp, streamErr = agent.Stream(ctx, messages)
}()
select {
case <-done:
case <-time.After(timeout):
t.Fatalf("timeout after %v", timeout)
}
if streamErr != nil {
t.Logf("Stream error: %v", streamErr)
}
require.NoError(t, streamErr, "Stream should not return error")
require.NotNil(t, resp, "response should not be nil")
if resp.Completion != nil {
t.Logf("Completion: role=%s content=%v", resp.Completion.Role, resp.Completion.Content)
}
require.NotNil(t, ctx.Buffer, "ctx.Buffer should not be nil")
msgs := ctx.Buffer.GetMessages()
t.Logf("buffer message count: %d", len(msgs))
for _, m := range msgs {
t.Logf(" seq=%d role=%s type=%s streaming=%v",
m.Sequence, m.Role, m.Type, m.IsStreaming)
}
return resp
}
func contentString(t *testing.T, resp *agentcontext.Response) string {
t.Helper()
s, ok := resp.Completion.Content.(string)
require.True(t, ok, "Content should be string, got %T", resp.Completion.Content)
return s
}

View file

@ -0,0 +1,261 @@
package opencode
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
infra "github.com/yaoapp/yao/sandbox/v2"
)
// session encapsulates a single OpenCode CLI execution lifecycle:
// process start, stderr collection, kill on cancel, Wait with timeout.
type session struct {
ctx context.Context
computer infra.Computer
plat platform
exec *infra.ExecStream
stderr strings.Builder
stderrMu sync.Mutex
logger *agentContext.RequestLogger
chatID string
}
func startSession(ctx context.Context, computer infra.Computer, p platform, cmd command, chatID string, logger *agentContext.RequestLogger) (*session, error) {
opts := []infra.ExecOption{infra.WithWorkDir(cmd.workDir), infra.WithEnv(cmd.env)}
logger.Info("opencode session starting: cmd=%v workDir=%s platform=%s chatID=%s",
cmd.shell, cmd.workDir, p.OS(), chatID)
execStream, err := computer.Stream(ctx, cmd.shell, opts...)
if err != nil {
return nil, fmt.Errorf("computer.Stream: %w", err)
}
// Write user message to stdin, then close. OpenCode reads the prompt
// from stdin when no positional message is given (same as Claude runner).
// Closing after write signals EOF so OpenCode begins processing.
if execStream.Stdin != nil {
if cmd.stdin != "" {
if _, err := io.WriteString(execStream.Stdin, cmd.stdin); err != nil {
logger.Warn("failed to write stdin: %v", err)
}
}
execStream.Stdin.Close()
}
return &session{
ctx: ctx,
computer: computer,
plat: p,
exec: execStream,
logger: logger,
chatID: chatID,
}, nil
}
// runStream executes the main stream processing loop.
// Returns (completed, error) where completed=true means OpenCode CLI sent
// a step_finish with reason=stop and the stream finished normally.
func (s *session) runStream(handler message.StreamFunc) (completed bool, err error) {
s.collectStderr()
cleanup := s.watchCancel()
defer cleanup()
// Tee stdout to a debug log so we can inspect raw JSONL timing.
stdout := s.teeStdout()
parser := newStreamParser(handler)
parseErr := parser.parse(s.ctx, stdout)
s.logger.Debug("runStream: parse returned completed=%v parseErr=%v", parser.completed, parseErr)
if parser.completed {
s.logger.Info("opencode stream completed normally")
return true, nil
}
exitErr := s.waitForExit(parseErr)
if exitErr != nil {
if handler != nil {
handler(message.ChunkError, []byte(exitErr.Error()))
}
return false, exitErr
}
s.stderrMu.Lock()
stderrStr := strings.TrimSpace(s.stderr.String())
s.stderrMu.Unlock()
if stderrStr != "" {
s.logger.Warn("opencode exited with code 0 but stream incomplete and stderr present: %s", stderrStr)
errMsg := fmt.Errorf("opencode CLI setup failed: %s", stderrStr)
if handler != nil {
handler(message.ChunkError, []byte(errMsg.Error()))
}
return false, errMsg
}
return false, nil
}
// teeStdout wraps exec.Stdout with a TeeReader that writes a copy to a
// timestamped log file. Returns the original Stdout if tee setup fails.
func (s *session) teeStdout() io.ReadCloser {
logDir := os.Getenv("YAO_LOG_PATH")
if logDir == "" {
logDir = "/tmp"
}
logFile := filepath.Join(logDir, fmt.Sprintf("opencode-stream-%s-%d.jsonl", s.chatID, time.Now().Unix()))
f, err := os.Create(logFile)
if err != nil {
s.logger.Debug("teeStdout: cannot create %s: %v", logFile, err)
return s.exec.Stdout
}
s.logger.Info("teeStdout: raw JSONL -> %s", logFile)
tee := io.TeeReader(s.exec.Stdout, f)
return &teeReadCloser{Reader: tee, closers: []io.Closer{s.exec.Stdout, f}}
}
type teeReadCloser struct {
io.Reader
closers []io.Closer
}
func (t *teeReadCloser) Close() error {
var firstErr error
for _, c := range t.closers {
if err := c.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (s *session) collectStderr() {
go func() {
buf := make([]byte, 4096)
for {
n, err := s.exec.Stderr.Read(buf)
if n > 0 {
chunk := string(buf[:n])
s.stderrMu.Lock()
s.stderr.WriteString(chunk)
s.stderrMu.Unlock()
s.logger.Debug("opencode stderr: %s", chunk)
}
if err != nil {
return
}
}
}()
}
// killProcess terminates the OpenCode CLI process (Node.js).
func (s *session) killProcess(ctx context.Context) {
if s.chatID != "" {
name := sanitizeSessionName(s.chatID)
result, err := s.computer.Exec(ctx, s.plat.KillSessionCmd(name))
s.logger.Debug("killProcess: KillSessionCmd(%s) exitCode=%d err=%v", name, result.ExitCode, err)
return
}
// OpenCode is a Node.js process; match both "opencode" and "node.*opencode"
result, err := s.computer.Exec(ctx, s.plat.KillCmd("opencode"))
s.logger.Debug("killProcess: KillCmd(opencode) exitCode=%d err=%v", result.ExitCode, err)
}
func (s *session) watchCancel() func() {
done := make(chan struct{})
go func() {
select {
case <-s.ctx.Done():
s.logger.Info("context cancelled, killing opencode: %v", s.ctx.Err())
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.killProcess(killCtx)
s.exec.Cancel()
case <-done:
}
}()
return func() { close(done) }
}
// shutdown cleans up after normal stream completion.
//
// OpenCode exits cleanly after step_finish(stop), but we still need to
// release the Docker exec connection. Like Claude runner, we first kill
// only the opencode process with SIGKILL (which cannot be caught, so
// OpenCode has no chance to propagate signals to child processes), then
// close the exec connection. Children (browsers, servers, etc.) that were
// launched via nohup/setsid survive because they are in separate sessions.
func (s *session) shutdown() {
s.logger.Info("shutting down completed opencode exec session: chatID=%s", s.chatID)
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.killProcess(killCtx)
s.exec.Cancel()
}
func (s *session) waitForExit(parseErr error) error {
s.logger.Info("opencode stream did not complete normally, waiting for exit")
type waitResult struct {
exitCode int
err error
}
ch := make(chan waitResult, 1)
go func() {
code, err := s.exec.Wait()
ch <- waitResult{code, err}
}()
var exitCode int
var waitErr error
select {
case wr := <-ch:
exitCode, waitErr = wr.exitCode, wr.err
case <-s.ctx.Done():
select {
case wr := <-ch:
exitCode, waitErr = wr.exitCode, wr.err
case <-time.After(10 * time.Second):
s.exec.Cancel()
s.logger.Error("opencode did not exit after kill, timeout")
return fmt.Errorf("opencode did not exit after kill (timeout)")
}
}
s.stderrMu.Lock()
stderrStr := strings.TrimSpace(s.stderr.String())
s.stderrMu.Unlock()
if parseErr != nil {
if stderrStr != "" {
return fmt.Errorf("%w (stderr: %s)", parseErr, stderrStr)
}
return parseErr
}
if waitErr != nil {
if stderrStr != "" {
return fmt.Errorf("%w (stderr: %s)", waitErr, stderrStr)
}
return waitErr
}
if exitCode != 0 {
s.logger.Warn("opencode exited with non-zero code: exitCode=%d stderr=%s", exitCode, stderrStr)
if stderrStr != "" {
return fmt.Errorf("opencode CLI exited with code %d: %s", exitCode, stderrStr)
}
return fmt.Errorf("opencode CLI exited with code %d", exitCode)
}
return nil
}

View file

@ -166,12 +166,18 @@ func runExecStep(ctx context.Context, computer infra.Computer, step types.Prepar
}
}
rootDir := "/"
workDir := computer.GetWorkDir()
if workDir == "" {
workDir = "/"
if isWindowsComputer(computer) {
rootDir = `C:\`
workDir = `C:\`
}
}
result, err := computer.Exec(ctx, shellWrap(kind, script), infra.WithWorkDir(rootDir))
result, err := computer.Exec(ctx, shellWrap(kind, script),
infra.WithWorkDir(workDir),
infra.WithEnv(map[string]string{"HOME": workDir}),
)
if err != nil {
return err
}

View file

@ -0,0 +1,251 @@
package shared
import (
"context"
"fmt"
"path/filepath"
"strings"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/attachment"
workspace "github.com/yaoapp/yao/tai/workspace"
)
// AttachmentResult holds the resolved attachment info after copying to workspace.
type AttachmentResult struct {
Path string // workspace-relative path (e.g. ".attachments/{chatID}/image.png")
ContentType string
Filename string
Bytes int
}
// PrepareAttachments resolves __yao.attachment:// URLs in user messages,
// copies actual files into the workspace .attachments/{chatID}/ directory,
// and returns processed messages plus a list of resolved file paths.
//
// The returned messages have multimodal content replaced with text references
// (for runners like Claude that need text-only). Callers that need the raw
// file paths (like OpenCode's --file) can use the returned []AttachmentResult.
func PrepareAttachments(ctx context.Context, messages []agentContext.Message, chatID string, ws workspace.FS) ([]agentContext.Message, []AttachmentResult, error) {
usedNames := make(map[string]int)
attachDir := ".attachments/" + chatID
var resolved []AttachmentResult
result := make([]agentContext.Message, len(messages))
copy(result, messages)
for i, msg := range result {
if msg.Role != "user" {
continue
}
parts, ok := msg.Content.([]interface{})
if !ok {
if typedParts, ok := msg.Content.([]agentContext.ContentPart); ok {
iparts := make([]interface{}, len(typedParts))
for j, p := range typedParts {
m := map[string]interface{}{"type": string(p.Type)}
if p.Text != "" {
m["text"] = p.Text
}
if p.ImageURL != nil {
m["image_url"] = map[string]interface{}{
"url": p.ImageURL.URL,
"detail": string(p.ImageURL.Detail),
}
}
if p.File != nil {
m["file"] = map[string]interface{}{
"url": p.File.URL,
"filename": p.File.Filename,
}
}
iparts[j] = m
}
parts = iparts
} else {
continue
}
}
if len(parts) == 0 {
continue
}
var textParts []string
for _, item := range parts {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
partType, _ := m["type"].(string)
switch partType {
case "text":
if text, ok := m["text"].(string); ok && text != "" {
textParts = append(textParts, text)
}
case "image_url":
imgData, _ := m["image_url"].(map[string]interface{})
if imgData == nil {
continue
}
url, _ := imgData["url"].(string)
if url == "" {
continue
}
uploaderName, fileID, isWrapper := attachment.Parse(url)
if !isWrapper {
textParts = append(textParts, fmt.Sprintf("[Image: %s]", url))
continue
}
ar, ref, err := resolveAttachment(ctx, uploaderName, fileID, "", attachDir, usedNames, ws)
if err != nil {
textParts = append(textParts, "[Attached image: failed to load]")
continue
}
resolved = append(resolved, *ar)
textParts = append(textParts, ref)
case "file":
fileData, _ := m["file"].(map[string]interface{})
if fileData == nil {
continue
}
url, _ := fileData["url"].(string)
hintName, _ := fileData["filename"].(string)
if url == "" {
continue
}
uploaderName, fileID, isWrapper := attachment.Parse(url)
if !isWrapper {
textParts = append(textParts, fmt.Sprintf("[File: %s]", url))
continue
}
ar, ref, err := resolveAttachment(ctx, uploaderName, fileID, hintName, attachDir, usedNames, ws)
if err != nil {
textParts = append(textParts, "[Attached file: failed to load]")
continue
}
resolved = append(resolved, *ar)
textParts = append(textParts, ref)
}
}
if len(textParts) > 0 {
newMsg := result[i]
newMsg.Content = strings.Join(textParts, "\n\n")
result[i] = newMsg
}
}
return result, resolved, nil
}
// resolveAttachment gets the local path of an attachment and copies it into
// the workspace via ws.Copy("local:///abs/path", ".attachments/{chatID}/filename").
func resolveAttachment(
ctx context.Context,
uploaderName, fileID, hintName, attachDir string,
usedNames map[string]int,
ws workspace.FS,
) (*AttachmentResult, string, error) {
manager, exists := attachment.Managers[uploaderName]
if !exists {
return nil, "", fmt.Errorf("attachment manager not found: %s", uploaderName)
}
fileInfo, err := manager.Info(ctx, fileID)
if err != nil {
return nil, "", fmt.Errorf("failed to get file info: %w", err)
}
absPath, _, err := manager.LocalPath(ctx, fileID)
if err != nil {
return nil, "", fmt.Errorf("failed to get local path: %w", err)
}
filename := fileInfo.Filename
if filename == "" && hintName != "" {
filename = hintName
}
if filename == "" {
ext := ExtensionFromContentType(fileInfo.ContentType)
filename = fileID + ext
}
baseName := filename
if count, exists := usedNames[baseName]; exists {
ext := filepath.Ext(filename)
name := strings.TrimSuffix(filename, ext)
filename = fmt.Sprintf("%s_%d%s", name, count+1, ext)
usedNames[baseName] = count + 1
} else {
usedNames[baseName] = 0
}
dstPath := attachDir + "/" + filename
src := "local:///" + absPath
if _, err := ws.Copy(src, dstPath); err != nil {
return nil, "", fmt.Errorf("failed to copy attachment to workspace: %w", err)
}
sizeStr := FormatFileSize(fileInfo.Bytes)
ref := fmt.Sprintf("[Attached file: %s (%s, %s)]", dstPath, fileInfo.ContentType, sizeStr)
ar := &AttachmentResult{
Path: dstPath,
ContentType: fileInfo.ContentType,
Filename: filename,
Bytes: fileInfo.Bytes,
}
return ar, ref, nil
}
// ExtensionFromContentType maps common MIME types to file extensions.
func ExtensionFromContentType(contentType string) string {
switch contentType {
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "image/svg+xml":
return ".svg"
case "application/pdf":
return ".pdf"
case "text/plain":
return ".txt"
case "text/html":
return ".html"
case "text/css":
return ".css"
case "text/javascript", "application/javascript":
return ".js"
case "application/json":
return ".json"
case "application/zip":
return ".zip"
default:
return ""
}
}
// FormatFileSize returns a human-readable file size string.
func FormatFileSize(bytes int) string {
switch {
case bytes >= 1024*1024:
return fmt.Sprintf("%.1fMB", float64(bytes)/(1024*1024))
case bytes >= 1024:
return fmt.Sprintf("%.1fKB", float64(bytes)/1024)
default:
return fmt.Sprintf("%dB", bytes)
}
}

View file

@ -166,7 +166,7 @@ func TestRunnerWithFailAndSkip(t *testing.T) {
assert.Equal(t, "passed", r.Status)
case "TestRuntimeError":
assert.Equal(t, "error", r.Status)
assert.Contains(t, r.Error, "deliberate runtime error")
assert.Contains(t, r.Error, "deliberate execution error")
}
}
}