Merge pull request #1527 from trheyi/main
feat(sandbox): add opencode runner and enhance attachment processing
This commit is contained in:
commit
72d7ed6f80
18 changed files with 3895 additions and 216 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
|
|
|
|||
405
agent/sandbox/v2/opencode/command.go
Normal file
405
agent/sandbox/v2/opencode/command.go
Normal 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)
|
||||
}
|
||||
145
agent/sandbox/v2/opencode/command_test.go
Normal file
145
agent/sandbox/v2/opencode/command_test.go
Normal 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
|
||||
}
|
||||
364
agent/sandbox/v2/opencode/config.go
Normal file
364
agent/sandbox/v2/opencode/config.go
Normal 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
|
||||
}
|
||||
125
agent/sandbox/v2/opencode/config_test.go
Normal file
125
agent/sandbox/v2/opencode/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
533
agent/sandbox/v2/opencode/parse.go
Normal file
533
agent/sandbox/v2/opencode/parse.go
Normal 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
|
||||
}
|
||||
528
agent/sandbox/v2/opencode/parse_test.go
Normal file
528
agent/sandbox/v2/opencode/parse_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
76
agent/sandbox/v2/opencode/plat_win.go
Normal file
76
agent/sandbox/v2/opencode/plat_win.go
Normal 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)
|
||||
}
|
||||
66
agent/sandbox/v2/opencode/platform.go
Normal file
66
agent/sandbox/v2/opencode/platform.go
Normal 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
|
||||
}
|
||||
220
agent/sandbox/v2/opencode/platform_test.go
Normal file
220
agent/sandbox/v2/opencode/platform_test.go
Normal 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")
|
||||
}
|
||||
493
agent/sandbox/v2/opencode/roles_test.go
Normal file
493
agent/sandbox/v2/opencode/roles_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
246
agent/sandbox/v2/opencode/runner.go
Normal file
246
agent/sandbox/v2/opencode/runner.go
Normal 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`
|
||||
}
|
||||
162
agent/sandbox/v2/opencode/runner_test.go
Normal file
162
agent/sandbox/v2/opencode/runner_test.go
Normal 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
|
||||
}
|
||||
261
agent/sandbox/v2/opencode/session.go
Normal file
261
agent/sandbox/v2/opencode/session.go
Normal 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
|
||||
}
|
||||
|
|
@ -166,12 +166,18 @@ func runExecStep(ctx context.Context, computer infra.Computer, step types.Prepar
|
|||
}
|
||||
}
|
||||
|
||||
rootDir := "/"
|
||||
if isWindowsComputer(computer) {
|
||||
rootDir = `C:\`
|
||||
workDir := computer.GetWorkDir()
|
||||
if workDir == "" {
|
||||
workDir = "/"
|
||||
if isWindowsComputer(computer) {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
251
agent/sandbox/v2/shared/attachments.go
Normal file
251
agent/sandbox/v2/shared/attachments.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue