Merge pull request #1500 from trheyi/main
feat(sandbox): enhance workspace lifecycle, connector config and CLI execute handling
This commit is contained in:
commit
e41cb3f4a5
29 changed files with 3341 additions and 1017 deletions
|
|
@ -183,6 +183,17 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
}
|
}
|
||||||
sandboxCleanup = v2Cleanup
|
sandboxCleanup = v2Cleanup
|
||||||
ctx.Logger.PhaseComplete("Sandbox V2")
|
ctx.Logger.PhaseComplete("Sandbox V2")
|
||||||
|
if v2Computer != nil {
|
||||||
|
ci := v2Computer.ComputerInfo()
|
||||||
|
ctx.Logger.Trace("Node: %s (%s)", ci.NodeID, ci.Kind)
|
||||||
|
if ci.BoxID != "" {
|
||||||
|
ctx.Logger.Trace("Computer: %s", ci.BoxID)
|
||||||
|
}
|
||||||
|
ctx.Logger.Trace("Workspace: %s", ast.SandboxV2.WorkspaceID)
|
||||||
|
if conn, _, err := ast.GetConnector(ctx, opts); err == nil && conn != nil {
|
||||||
|
ctx.Logger.Trace("Connector: %s", conn.ID())
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if ast.HasSandbox() {
|
} else if ast.HasSandbox() {
|
||||||
ctx.Logger.Phase("Sandbox")
|
ctx.Logger.Phase("Sandbox")
|
||||||
var err error
|
var err error
|
||||||
|
|
@ -320,7 +331,15 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
// Choose between sandbox execution or direct LLM execution
|
// Choose between sandbox execution or direct LLM execution
|
||||||
if ast.HasSandboxV2() && v2Runner != nil && v2Computer != nil && v2Runner.Name() != "yao" {
|
if ast.HasSandboxV2() && v2Runner != nil && v2Computer != nil && v2Runner.Name() != "yao" {
|
||||||
// V2 Sandbox execution path (non-yao runners replace LLM.Stream)
|
// V2 Sandbox execution path (non-yao runners replace LLM.Stream)
|
||||||
completionResponse, err = ast.executeSandboxV2Stream(ctx, completionMessages, agentNode, streamHandler, v2Runner, v2Computer, v2LoadingMsgID)
|
completionResponse, err = ast.executeSandboxV2Stream(ctx, &sandboxV2StreamParams{
|
||||||
|
Messages: completionMessages,
|
||||||
|
AgentNode: agentNode,
|
||||||
|
Handler: streamHandler,
|
||||||
|
Runner: v2Runner,
|
||||||
|
Computer: v2Computer,
|
||||||
|
LoadingMsgID: v2LoadingMsgID,
|
||||||
|
Options: opts,
|
||||||
|
})
|
||||||
} else if ast.HasSandboxV2() && v2Runner != nil && v2Runner.Name() == "yao" {
|
} else if ast.HasSandboxV2() && v2Runner != nil && v2Runner.Name() == "yao" {
|
||||||
// V2 yao runner: Prepare is done, close loading, fall through to LLM
|
// V2 yao runner: Prepare is done, close loading, fall through to LLM
|
||||||
if v2LoadingMsgID != "" {
|
if v2LoadingMsgID != "" {
|
||||||
|
|
@ -690,12 +709,6 @@ func (ast *Assistant) sendAgentStreamEnd(ctx *context.Context, handler message.S
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if context is cancelled - if so, skip handler call to avoid blocking
|
|
||||||
if ctx.Context != nil && ctx.Context.Err() != nil {
|
|
||||||
ctx.Logger.Debug("Context cancelled, skipping sendAgentStreamEnd handler call")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
endData := &message.EventStreamEndData{
|
endData := &message.EventStreamEndData{
|
||||||
RequestID: ctx.RequestID(),
|
RequestID: ctx.RequestID(),
|
||||||
ContextID: ctx.ID,
|
ContextID: ctx.ID,
|
||||||
|
|
@ -727,25 +740,17 @@ func (ast *Assistant) sendStreamEndOnError(ctx *context.Context, handler message
|
||||||
// handleInterrupt handles the interrupt signal
|
// handleInterrupt handles the interrupt signal
|
||||||
// This is called by the interrupt listener when a signal is received
|
// This is called by the interrupt listener when a signal is received
|
||||||
func (ast *Assistant) handleInterrupt(ctx *context.Context, signal *context.InterruptSignal) error {
|
func (ast *Assistant) handleInterrupt(ctx *context.Context, signal *context.InterruptSignal) error {
|
||||||
// Handle based on interrupt type
|
|
||||||
switch signal.Type {
|
switch signal.Type {
|
||||||
case context.InterruptForce:
|
case context.InterruptForce:
|
||||||
// Force interrupt: context is already cancelled in handleSignal
|
ctx.Logger.Debug("Force interrupt received")
|
||||||
// LLM streaming will detect ctx.Interrupt.Context().Done() and stop
|
if ctx.Buffer != nil {
|
||||||
ctx.Logger.Debug("Force interrupt: stopping current operations immediately")
|
ctx.Buffer.FailCurrentStep(context.ResumeStatusInterrupted,
|
||||||
|
fmt.Errorf("interrupted by user"))
|
||||||
|
}
|
||||||
case context.InterruptGraceful:
|
case context.InterruptGraceful:
|
||||||
ctx.Logger.Debug("Graceful interrupt: will process after current step completes")
|
ctx.Logger.Debug("Graceful interrupt received: messages=%d", len(signal.Messages))
|
||||||
// Graceful interrupt: let current operation complete
|
|
||||||
// The signal is stored in current/pending, can be checked at checkpoints
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement actual interrupt handling logic:
|
|
||||||
// 1. For graceful: wait for current step, then merge messages and restart
|
|
||||||
// 2. For force: immediately stop and restart with new messages
|
|
||||||
// 3. Call Interrupted Hook if configured
|
|
||||||
// 4. Decide whether to continue, restart, or abort based on Hook response
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ func DefaultStreamHandler(ctx *context.Context) message.StreamFunc {
|
||||||
case message.ChunkToolCall:
|
case message.ChunkToolCall:
|
||||||
return state.handleToolCall(data)
|
return state.handleToolCall(data)
|
||||||
|
|
||||||
|
case message.ChunkExecute:
|
||||||
|
return state.handleExecute(data)
|
||||||
|
|
||||||
case message.ChunkMetadata:
|
case message.ChunkMetadata:
|
||||||
return state.handleMetadata(data)
|
return state.handleMetadata(data)
|
||||||
|
|
||||||
|
|
@ -72,9 +75,11 @@ type streamState struct {
|
||||||
currentGroupID string // Current group ID (shared by all chunks in the group)
|
currentGroupID string // Current group ID (shared by all chunks in the group)
|
||||||
currentType string // Track the current message type (text, thinking, tool_call)
|
currentType string // Track the current message type (text, thinking, tool_call)
|
||||||
buffer []byte
|
buffer []byte
|
||||||
chunkCount int // Track number of chunks in current group
|
chunkCount int // Track number of chunks in current group
|
||||||
messageSeq int // Message sequence number (for generating readable IDs)
|
messageSeq int // Message sequence number (for generating readable IDs)
|
||||||
groupStartTime time.Time // Track when group started
|
groupStartTime time.Time // Track when group started
|
||||||
|
lastExecStatus string // Last observed execute status in current group ("running", "completed", "error")
|
||||||
|
lastExecProps map[string]interface{} // Accumulated execute props for the current group (merged across chunks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleStreamStart handles stream start event
|
// handleStreamStart handles stream start event
|
||||||
|
|
@ -290,11 +295,75 @@ func (s *streamState) handleToolCall(data []byte) int {
|
||||||
return 0 // Continue
|
return 0 // Continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMetadata handles metadata chunks (usage, finish_reason, etc.)
|
// handleExecute handles execute observation chunks from sandbox CLI agents.
|
||||||
|
// These represent tool actions observed inside the agent runtime (e.g., Bash, Read, Write).
|
||||||
|
func (s *streamState) handleExecute(data []byte) int {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
s.currentType = message.TypeExecute
|
||||||
|
s.buffer = append(s.buffer, data...)
|
||||||
|
s.chunkCount++
|
||||||
|
s.messageSeq++
|
||||||
|
|
||||||
|
var props map[string]interface{}
|
||||||
|
if err := jsoniter.Unmarshal(data, &props); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if st, ok := props["status"].(string); ok {
|
||||||
|
s.lastExecStatus = st
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.lastExecProps == nil {
|
||||||
|
s.lastExecProps = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
for k, v := range props {
|
||||||
|
s.lastExecProps[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
deltaAction := "merge"
|
||||||
|
|
||||||
|
msg := &message.Message{
|
||||||
|
ChunkID: s.ctx.IDGenerator.GenerateChunkID(),
|
||||||
|
MessageID: s.currentGroupID,
|
||||||
|
Type: message.TypeExecute,
|
||||||
|
Delta: true,
|
||||||
|
DeltaAction: deltaAction,
|
||||||
|
Props: props,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ctx.Send(msg); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMetadata handles metadata chunks (usage, finish_reason, result_summary, etc.)
|
||||||
|
// For sandbox CLI agents, this carries token usage and result summaries.
|
||||||
func (s *streamState) handleMetadata(data []byte) int {
|
func (s *streamState) handleMetadata(data []byte) int {
|
||||||
// Metadata is usually not displayed to users
|
if len(data) == 0 {
|
||||||
// Could be logged or stored for analytics
|
return 0
|
||||||
return 0 // Continue
|
}
|
||||||
|
|
||||||
|
var meta map[string]interface{}
|
||||||
|
if err := jsoniter.Unmarshal(data, &meta); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage, ok := meta["usage"]; ok {
|
||||||
|
msg := output.NewEventMessage("token/usage", "", usage)
|
||||||
|
s.ctx.Send(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary, ok := meta["result_summary"]; ok {
|
||||||
|
msg := output.NewEventMessage("result/summary", "", summary)
|
||||||
|
s.ctx.Send(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleError handles error chunks
|
// handleError handles error chunks
|
||||||
|
|
@ -341,16 +410,20 @@ func (s *streamState) handleMessageEnd(data []byte) int {
|
||||||
shouldSkipHistory := s.ctx.Stack != nil && s.ctx.Stack.Options != nil &&
|
shouldSkipHistory := s.ctx.Stack != nil && s.ctx.Stack.Options != nil &&
|
||||||
s.ctx.Stack.Options.Skip != nil && s.ctx.Stack.Options.Skip.History
|
s.ctx.Stack.Options.Skip != nil && s.ctx.Stack.Options.Skip.History
|
||||||
|
|
||||||
if s.ctx.Buffer != nil && len(s.buffer) > 0 && !shouldSkipHistory {
|
// Execute messages have two phases sharing the same message_id:
|
||||||
|
// 1. running — streamed for UI display only, NOT persisted
|
||||||
|
// 2. completed / error — the final state, persisted to the buffer
|
||||||
|
isExecuteRunning := msgType == message.TypeExecute && s.lastExecStatus == "running"
|
||||||
|
|
||||||
|
if s.ctx.Buffer != nil && len(s.buffer) > 0 && !shouldSkipHistory && !isExecuteRunning {
|
||||||
assistantID := ""
|
assistantID := ""
|
||||||
if s.ctx.Stack != nil {
|
if s.ctx.Stack != nil {
|
||||||
assistantID = s.ctx.Stack.AssistantID
|
assistantID = s.ctx.Stack.AssistantID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build props based on message type
|
|
||||||
var props map[string]interface{}
|
var props map[string]interface{}
|
||||||
if msgType == message.TypeToolCall {
|
switch msgType {
|
||||||
// For tool calls, try to parse the accumulated buffer as JSON
|
case message.TypeToolCall:
|
||||||
var toolCallData interface{}
|
var toolCallData interface{}
|
||||||
if err := jsoniter.Unmarshal(s.buffer, &toolCallData); err == nil {
|
if err := jsoniter.Unmarshal(s.buffer, &toolCallData); err == nil {
|
||||||
props = map[string]interface{}{
|
props = map[string]interface{}{
|
||||||
|
|
@ -361,15 +434,25 @@ func (s *streamState) handleMessageEnd(data []byte) int {
|
||||||
"content": string(s.buffer),
|
"content": string(s.buffer),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
case message.TypeExecute:
|
||||||
// For text/thinking, content is the accumulated text
|
if s.lastExecProps != nil {
|
||||||
|
props = make(map[string]interface{}, len(s.lastExecProps))
|
||||||
|
for k, v := range s.lastExecProps {
|
||||||
|
props[k] = v
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
props = map[string]interface{}{
|
||||||
|
"content": string(s.buffer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
props = map[string]interface{}{
|
props = map[string]interface{}{
|
||||||
"content": string(s.buffer),
|
"content": string(s.buffer),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.ctx.Buffer.AddAssistantMessage(
|
s.ctx.Buffer.AddAssistantMessage(
|
||||||
s.currentGroupID, // Use the message ID
|
s.currentGroupID,
|
||||||
msgType,
|
msgType,
|
||||||
props,
|
props,
|
||||||
blockID,
|
blockID,
|
||||||
|
|
@ -403,6 +486,8 @@ func (s *streamState) handleMessageEnd(data []byte) int {
|
||||||
s.currentType = ""
|
s.currentType = ""
|
||||||
s.buffer = []byte{}
|
s.buffer = []byte{}
|
||||||
s.chunkCount = 0
|
s.chunkCount = 0
|
||||||
|
s.lastExecStatus = ""
|
||||||
|
s.lastExecProps = nil
|
||||||
|
|
||||||
return 0 // Continue
|
return 0 // Continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package assistant
|
package assistant
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
stdContext "context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
|
@ -77,9 +79,9 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Obtain Computer (passes connector for OPENAI_PROXY_* env injection).
|
// 3. Obtain Computer.
|
||||||
updateLoadingV2(ctx, loadingMsgID, "sandbox.starting")
|
updateLoadingV2(ctx, loadingMsgID, "sandbox.starting")
|
||||||
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager, conn)
|
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
|
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
|
||||||
return nil, nil, nil, "", fmt.Errorf("getComputer failed: %w", err)
|
return nil, nil, nil, "", fmt.Errorf("getComputer failed: %w", err)
|
||||||
|
|
@ -140,25 +142,32 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
|
||||||
ctx.SetComputer(computer)
|
ctx.SetComputer(computer)
|
||||||
|
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
// Defensive fallback — executeSandboxV2Stream defer handles the
|
cleanCtx, cancel := stdContext.WithTimeout(stdContext.Background(), 5*time.Second)
|
||||||
// normal case; this covers paths that never reach execution.
|
defer cancel()
|
||||||
|
runner.Cleanup(cleanCtx, computer)
|
||||||
|
sandboxv2.LifecycleAction(cleanCtx, cfg, computer, manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
return runner, computer, cleanup, loadingMsgID, nil
|
return runner, computer, cleanup, loadingMsgID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sandboxV2StreamParams groups arguments for executeSandboxV2Stream.
|
||||||
|
type sandboxV2StreamParams struct {
|
||||||
|
Messages []context.Message
|
||||||
|
AgentNode traceTypes.Node
|
||||||
|
Handler message.StreamFunc
|
||||||
|
Runner sandboxTypes.Runner
|
||||||
|
Computer infraV2.Computer
|
||||||
|
LoadingMsgID string
|
||||||
|
Options *context.Options
|
||||||
|
}
|
||||||
|
|
||||||
// executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the
|
// executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the
|
||||||
// standard completion response.
|
// standard completion response.
|
||||||
func (ast *Assistant) executeSandboxV2Stream(
|
func (ast *Assistant) executeSandboxV2Stream(
|
||||||
ctx *context.Context,
|
ctx *context.Context, p *sandboxV2StreamParams,
|
||||||
completionMessages []context.Message,
|
|
||||||
agentNode traceTypes.Node,
|
|
||||||
streamHandler message.StreamFunc,
|
|
||||||
runner sandboxTypes.Runner,
|
|
||||||
computer infraV2.Computer,
|
|
||||||
loadingMsgID string,
|
|
||||||
) (*context.CompletionResponse, error) {
|
) (*context.CompletionResponse, error) {
|
||||||
_ = agentNode
|
_ = p.AgentNode
|
||||||
|
|
||||||
cfg := ast.SandboxV2
|
cfg := ast.SandboxV2
|
||||||
manager := infraV2.M()
|
manager := infraV2.M()
|
||||||
|
|
@ -166,16 +175,16 @@ func (ast *Assistant) executeSandboxV2Stream(
|
||||||
// Build system prompt.
|
// Build system prompt.
|
||||||
var systemPrompt string
|
var systemPrompt string
|
||||||
if len(ast.Prompts) > 0 {
|
if len(ast.Prompts) > 0 {
|
||||||
for _, p := range ast.Prompts {
|
for _, pr := range ast.Prompts {
|
||||||
if p.Role == "system" && p.Content != "" {
|
if pr.Role == "system" && pr.Content != "" {
|
||||||
systemPrompt = p.Content
|
systemPrompt = pr.Content
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve connector for Stream.
|
// Resolve connector for Stream (respects user-selected connector via opts).
|
||||||
conn, _, _ := ast.GetConnector(ctx)
|
conn, _, _ := ast.GetConnector(ctx, p.Options)
|
||||||
|
|
||||||
var tok *sandboxTypes.SandboxToken
|
var tok *sandboxTypes.SandboxToken
|
||||||
if ctx.Authorized != nil {
|
if ctx.Authorized != nil {
|
||||||
|
|
@ -187,25 +196,26 @@ func (ast *Assistant) executeSandboxV2Stream(
|
||||||
}
|
}
|
||||||
|
|
||||||
streamReq := &sandboxTypes.StreamRequest{
|
streamReq := &sandboxTypes.StreamRequest{
|
||||||
Computer: computer,
|
Computer: p.Computer,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Connector: conn,
|
Connector: conn,
|
||||||
Messages: completionMessages,
|
Messages: p.Messages,
|
||||||
SystemPrompt: systemPrompt,
|
SystemPrompt: systemPrompt,
|
||||||
ChatID: ctx.ChatID,
|
ChatID: ctx.ChatID,
|
||||||
Token: tok,
|
Token: tok,
|
||||||
|
Logger: ctx.Logger,
|
||||||
}
|
}
|
||||||
|
|
||||||
execReq := &sandboxv2.ExecuteRequest{
|
execReq := &sandboxv2.ExecuteRequest{
|
||||||
Computer: computer,
|
Computer: p.Computer,
|
||||||
Runner: runner,
|
Runner: p.Runner,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
StreamReq: streamReq,
|
StreamReq: streamReq,
|
||||||
Manager: manager,
|
Manager: manager,
|
||||||
LoadingMsgID: loadingMsgID,
|
LoadingMsgID: p.LoadingMsgID,
|
||||||
}
|
}
|
||||||
|
|
||||||
return sandboxv2.ExecuteSandboxStream(ctx, execReq, streamHandler)
|
return sandboxv2.ExecuteSandboxStream(ctx, execReq, p.Handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// initStandaloneWorkspace loads the workspace FS into context when no sandbox
|
// initStandaloneWorkspace loads the workspace FS into context when no sandbox
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,11 @@ func WithParentID(parentID string) LoggerOption {
|
||||||
// noopLogger is a shared no-op logger instance
|
// noopLogger is a shared no-op logger instance
|
||||||
var noopLogger = &RequestLogger{noop: true}
|
var noopLogger = &RequestLogger{noop: true}
|
||||||
|
|
||||||
|
// NoopLogger returns a shared no-op RequestLogger that silently discards all
|
||||||
|
// log calls. Use when a non-nil logger is required but no actual logging is
|
||||||
|
// desired (e.g., fallback when StreamRequest.Logger is nil).
|
||||||
|
func NoopLogger() *RequestLogger { return noopLogger }
|
||||||
|
|
||||||
// NewRequestLogger creates a new request-scoped logger with async processing
|
// NewRequestLogger creates a new request-scoped logger with async processing
|
||||||
func NewRequestLogger(assistantID, chatID, requestID string, opts ...LoggerOption) *RequestLogger {
|
func NewRequestLogger(assistantID, chatID, requestID string, opts ...LoggerOption) *RequestLogger {
|
||||||
l := &RequestLogger{
|
l := &RequestLogger{
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,27 @@ func NewToolCallMessage(id, name, arguments string) *message.Message {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewExecuteMessage creates an execute observation message for sandbox CLI agent actions.
|
||||||
|
//
|
||||||
|
// tool: the tool name (e.g., "Bash", "Read", "Write")
|
||||||
|
// toolID: the agent-side tool call ID
|
||||||
|
// input: the tool input (structured, may be nil for status-only updates)
|
||||||
|
// status: "running" | "completed" | "error"
|
||||||
|
func NewExecuteMessage(tool, toolID string, input interface{}, status string) *message.Message {
|
||||||
|
props := map[string]interface{}{
|
||||||
|
"tool": tool,
|
||||||
|
"tool_id": toolID,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
if input != nil {
|
||||||
|
props["input"] = input
|
||||||
|
}
|
||||||
|
return &message.Message{
|
||||||
|
Type: message.TypeExecute,
|
||||||
|
Props: props,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewErrorMessage creates an error message
|
// NewErrorMessage creates an error message
|
||||||
func NewErrorMessage(msg, code string) *message.Message {
|
func NewErrorMessage(msg, code string) *message.Message {
|
||||||
return &message.Message{
|
return &message.Message{
|
||||||
|
|
@ -135,7 +156,7 @@ func NewVideoMessage(url string) *message.Message {
|
||||||
// IsBuiltinType checks if a message type is a built-in type
|
// IsBuiltinType checks if a message type is a built-in type
|
||||||
func IsBuiltinType(msgType string) bool {
|
func IsBuiltinType(msgType string) bool {
|
||||||
switch msgType {
|
switch msgType {
|
||||||
case message.TypeUserInput, message.TypeText, message.TypeThinking, message.TypeLoading, message.TypeToolCall, message.TypeError, message.TypeImage, message.TypeAudio, message.TypeVideo, message.TypeAction, message.TypeEvent:
|
case message.TypeUserInput, message.TypeText, message.TypeThinking, message.TypeLoading, message.TypeToolCall, message.TypeExecute, message.TypeError, message.TypeImage, message.TypeAudio, message.TypeVideo, message.TypeAction, message.TypeEvent:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@ const (
|
||||||
TypeAudio = "audio" // Audio content
|
TypeAudio = "audio" // Audio content
|
||||||
TypeVideo = "video" // Video content
|
TypeVideo = "video" // Video content
|
||||||
|
|
||||||
|
// Agent execution observation types
|
||||||
|
TypeExecute = "execute" // Agent tool execution observation (sandbox CLI agent actions, not LLM tool_call requests)
|
||||||
|
|
||||||
// System types (not visible in standard chat clients)
|
// System types (not visible in standard chat clients)
|
||||||
TypeAction = "action" // System action (open panel, navigate, etc.) - silent in OpenAI clients
|
TypeAction = "action" // System action (open panel, navigate, etc.) - silent in OpenAI clients
|
||||||
TypeEvent = "event" // Lifecycle event (stream_start, stream_end, etc.) - CUI only, silent in OpenAI clients
|
TypeEvent = "event" // Lifecycle event (stream_start, stream_end, etc.) - CUI only, silent in OpenAI clients
|
||||||
|
|
@ -205,6 +208,34 @@ type VideoProps struct {
|
||||||
Loop bool `json:"loop,omitempty"` // Whether to loop
|
Loop bool `json:"loop,omitempty"` // Whether to loop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecuteProps defines the standard structure for execute messages.
|
||||||
|
// Type: "execute"
|
||||||
|
//
|
||||||
|
// Represents an autonomous action taken by an external Agent (e.g., Claude CLI
|
||||||
|
// in a sandbox container, Codex CLI, or any future CLI-based Agent). Unlike
|
||||||
|
// tool_call (which is a call request that Yao dispatches), execute is an
|
||||||
|
// observation of an action that already happened inside the Agent's runtime.
|
||||||
|
//
|
||||||
|
// Lifecycle (via delta merge on the same MessageID):
|
||||||
|
//
|
||||||
|
// 1. message_start {type: "execute"}
|
||||||
|
// 2. ChunkExecute {tool, tool_id, input, status:"running"} — merge
|
||||||
|
// 3. ChunkExecute {tool_id, output, status:"completed"} — merge
|
||||||
|
// 4. message_end
|
||||||
|
type ExecuteProps struct {
|
||||||
|
Tool string `json:"tool"` // Tool name (e.g., "Bash", "Read", "Write", "mcp__github__search")
|
||||||
|
ToolID string `json:"tool_id"` // Agent-side tool call ID (e.g., "toolu_abc123")
|
||||||
|
Input interface{} `json:"input,omitempty"` // Tool input (structured, e.g., {"command":"ls -la"})
|
||||||
|
Output interface{} `json:"output,omitempty"` // Tool execution result (via delta merge)
|
||||||
|
Status string `json:"status"` // "running" | "completed" | "error"
|
||||||
|
|
||||||
|
IsError bool `json:"is_error,omitempty"` // Whether the tool execution failed
|
||||||
|
ExitCode *int `json:"exit_code,omitempty"` // Process exit code (Bash-type tools)
|
||||||
|
|
||||||
|
Runner string `json:"runner,omitempty"` // Runner identifier (e.g., "claude-cli", "codex-cli")
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"` // Extensible metadata per runner
|
||||||
|
}
|
||||||
|
|
||||||
// Delta action constants for incremental updates
|
// Delta action constants for incremental updates
|
||||||
const (
|
const (
|
||||||
DeltaAppend = "append" // Append (for arrays, strings)
|
DeltaAppend = "append" // Append (for arrays, strings)
|
||||||
|
|
@ -224,6 +255,7 @@ const (
|
||||||
ChunkToolCall StreamChunkType = "tool_call" // Tool/function call
|
ChunkToolCall StreamChunkType = "tool_call" // Tool/function call
|
||||||
ChunkRefusal StreamChunkType = "refusal" // Model refusal
|
ChunkRefusal StreamChunkType = "refusal" // Model refusal
|
||||||
ChunkMetadata StreamChunkType = "metadata" // Metadata (usage, finish_reason, etc.)
|
ChunkMetadata StreamChunkType = "metadata" // Metadata (usage, finish_reason, etc.)
|
||||||
|
ChunkExecute StreamChunkType = "execute" // Agent execution observation (sandbox CLI agent tool use)
|
||||||
ChunkError StreamChunkType = "error" // Error chunk
|
ChunkError StreamChunkType = "error" // Error chunk
|
||||||
ChunkUnknown StreamChunkType = "unknown" // Unknown/unrecognized chunk type
|
ChunkUnknown StreamChunkType = "unknown" // Unknown/unrecognized chunk type
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
kunlog "github.com/yaoapp/kun/log"
|
||||||
tgapi "github.com/yaoapp/yao/integrations/telegram"
|
tgapi "github.com/yaoapp/yao/integrations/telegram"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -34,7 +35,7 @@ func (a *Adapter) pollLoop() {
|
||||||
|
|
||||||
func (a *Adapter) pollAll() {
|
func (a *Adapter) pollAll() {
|
||||||
entries := a.snapshot()
|
entries := a.snapshot()
|
||||||
log.Debug("pollAll bots=%d", len(entries))
|
kunlog.Trace("[robot:telegram] pollAll bots=%d", len(entries))
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +50,7 @@ func (a *Adapter) pollAll() {
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug("polling robot=%s offset=%d", entry.robotID, entry.offset)
|
kunlog.Trace("[robot:telegram] polling robot=%s offset=%d", entry.robotID, entry.offset)
|
||||||
groups := []string{"telegram", entry.robotID}
|
groups := []string{"telegram", entry.robotID}
|
||||||
msgs, err := entry.bot.GetUpdates(ctx, entry.offset, pollTimeout, groups)
|
msgs, err := entry.bot.GetUpdates(ctx, entry.offset, pollTimeout, groups)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
328
agent/sandbox/v2/claude/command.go
Normal file
328
agent/sandbox/v2/claude/command.go
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/connector"
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
||||||
|
infra "github.com/yaoapp/yao/sandbox/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultA2OPort = 3099
|
||||||
|
|
||||||
|
type command struct {
|
||||||
|
shell []string
|
||||||
|
env map[string]string
|
||||||
|
stdin []byte
|
||||||
|
workDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClaudeRunner) buildCommand(ctx context.Context, req *types.StreamRequest, p platform) command {
|
||||||
|
computer := req.Computer
|
||||||
|
workDir := computer.GetWorkDir()
|
||||||
|
assistantID := ""
|
||||||
|
if req.Config != nil {
|
||||||
|
assistantID = req.Config.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
isContinuation := hasExistingSession(ctx, computer, p, assistantID)
|
||||||
|
env := buildEnv(req, p)
|
||||||
|
args := buildArgs(req, r, p, isContinuation, assistantID)
|
||||||
|
inputJSONL := buildInput(req.Messages, isContinuation)
|
||||||
|
|
||||||
|
var systemPrompt string
|
||||||
|
envPrompt := buildSandboxEnvPrompt(p, workDir)
|
||||||
|
if !isContinuation && req.SystemPrompt != "" {
|
||||||
|
systemPrompt = req.SystemPrompt + "\n\n" + envPrompt
|
||||||
|
} else if !isContinuation {
|
||||||
|
systemPrompt = envPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
promptFile := p.PathJoin(workDir, ".yao", "assistants", assistantID, "system-prompt.txt")
|
||||||
|
if assistantID == "" {
|
||||||
|
promptFile = p.PathJoin(workDir, ".yao", ".system-prompt.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
script, stdin := p.BuildScript(scriptInput{
|
||||||
|
args: args,
|
||||||
|
systemPrompt: systemPrompt,
|
||||||
|
inputJSONL: inputJSONL,
|
||||||
|
workDir: workDir,
|
||||||
|
promptFile: promptFile,
|
||||||
|
})
|
||||||
|
|
||||||
|
return command{
|
||||||
|
shell: p.ShellCmd(script),
|
||||||
|
env: env,
|
||||||
|
stdin: stdin,
|
||||||
|
workDir: workDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEnv(req *types.StreamRequest, p platform) map[string]string {
|
||||||
|
env := make(map[string]string)
|
||||||
|
workDir := req.Computer.GetWorkDir()
|
||||||
|
|
||||||
|
for k, v := range p.HomeEnv(workDir) {
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantID := ""
|
||||||
|
if req.Config != nil {
|
||||||
|
assistantID = req.Config.ID
|
||||||
|
}
|
||||||
|
if assistantID != "" {
|
||||||
|
configDir := p.PathJoin(workDir, ".yao", "assistants", assistantID)
|
||||||
|
env["CLAUDE_CONFIG_DIR"] = configDir
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Connector != nil {
|
||||||
|
setting := req.Connector.Setting()
|
||||||
|
host, _ := setting["host"].(string)
|
||||||
|
key, _ := setting["key"].(string)
|
||||||
|
model, _ := setting["model"].(string)
|
||||||
|
|
||||||
|
if req.Connector.Is(connector.ANTHROPIC) {
|
||||||
|
env["ANTHROPIC_BASE_URL"] = host
|
||||||
|
env["ANTHROPIC_API_KEY"] = key
|
||||||
|
if model != "" {
|
||||||
|
env["ANTHROPIC_MODEL"] = model
|
||||||
|
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
|
||||||
|
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
|
||||||
|
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
|
||||||
|
env["CLAUDE_CODE_SUBAGENT_MODEL"] = model
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
connectorID := req.Connector.ID()
|
||||||
|
env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d/c/%s", defaultA2OPort, connectorID)
|
||||||
|
env["ANTHROPIC_API_KEY"] = "dummy"
|
||||||
|
// Use a valid Anthropic model name to pass Claude CLI's local
|
||||||
|
// validation. The a2o proxy ignores this and substitutes the
|
||||||
|
// real backend model from its connector config.
|
||||||
|
env["ANTHROPIC_MODEL"] = "claude-sonnet-4-6"
|
||||||
|
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = "claude-sonnet-4-6"
|
||||||
|
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = "claude-sonnet-4-6"
|
||||||
|
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "claude-sonnet-4-6"
|
||||||
|
env["CLAUDE_CODE_SUBAGENT_MODEL"] = "claude-sonnet-4-6"
|
||||||
|
}
|
||||||
|
|
||||||
|
if thinking, ok := setting["thinking"].(map[string]interface{}); ok {
|
||||||
|
thinkType, _ := thinking["type"].(string)
|
||||||
|
switch thinkType {
|
||||||
|
case "disabled":
|
||||||
|
env["MAX_THINKING_TOKENS"] = "0"
|
||||||
|
case "enabled":
|
||||||
|
if budget, ok := thinking["budget_tokens"].(float64); ok && budget > 0 {
|
||||||
|
env["MAX_THINKING_TOKENS"] = fmt.Sprintf("%d", int(budget))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Config != nil && len(req.Config.Secrets) > 0 {
|
||||||
|
for k, v := range req.Config.Secrets {
|
||||||
|
env[k] = 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 *ClaudeRunner, p platform, isContinuation bool, assistantID string) []string {
|
||||||
|
var args []string
|
||||||
|
|
||||||
|
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")
|
||||||
|
args = append(args, "--permission-mode", permMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "--input-format", "stream-json")
|
||||||
|
args = append(args, "--output-format", "stream-json")
|
||||||
|
args = append(args, "--include-partial-messages")
|
||||||
|
args = append(args, "--verbose")
|
||||||
|
|
||||||
|
if isContinuation {
|
||||||
|
args = append(args, "--continue")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Config != nil && req.Config.Runner.Options != nil {
|
||||||
|
for key, val := range req.Config.Runner.Options {
|
||||||
|
if flag, ok := claudeArgWhitelist[key]; ok {
|
||||||
|
args = append(args, flag, fmt.Sprintf("%v", val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.hasMCP {
|
||||||
|
workDir := req.Computer.GetWorkDir()
|
||||||
|
mcpPath := p.PathJoin(workDir, ".yao", "assistants", assistantID, "mcp.json")
|
||||||
|
if assistantID == "" {
|
||||||
|
mcpPath = p.PathJoin(workDir, ".claude", "mcp.json")
|
||||||
|
}
|
||||||
|
args = append(args, "--mcp-config", mcpPath)
|
||||||
|
if r.mcpToolPattern != "" {
|
||||||
|
args = append(args, "--allowedTools", r.mcpToolPattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInput(messages []agentContext.Message, isContinuation bool) string {
|
||||||
|
if isContinuation {
|
||||||
|
return buildLastUserMessageJSONL(messages)
|
||||||
|
}
|
||||||
|
return buildFirstRequestJSONL(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSandboxEnvPrompt(p platform, workDir string) string {
|
||||||
|
osName := p.OS()
|
||||||
|
if osName == "" {
|
||||||
|
osName = "linux"
|
||||||
|
}
|
||||||
|
shell := p.Shell()
|
||||||
|
if shell == "" {
|
||||||
|
shell = "bash"
|
||||||
|
}
|
||||||
|
|
||||||
|
shellNote := p.EnvPromptNote()
|
||||||
|
|
||||||
|
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%[4]s
|
||||||
|
|
||||||
|
## User Attachments
|
||||||
|
|
||||||
|
User-uploaded files (images, documents, code files, etc.) are placed in %[1]s/.attachments/{chatID}/
|
||||||
|
Each chat session has its own subdirectory to avoid conflicts.
|
||||||
|
When the user references an attached file, read it from this directory using the Read or Bash tool.
|
||||||
|
For image files, you can view them directly as Claude supports vision on local files.
|
||||||
|
`, workDir, osName, shell, shellNote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasExistingSession(ctx context.Context, computer infra.Computer, p platform, assistantID string) bool {
|
||||||
|
workDir := computer.GetWorkDir()
|
||||||
|
var sessionDir string
|
||||||
|
if assistantID != "" {
|
||||||
|
configDir := p.PathJoin(workDir, ".yao", "assistants", assistantID)
|
||||||
|
sessionDir = p.PathJoin(configDir, "projects")
|
||||||
|
} else {
|
||||||
|
sessionDir = p.PathJoin(workDir, ".claude", "projects")
|
||||||
|
}
|
||||||
|
result, err := computer.Exec(ctx, p.ListDirCmd(sessionDir))
|
||||||
|
if err != nil || result.ExitCode != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result.Stdout) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMCPConfig(servers []types.MCPServer) []byte {
|
||||||
|
mcpServers := make(map[string]any, len(servers))
|
||||||
|
for _, s := range servers {
|
||||||
|
name := s.ServerID
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mcpServers[name] = map[string]any{
|
||||||
|
"command": "tai",
|
||||||
|
"args": []string{"mcp", name},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mcpServers) == 0 {
|
||||||
|
mcpServers["yao"] = map[string]any{
|
||||||
|
"command": "tai",
|
||||||
|
"args": []string{"mcp"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config := map[string]any{"mcpServers": mcpServers}
|
||||||
|
data, _ := json.Marshal(config)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMCPAllowedTools(servers []types.MCPServer) string {
|
||||||
|
patterns := make([]string, 0, len(servers))
|
||||||
|
for _, s := range servers {
|
||||||
|
if s.ServerID != "" {
|
||||||
|
patterns = append(patterns, fmt.Sprintf("mcp__%s__*", s.ServerID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(patterns) == 0 {
|
||||||
|
return "mcp__yao__*"
|
||||||
|
}
|
||||||
|
return strings.Join(patterns, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFirstRequestJSONL builds the input JSONL for a new (non-continuation)
|
||||||
|
// Claude CLI session. Per the stream-json input protocol, only user messages
|
||||||
|
// should be sent; Claude CLI manages its own assistant history internally.
|
||||||
|
func buildFirstRequestJSONL(messages []agentContext.Message) string {
|
||||||
|
var lines []string
|
||||||
|
for _, msg := range messages {
|
||||||
|
if msg.Role != "user" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content := msg.Content
|
||||||
|
if content == nil {
|
||||||
|
content = ""
|
||||||
|
}
|
||||||
|
streamMsg := map[string]any{
|
||||||
|
"type": "user",
|
||||||
|
"message": map[string]any{
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(streamMsg)
|
||||||
|
lines = append(lines, string(data))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLastUserMessageJSONL(messages []agentContext.Message) string {
|
||||||
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
|
if messages[i].Role == "user" {
|
||||||
|
content := messages[i].Content
|
||||||
|
if content == nil {
|
||||||
|
content = ""
|
||||||
|
}
|
||||||
|
msg := map[string]any{
|
||||||
|
"type": "user",
|
||||||
|
"message": map[string]any{
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(msg)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var claudeArgWhitelist = map[string]string{
|
||||||
|
"max_turns": "--max-turns",
|
||||||
|
"disallowed_tools": "--disallowed-tools",
|
||||||
|
"allowed_tools": "--allowedTools",
|
||||||
|
}
|
||||||
384
agent/sandbox/v2/claude/command_test.go
Normal file
384
agent/sandbox/v2/claude/command_test.go
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
||||||
|
infra "github.com/yaoapp/yao/sandbox/v2"
|
||||||
|
"github.com/yaoapp/yao/tai/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testPlatform() platform {
|
||||||
|
return &darwinPlatform{posixBase: posixBase{
|
||||||
|
os: "darwin", workDir: "/workspace", shell: "bash", tempDir: "/tmp",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildEnv ---
|
||||||
|
|
||||||
|
func TestBuildEnv_HomeEnv(t *testing.T) {
|
||||||
|
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
|
||||||
|
req.Computer = newFakeComputer("/workspace")
|
||||||
|
p := testPlatform()
|
||||||
|
|
||||||
|
env := buildEnv(req, p)
|
||||||
|
assert.Equal(t, "/workspace", env["HOME"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEnv_ConfigDirIsolation(t *testing.T) {
|
||||||
|
req := &types.StreamRequest{
|
||||||
|
Config: &types.SandboxConfig{ID: "my-assistant"},
|
||||||
|
}
|
||||||
|
req.Computer = newFakeComputer("/workspace")
|
||||||
|
p := testPlatform()
|
||||||
|
|
||||||
|
env := buildEnv(req, p)
|
||||||
|
expected := "/workspace/.yao/assistants/my-assistant"
|
||||||
|
assert.Equal(t, expected, env["CLAUDE_CONFIG_DIR"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEnv_NoAssistantID(t *testing.T) {
|
||||||
|
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
|
||||||
|
req.Computer = newFakeComputer("/workspace")
|
||||||
|
p := testPlatform()
|
||||||
|
|
||||||
|
env := buildEnv(req, p)
|
||||||
|
_, hasConfigDir := env["CLAUDE_CONFIG_DIR"]
|
||||||
|
assert.False(t, hasConfigDir, "should not set CLAUDE_CONFIG_DIR without assistantID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEnv_Token(t *testing.T) {
|
||||||
|
req := &types.StreamRequest{
|
||||||
|
Config: &types.SandboxConfig{},
|
||||||
|
Token: &types.SandboxToken{
|
||||||
|
Token: "tok123",
|
||||||
|
RefreshToken: "ref456",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req.Computer = newFakeComputer("/workspace")
|
||||||
|
p := testPlatform()
|
||||||
|
|
||||||
|
env := buildEnv(req, p)
|
||||||
|
assert.Equal(t, "tok123", env["YAO_TOKEN"])
|
||||||
|
assert.Equal(t, "ref456", env["YAO_REFRESH_TOKEN"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEnv_Secrets(t *testing.T) {
|
||||||
|
req := &types.StreamRequest{
|
||||||
|
Config: &types.SandboxConfig{
|
||||||
|
Secrets: map[string]string{
|
||||||
|
"MY_SECRET": "secret_val",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req.Computer = newFakeComputer("/workspace")
|
||||||
|
p := testPlatform()
|
||||||
|
|
||||||
|
env := buildEnv(req, p)
|
||||||
|
assert.Equal(t, "secret_val", env["MY_SECRET"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildArgs ---
|
||||||
|
|
||||||
|
func TestBuildArgs_Default(t *testing.T) {
|
||||||
|
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
|
||||||
|
req.Computer = newFakeComputer("/workspace")
|
||||||
|
r := &ClaudeRunner{}
|
||||||
|
p := testPlatform()
|
||||||
|
|
||||||
|
args := buildArgs(req, r, p, false, "")
|
||||||
|
assert.Contains(t, args, "--input-format")
|
||||||
|
assert.Contains(t, args, "stream-json")
|
||||||
|
assert.Contains(t, args, "--output-format")
|
||||||
|
assert.Contains(t, args, "--verbose")
|
||||||
|
assert.Contains(t, args, "--include-partial-messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildArgs_Continuation(t *testing.T) {
|
||||||
|
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
|
||||||
|
req.Computer = newFakeComputer("/workspace")
|
||||||
|
r := &ClaudeRunner{}
|
||||||
|
p := testPlatform()
|
||||||
|
|
||||||
|
args := buildArgs(req, r, p, true, "")
|
||||||
|
assert.Contains(t, args, "--continue")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildArgs_PermissionMode(t *testing.T) {
|
||||||
|
req := &types.StreamRequest{
|
||||||
|
Config: &types.SandboxConfig{
|
||||||
|
Runner: types.RunnerConfig{
|
||||||
|
Options: map[string]interface{}{
|
||||||
|
"permission_mode": "bypassPermissions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req.Computer = newFakeComputer("/workspace")
|
||||||
|
r := &ClaudeRunner{}
|
||||||
|
p := testPlatform()
|
||||||
|
|
||||||
|
args := buildArgs(req, r, p, false, "")
|
||||||
|
assert.Contains(t, args, "--dangerously-skip-permissions")
|
||||||
|
assert.Contains(t, args, "--permission-mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildArgs_MCP(t *testing.T) {
|
||||||
|
req := &types.StreamRequest{Config: &types.SandboxConfig{}}
|
||||||
|
req.Computer = newFakeComputer("/workspace")
|
||||||
|
r := &ClaudeRunner{hasMCP: true, mcpToolPattern: "mcp__yao__*"}
|
||||||
|
p := testPlatform()
|
||||||
|
|
||||||
|
args := buildArgs(req, r, p, false, "test-assistant")
|
||||||
|
assert.Contains(t, args, "--mcp-config")
|
||||||
|
assert.Contains(t, args, "--allowedTools")
|
||||||
|
assert.Contains(t, args, "mcp__yao__*")
|
||||||
|
|
||||||
|
mcpIdx := -1
|
||||||
|
for i, a := range args {
|
||||||
|
if a == "--mcp-config" {
|
||||||
|
mcpIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Greater(t, mcpIdx, -1)
|
||||||
|
mcpPath := args[mcpIdx+1]
|
||||||
|
assert.Contains(t, mcpPath, "test-assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildArgs_WhitelistOptions(t *testing.T) {
|
||||||
|
req := &types.StreamRequest{
|
||||||
|
Config: &types.SandboxConfig{
|
||||||
|
Runner: types.RunnerConfig{
|
||||||
|
Options: map[string]interface{}{
|
||||||
|
"max_turns": 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req.Computer = newFakeComputer("/workspace")
|
||||||
|
r := &ClaudeRunner{}
|
||||||
|
p := testPlatform()
|
||||||
|
|
||||||
|
args := buildArgs(req, r, p, false, "")
|
||||||
|
assert.Contains(t, args, "--max-turns")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildInput ---
|
||||||
|
|
||||||
|
func TestBuildInput_FirstRequest(t *testing.T) {
|
||||||
|
msgs := []agentContext.Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
{Role: "assistant", Content: "hi"},
|
||||||
|
}
|
||||||
|
result := buildInput(msgs, false)
|
||||||
|
lines := strings.Split(strings.TrimSpace(result), "\n")
|
||||||
|
assert.Len(t, lines, 1, "first request only includes user messages")
|
||||||
|
assert.Contains(t, lines[0], "hello")
|
||||||
|
assert.NotContains(t, result, "hi")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildInput_Continuation(t *testing.T) {
|
||||||
|
msgs := []agentContext.Message{
|
||||||
|
{Role: "user", Content: "first question"},
|
||||||
|
{Role: "assistant", Content: "first answer"},
|
||||||
|
{Role: "user", Content: "follow up"},
|
||||||
|
}
|
||||||
|
result := buildInput(msgs, true)
|
||||||
|
var parsed map[string]any
|
||||||
|
err := json.Unmarshal([]byte(result), &parsed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "user", parsed["type"])
|
||||||
|
msg, _ := parsed["message"].(map[string]any)
|
||||||
|
assert.Equal(t, "follow up", msg["content"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildFirstRequestJSONL ---
|
||||||
|
|
||||||
|
func TestBuildFirstRequestJSONL_SkipsSystem(t *testing.T) {
|
||||||
|
msgs := []agentContext.Message{
|
||||||
|
{Role: "system", Content: "system prompt"},
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
}
|
||||||
|
result := buildFirstRequestJSONL(msgs)
|
||||||
|
lines := strings.Split(strings.TrimSpace(result), "\n")
|
||||||
|
assert.Len(t, lines, 1, "system messages should be skipped")
|
||||||
|
assert.Contains(t, lines[0], "hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFirstRequestJSONL_OnlyUserMessages(t *testing.T) {
|
||||||
|
msgs := []agentContext.Message{
|
||||||
|
{Role: "user", Content: "q1"},
|
||||||
|
{Role: "assistant", Content: "a1"},
|
||||||
|
{Role: "user", Content: "q2"},
|
||||||
|
}
|
||||||
|
result := buildFirstRequestJSONL(msgs)
|
||||||
|
lines := strings.Split(strings.TrimSpace(result), "\n")
|
||||||
|
assert.Len(t, lines, 2, "only user messages should be included")
|
||||||
|
for _, line := range lines {
|
||||||
|
var parsed map[string]any
|
||||||
|
err := json.Unmarshal([]byte(line), &parsed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "user", parsed["type"])
|
||||||
|
msg, _ := parsed["message"].(map[string]any)
|
||||||
|
assert.Equal(t, "user", msg["role"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFirstRequestJSONL_AssistantOnlyMessages(t *testing.T) {
|
||||||
|
msgs := []agentContext.Message{
|
||||||
|
{Role: "assistant", Content: "a1"},
|
||||||
|
{Role: "assistant", Content: "a2"},
|
||||||
|
{Role: "assistant", Content: "a3"},
|
||||||
|
{Role: "user", Content: "q1"},
|
||||||
|
}
|
||||||
|
result := buildFirstRequestJSONL(msgs)
|
||||||
|
lines := strings.Split(strings.TrimSpace(result), "\n")
|
||||||
|
assert.Len(t, lines, 1, "only the user message should be included")
|
||||||
|
assert.Contains(t, lines[0], "q1")
|
||||||
|
assert.NotContains(t, result, "a1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFirstRequestJSONL_NilContent(t *testing.T) {
|
||||||
|
msgs := []agentContext.Message{
|
||||||
|
{Role: "user", Content: nil},
|
||||||
|
}
|
||||||
|
result := buildFirstRequestJSONL(msgs)
|
||||||
|
var parsed map[string]any
|
||||||
|
err := json.Unmarshal([]byte(strings.TrimSpace(result)), &parsed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
msg, _ := parsed["message"].(map[string]any)
|
||||||
|
assert.Equal(t, "", msg["content"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildLastUserMessageJSONL ---
|
||||||
|
|
||||||
|
func TestBuildLastUserMessageJSONL_Found(t *testing.T) {
|
||||||
|
msgs := []agentContext.Message{
|
||||||
|
{Role: "user", Content: "first"},
|
||||||
|
{Role: "assistant", Content: "reply"},
|
||||||
|
{Role: "user", Content: "second"},
|
||||||
|
}
|
||||||
|
result := buildLastUserMessageJSONL(msgs)
|
||||||
|
var parsed map[string]any
|
||||||
|
err := json.Unmarshal([]byte(result), &parsed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
msg, _ := parsed["message"].(map[string]any)
|
||||||
|
assert.Equal(t, "second", msg["content"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildLastUserMessageJSONL_NoUser(t *testing.T) {
|
||||||
|
msgs := []agentContext.Message{
|
||||||
|
{Role: "assistant", Content: "only assistant"},
|
||||||
|
}
|
||||||
|
assert.Empty(t, buildLastUserMessageJSONL(msgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildLastUserMessageJSONL_Empty(t *testing.T) {
|
||||||
|
assert.Empty(t, buildLastUserMessageJSONL(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildMCPConfig ---
|
||||||
|
|
||||||
|
func TestBuildMCPConfig_WithServers(t *testing.T) {
|
||||||
|
servers := []types.MCPServer{
|
||||||
|
{ServerID: "server1"},
|
||||||
|
{ServerID: "server2"},
|
||||||
|
}
|
||||||
|
data := buildMCPConfig(servers)
|
||||||
|
var cfg map[string]any
|
||||||
|
err := json.Unmarshal(data, &cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
mcpServers, ok := cfg["mcpServers"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Contains(t, mcpServers, "server1")
|
||||||
|
assert.Contains(t, mcpServers, "server2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMCPConfig_Empty(t *testing.T) {
|
||||||
|
data := buildMCPConfig(nil)
|
||||||
|
var cfg map[string]any
|
||||||
|
err := json.Unmarshal(data, &cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
mcpServers, ok := cfg["mcpServers"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Contains(t, mcpServers, "yao", "should default to yao server")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMCPConfig_EmptyServerID(t *testing.T) {
|
||||||
|
servers := []types.MCPServer{{ServerID: ""}}
|
||||||
|
data := buildMCPConfig(servers)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
mcpServers, _ := cfg["mcpServers"].(map[string]any)
|
||||||
|
assert.Contains(t, mcpServers, "yao", "empty serverID should fall back to default")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildMCPAllowedTools ---
|
||||||
|
|
||||||
|
func TestBuildMCPAllowedTools_WithServers(t *testing.T) {
|
||||||
|
servers := []types.MCPServer{
|
||||||
|
{ServerID: "s1"},
|
||||||
|
{ServerID: "s2"},
|
||||||
|
}
|
||||||
|
result := buildMCPAllowedTools(servers)
|
||||||
|
assert.Contains(t, result, "mcp__s1__*")
|
||||||
|
assert.Contains(t, result, "mcp__s2__*")
|
||||||
|
assert.Contains(t, result, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMCPAllowedTools_Empty(t *testing.T) {
|
||||||
|
assert.Equal(t, "mcp__yao__*", buildMCPAllowedTools(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildSandboxEnvPrompt ---
|
||||||
|
|
||||||
|
func TestBuildSandboxEnvPrompt(t *testing.T) {
|
||||||
|
p := testPlatform()
|
||||||
|
prompt := buildSandboxEnvPrompt(p, "/workspace")
|
||||||
|
assert.Contains(t, prompt, "/workspace")
|
||||||
|
assert.Contains(t, prompt, "darwin")
|
||||||
|
assert.Contains(t, prompt, "bash")
|
||||||
|
assert.Contains(t, prompt, "Sandbox Environment")
|
||||||
|
assert.Contains(t, prompt, ".attachments")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSandboxEnvPrompt_WindowsPlatform(t *testing.T) {
|
||||||
|
p := newWindowsPlatform(`C:\ws`, "pwsh", "")
|
||||||
|
prompt := buildSandboxEnvPrompt(p, `C:\ws`)
|
||||||
|
assert.Contains(t, prompt, "windows")
|
||||||
|
assert.Contains(t, prompt, "pwsh")
|
||||||
|
assert.Contains(t, prompt, `C:\ws`)
|
||||||
|
assert.Contains(t, prompt, "Windows desktop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fakeComputer implements infra.Computer for unit tests ---
|
||||||
|
|
||||||
|
type fakeComputer struct {
|
||||||
|
workDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeComputer(workDir string) *fakeComputer {
|
||||||
|
return &fakeComputer{workDir: workDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeComputer) GetWorkDir() string { return f.workDir }
|
||||||
|
func (f *fakeComputer) BindWorkplace(string) {}
|
||||||
|
func (f *fakeComputer) Workplace() workspace.FS { return nil }
|
||||||
|
func (f *fakeComputer) ComputerInfo() infra.ComputerInfo {
|
||||||
|
return infra.ComputerInfo{System: infra.SystemInfo{OS: "linux", Shell: "bash"}}
|
||||||
|
}
|
||||||
|
func (f *fakeComputer) Exec(_ context.Context, _ []string, _ ...infra.ExecOption) (*infra.ExecResult, error) {
|
||||||
|
return &infra.ExecResult{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeComputer) Stream(_ context.Context, _ []string, _ ...infra.ExecOption) (*infra.ExecStream, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeComputer) VNC(_ context.Context) (string, error) { return "", nil }
|
||||||
|
func (f *fakeComputer) Proxy(_ context.Context, _ int, _ string) (string, error) { return "", nil }
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
package claude
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
|
||||||
infra "github.com/yaoapp/yao/sandbox/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// osEnv captures OS-dependent paths and shell settings derived from the
|
|
||||||
// Computer's SystemInfo. All runner code should use osEnv instead of
|
|
||||||
// hardcoded Linux constants.
|
|
||||||
type osEnv struct {
|
|
||||||
OS string // "windows", "linux", "darwin", ...
|
|
||||||
Shell string // preferred shell binary: "bash", "pwsh", "cmd.exe", ...
|
|
||||||
WorkDir string // working directory on the target machine
|
|
||||||
UserHome string // user home directory (empty if irrelevant)
|
|
||||||
TempDir string // system temp directory
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *osEnv) isWindows() bool {
|
|
||||||
return strings.EqualFold(e.OS, "windows")
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveOSEnv builds an osEnv from the Computer's reported SystemInfo,
|
|
||||||
// falling back to SandboxConfig values where available, then to per-OS defaults.
|
|
||||||
func resolveOSEnv(computer infra.Computer, _ *types.SandboxConfig) *osEnv {
|
|
||||||
sys := computer.ComputerInfo().System
|
|
||||||
|
|
||||||
env := &osEnv{
|
|
||||||
OS: strings.ToLower(sys.OS),
|
|
||||||
Shell: sys.Shell,
|
|
||||||
TempDir: sys.TempDir,
|
|
||||||
WorkDir: computer.GetWorkDir(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if env.TempDir == "" {
|
|
||||||
env.TempDir = env.pathJoin(env.WorkDir, ".tmp")
|
|
||||||
}
|
|
||||||
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
// shellCmd returns the command slice to run a script through the appropriate shell.
|
|
||||||
func (e *osEnv) shellCmd(script string) []string {
|
|
||||||
shell := strings.ToLower(e.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{"bash", "-c", script}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mkdirCmd returns a shell command string to create a directory (with parents).
|
|
||||||
func (e *osEnv) mkdirCmd(dir string) string {
|
|
||||||
if e.isWindows() {
|
|
||||||
return fmt.Sprintf(`if (!(Test-Path '%s')) { New-Item -ItemType Directory -Path '%s' -Force | Out-Null }`, dir, dir)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("mkdir -p %s", dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// listDirCmd returns a command slice to list directory contents.
|
|
||||||
func (e *osEnv) listDirCmd(dir string) []string {
|
|
||||||
if e.isWindows() {
|
|
||||||
return e.shellCmd(fmt.Sprintf("Get-ChildItem -Name '%s'", dir))
|
|
||||||
}
|
|
||||||
return []string{"ls", dir}
|
|
||||||
}
|
|
||||||
|
|
||||||
// killProcessCmd returns a command slice to kill processes matching a pattern.
|
|
||||||
// On Windows, uses taskkill /T to kill the entire process tree, which handles
|
|
||||||
// child processes (chrome.exe, python3, etc.) that Claude CLI may have spawned.
|
|
||||||
func (e *osEnv) killProcessCmd(pattern string) []string {
|
|
||||||
if e.isWindows() {
|
|
||||||
// taskkill /F /T kills the process tree; fall back to Stop-Process.
|
|
||||||
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 e.shellCmd(script)
|
|
||||||
}
|
|
||||||
return []string{"sh", "-c", fmt.Sprintf("pkill -f '%s' || true", pattern)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// rootDir returns the filesystem root for the target OS.
|
|
||||||
func (e *osEnv) rootDir() string {
|
|
||||||
if e.isWindows() {
|
|
||||||
return `C:\`
|
|
||||||
}
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
// pathJoin joins path segments using the appropriate separator.
|
|
||||||
func (e *osEnv) pathJoin(parts ...string) string {
|
|
||||||
if e.isWindows() {
|
|
||||||
return strings.Join(parts, `\`)
|
|
||||||
}
|
|
||||||
return path.Join(parts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildCLIScript builds the complete CLI invocation script for the target OS.
|
|
||||||
// Returns (script, stdin) — on Linux stdin is nil (heredoc handles it),
|
|
||||||
// on Windows stdin contains inputJSONL bytes to pass via gRPC Stdin.
|
|
||||||
func (e *osEnv) buildCLIScript(args []string, systemPrompt, inputJSONL string) (string, []byte) {
|
|
||||||
workDir := e.WorkDir
|
|
||||||
promptFile := e.pathJoin(workDir, ".yao", ".system-prompt.txt")
|
|
||||||
|
|
||||||
if e.isWindows() {
|
|
||||||
return e.buildPowerShellScript(args, systemPrompt, inputJSONL, workDir, promptFile)
|
|
||||||
}
|
|
||||||
return e.buildBashScript(args, systemPrompt, inputJSONL, workDir, promptFile), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *osEnv) buildBashScript(args []string, systemPrompt, inputJSONL, workDir, promptFile string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
if e.UserHome != "" {
|
|
||||||
b.WriteString(fmt.Sprintf("touch %s/.Xauthority 2>/dev/null; ", e.UserHome))
|
|
||||||
}
|
|
||||||
b.WriteString("touch \"$HOME/.Xauthority\" 2>/dev/null\n")
|
|
||||||
|
|
||||||
if systemPrompt != "" {
|
|
||||||
b.WriteString(fmt.Sprintf("mkdir -p %s/.yao\n", workDir))
|
|
||||||
b.WriteString(fmt.Sprintf("cat << 'PROMPTEOF' > %s\n", promptFile))
|
|
||||||
b.WriteString(systemPrompt)
|
|
||||||
b.WriteString("\nPROMPTEOF\n")
|
|
||||||
args = append(args, "--append-system-prompt-file", promptFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("cat << 'INPUTEOF' | claude -p")
|
|
||||||
for _, arg := range args {
|
|
||||||
b.WriteString(fmt.Sprintf(" %q", arg))
|
|
||||||
}
|
|
||||||
b.WriteString(" 2>&1\n")
|
|
||||||
b.WriteString(inputJSONL)
|
|
||||||
b.WriteString("\nINPUTEOF")
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildPowerShellScript builds a script that writes the system prompt file,
|
|
||||||
// then launches claude -p. inputJSONL is returned as stdin bytes to be passed
|
|
||||||
// directly via gRPC, bypassing PowerShell's encoding entirely.
|
|
||||||
func (e *osEnv) buildPowerShellScript(args []string, systemPrompt, inputJSONL, workDir, promptFile string) (string, []byte) {
|
|
||||||
var b strings.Builder
|
|
||||||
noBOM := "(New-Object System.Text.UTF8Encoding $false)"
|
|
||||||
|
|
||||||
// Force UTF-8 for both input and output streams.
|
|
||||||
// On CJK Windows the default codepage is often GBK/GB2312 (936)
|
|
||||||
// which corrupts Claude CLI's UTF-8 JSON output.
|
|
||||||
b.WriteString("[Console]::InputEncoding = [System.Text.Encoding]::UTF8\n")
|
|
||||||
b.WriteString("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n")
|
|
||||||
b.WriteString("$OutputEncoding = [System.Text.Encoding]::UTF8\n")
|
|
||||||
|
|
||||||
// Ensure claude.exe can be found even when Tai runs as a different user.
|
|
||||||
// Claude CLI is typically installed per-user (e.g. C:\Users\X\.local\bin)
|
|
||||||
// which isn't in the PATH when Tai runs as a service or another account.
|
|
||||||
// Scan all user profiles for common install locations.
|
|
||||||
b.WriteString("foreach ($d in (Get-ChildItem 'C:\\Users' -Directory -ErrorAction SilentlyContinue)) {\n")
|
|
||||||
b.WriteString(" $p = Join-Path $d.FullName '.local\\bin'\n")
|
|
||||||
b.WriteString(" if (Test-Path (Join-Path $p 'claude.exe')) { $env:PATH = \"$p;$env:PATH\"; break }\n")
|
|
||||||
b.WriteString("}\n")
|
|
||||||
b.WriteString("if ($env:APPDATA) { $env:PATH = \"$env:APPDATA\\npm;$env:PATH\" }\n")
|
|
||||||
|
|
||||||
yaoDir := e.pathJoin(workDir, ".yao")
|
|
||||||
b.WriteString(fmt.Sprintf("if (!(Test-Path '%s')) { New-Item -ItemType Directory -Path '%s' -Force | Out-Null }\n", yaoDir, yaoDir))
|
|
||||||
|
|
||||||
if systemPrompt != "" {
|
|
||||||
escaped := strings.ReplaceAll(systemPrompt, "'", "''")
|
|
||||||
b.WriteString(fmt.Sprintf("[IO.File]::WriteAllText('%s', @'\n%s\n'@, %s)\n", promptFile, escaped, noBOM))
|
|
||||||
args = append(args, "--append-system-prompt-file", promptFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("claude -p")
|
|
||||||
for _, arg := range args {
|
|
||||||
b.WriteString(fmt.Sprintf(" '%s'", strings.ReplaceAll(arg, "'", "''")))
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String(), []byte(inputJSONL + "\n")
|
|
||||||
}
|
|
||||||
|
|
@ -4,30 +4,57 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
goujson "github.com/yaoapp/gou/json"
|
"github.com/yaoapp/kun/log"
|
||||||
agentContext "github.com/yaoapp/yao/agent/context"
|
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errStreamCompleted is a sentinel indicating the parser received the terminal
|
// streamParser is an explicit state machine for Claude CLI stream-json output.
|
||||||
// "result" message. It is NOT a real error — callers should treat it as
|
//
|
||||||
// successful completion of the stream.
|
// Each tool call gets its own message lifecycle:
|
||||||
var errStreamCompleted = errors.New("claude stream completed")
|
//
|
||||||
|
// content_block_start -> message_start(id=exec-N-xxx) + ChunkExecute{tool, status:running}
|
||||||
|
// input_json_delta -> ChunkExecute{input_delta:...} (same message group)
|
||||||
|
// content_block_stop -> message_end(exec-N-xxx)
|
||||||
|
// ...later...
|
||||||
|
// user/tool_result -> message_start(id=exec-N-xxx, reuse!) + ChunkExecute{status:completed, output:...} + message_end
|
||||||
|
type streamParser struct {
|
||||||
|
handler message.StreamFunc
|
||||||
|
completed bool
|
||||||
|
|
||||||
// parseStreamJSON reads stream-json lines from Claude CLI stdout and
|
textActive bool
|
||||||
// pushes them through handler as standard StreamChunkType events.
|
toolIndex int
|
||||||
func parseStreamJSON(ctx context.Context, stdout io.ReadCloser, handler message.StreamFunc) error {
|
curTool *toolState
|
||||||
// When the context is cancelled (upstream timeout / interrupt), close
|
|
||||||
// stdout so that scanner.Scan() unblocks immediately. Without this,
|
toolNames map[string]string // tool_id -> tool_name
|
||||||
// a failed TerminateProcess (Access is denied) would leave us stuck
|
toolMsgIDs map[string]string // tool_id -> message_id (for result reuse)
|
||||||
// forever on the read.
|
toolInputs map[string]string // tool_id -> full input JSON (for result replay)
|
||||||
|
toolSummaries map[string]string // tool_id -> summary (for result replay)
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolState struct {
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
msgID string
|
||||||
|
index int
|
||||||
|
inputJSON strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStreamParser(handler message.StreamFunc) *streamParser {
|
||||||
|
return &streamParser{
|
||||||
|
handler: handler,
|
||||||
|
toolNames: make(map[string]string),
|
||||||
|
toolMsgIDs: make(map[string]string),
|
||||||
|
toolInputs: make(map[string]string),
|
||||||
|
toolSummaries: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error {
|
||||||
doneParsing := make(chan struct{})
|
doneParsing := make(chan struct{})
|
||||||
defer close(doneParsing)
|
defer close(doneParsing)
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -39,20 +66,7 @@ func parseStreamJSON(ctx context.Context, stdout io.ReadCloser, handler message.
|
||||||
}()
|
}()
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
scanner := bufio.NewScanner(stdout)
|
||||||
buf := make([]byte, 0, 64*1024)
|
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
||||||
scanner.Buffer(buf, 1024*1024)
|
|
||||||
|
|
||||||
messageStarted := false
|
|
||||||
toolBlockActive := false
|
|
||||||
toolIndex := 0
|
|
||||||
|
|
||||||
type toolState struct {
|
|
||||||
id string
|
|
||||||
name string
|
|
||||||
index int
|
|
||||||
inputJSON strings.Builder
|
|
||||||
}
|
|
||||||
var currentTool *toolState
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
|
|
@ -62,280 +76,39 @@ func parseStreamJSON(ctx context.Context, stdout io.ReadCloser, handler message.
|
||||||
|
|
||||||
var msg map[string]any
|
var msg map[string]any
|
||||||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||||||
|
if len(line) > 200 {
|
||||||
|
log.Trace("[claude-parse] JSON unmarshal error: %v (line len=%d, prefix=%q)", err, len(line), line[:200])
|
||||||
|
} else {
|
||||||
|
log.Trace("[claude-parse] JSON unmarshal error: %v (line=%q)", err, line)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
msgType, _ := msg["type"].(string)
|
msgType, _ := msg["type"].(string)
|
||||||
stopped := false
|
var stopped bool
|
||||||
|
|
||||||
switch msgType {
|
switch msgType {
|
||||||
case "system":
|
case "system":
|
||||||
if handler != nil {
|
stopped = p.handleSystem(msg)
|
||||||
data, _ := json.Marshal(msg)
|
|
||||||
if handler(message.ChunkMetadata, data) != 0 {
|
|
||||||
stopped = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "stream_event":
|
case "stream_event":
|
||||||
event, _ := msg["event"].(map[string]any)
|
stopped = p.handleStreamEvent(msg)
|
||||||
if event == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
eventType, _ := event["type"].(string)
|
|
||||||
|
|
||||||
switch eventType {
|
|
||||||
case "content_block_start":
|
|
||||||
if cb, ok := event["content_block"].(map[string]any); ok {
|
|
||||||
blockType, _ := cb["type"].(string)
|
|
||||||
if blockType == "tool_use" {
|
|
||||||
toolName, _ := cb["name"].(string)
|
|
||||||
toolID, _ := cb["id"].(string)
|
|
||||||
if toolID == "" {
|
|
||||||
toolID = fmt.Sprintf("tool_%d_%d", toolIndex, time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
currentTool = &toolState{id: toolID, name: toolName, index: toolIndex}
|
|
||||||
toolIndex++
|
|
||||||
|
|
||||||
if handler != nil {
|
|
||||||
if messageStarted {
|
|
||||||
handler(message.ChunkMessageEnd, nil)
|
|
||||||
messageStarted = false
|
|
||||||
}
|
|
||||||
if !toolBlockActive {
|
|
||||||
startData := message.EventMessageStartData{
|
|
||||||
MessageID: fmt.Sprintf("sandbox-tool-%d", time.Now().UnixNano()),
|
|
||||||
Type: "tool_call",
|
|
||||||
Timestamp: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
sd, _ := json.Marshal(startData)
|
|
||||||
if handler(message.ChunkMessageStart, sd) != 0 {
|
|
||||||
stopped = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
toolBlockActive = true
|
|
||||||
}
|
|
||||||
tcData, _ := json.Marshal([]map[string]any{{
|
|
||||||
"index": currentTool.index,
|
|
||||||
"id": currentTool.id,
|
|
||||||
"type": "function",
|
|
||||||
"function": map[string]any{
|
|
||||||
"name": toolName,
|
|
||||||
"arguments": "",
|
|
||||||
},
|
|
||||||
}})
|
|
||||||
if handler(message.ChunkToolCall, tcData) != 0 {
|
|
||||||
stopped = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "content_block_delta":
|
|
||||||
if delta, ok := event["delta"].(map[string]any); ok {
|
|
||||||
deltaType, _ := delta["type"].(string)
|
|
||||||
switch deltaType {
|
|
||||||
case "text_delta":
|
|
||||||
if text, ok := delta["text"].(string); ok && text != "" {
|
|
||||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
|
||||||
text = strings.ReplaceAll(text, "\r", "\n")
|
|
||||||
if handler != nil {
|
|
||||||
if toolBlockActive {
|
|
||||||
handler(message.ChunkMessageEnd, nil)
|
|
||||||
toolBlockActive = false
|
|
||||||
messageStarted = false
|
|
||||||
}
|
|
||||||
if !messageStarted {
|
|
||||||
startData := message.EventMessageStartData{
|
|
||||||
MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
|
|
||||||
Type: "text",
|
|
||||||
Timestamp: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
sd, _ := json.Marshal(startData)
|
|
||||||
if handler(message.ChunkMessageStart, sd) != 0 {
|
|
||||||
stopped = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
messageStarted = true
|
|
||||||
}
|
|
||||||
if handler(message.ChunkText, []byte(text)) != 0 {
|
|
||||||
stopped = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "input_json_delta":
|
|
||||||
if currentTool != nil {
|
|
||||||
if partial, ok := delta["partial_json"].(string); ok {
|
|
||||||
currentTool.inputJSON.WriteString(partial)
|
|
||||||
if handler != nil {
|
|
||||||
tcData, _ := json.Marshal([]map[string]any{{
|
|
||||||
"index": currentTool.index,
|
|
||||||
"function": map[string]any{
|
|
||||||
"arguments": partial,
|
|
||||||
},
|
|
||||||
}})
|
|
||||||
if handler(message.ChunkToolCall, tcData) != 0 {
|
|
||||||
stopped = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "content_block_stop":
|
|
||||||
currentTool = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
case "assistant":
|
case "assistant":
|
||||||
if msgData, ok := msg["message"].(map[string]any); ok {
|
stopped = p.handleAssistant(msg)
|
||||||
stopReason, _ := msgData["stop_reason"].(string)
|
case "user":
|
||||||
if stopReason != "" {
|
stopped = p.handleUser(msg)
|
||||||
if contentArr, ok := msgData["content"].([]any); ok {
|
|
||||||
for _, item := range contentArr {
|
|
||||||
ci, ok := item.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
itemType, _ := ci["type"].(string)
|
|
||||||
|
|
||||||
if itemType == "tool_use" && handler != nil {
|
|
||||||
toolName, _ := ci["name"].(string)
|
|
||||||
toolID, _ := ci["id"].(string)
|
|
||||||
if toolID == "" {
|
|
||||||
toolID = fmt.Sprintf("tool_%d_%d", toolIndex, time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
inputRaw, _ := json.Marshal(ci["input"])
|
|
||||||
idx := toolIndex
|
|
||||||
toolIndex++
|
|
||||||
|
|
||||||
if !toolBlockActive {
|
|
||||||
startData := message.EventMessageStartData{
|
|
||||||
MessageID: fmt.Sprintf("sandbox-tool-%d", time.Now().UnixNano()),
|
|
||||||
Type: "tool_call",
|
|
||||||
Timestamp: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
sd, _ := json.Marshal(startData)
|
|
||||||
if handler(message.ChunkMessageStart, sd) != 0 {
|
|
||||||
stopped = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
toolBlockActive = true
|
|
||||||
}
|
|
||||||
tcData, _ := json.Marshal([]map[string]any{{
|
|
||||||
"index": idx,
|
|
||||||
"id": toolID,
|
|
||||||
"type": "function",
|
|
||||||
"function": map[string]any{
|
|
||||||
"name": toolName,
|
|
||||||
"arguments": string(inputRaw),
|
|
||||||
},
|
|
||||||
}})
|
|
||||||
if handler(message.ChunkToolCall, tcData) != 0 {
|
|
||||||
stopped = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if itemType == "text" {
|
|
||||||
if text, ok := ci["text"].(string); ok && text != "" && handler != nil && !messageStarted {
|
|
||||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
|
||||||
text = strings.ReplaceAll(text, "\r", "\n")
|
|
||||||
if toolBlockActive {
|
|
||||||
handler(message.ChunkMessageEnd, nil)
|
|
||||||
toolBlockActive = false
|
|
||||||
}
|
|
||||||
startData := message.EventMessageStartData{
|
|
||||||
MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
|
|
||||||
Type: "text",
|
|
||||||
Timestamp: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
sd, _ := json.Marshal(startData)
|
|
||||||
if handler(message.ChunkMessageStart, sd) != 0 {
|
|
||||||
stopped = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if handler(message.ChunkText, []byte(text)) != 0 {
|
|
||||||
stopped = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
messageStarted = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close any open message from the streaming phase.
|
|
||||||
// stream_event text_deltas set messageStarted=true but
|
|
||||||
// nothing resets it when the turn ends — the assistant
|
|
||||||
// message marks the turn boundary, so we must close
|
|
||||||
// the message here to keep state in sync with the
|
|
||||||
// stream handler (which already sent message_end).
|
|
||||||
if handler != nil {
|
|
||||||
if toolBlockActive {
|
|
||||||
handler(message.ChunkMessageEnd, nil)
|
|
||||||
toolBlockActive = false
|
|
||||||
}
|
|
||||||
if messageStarted {
|
|
||||||
handler(message.ChunkMessageEnd, nil)
|
|
||||||
messageStarted = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "result":
|
case "result":
|
||||||
isError, _ := msg["is_error"].(bool)
|
return p.handleResult(msg)
|
||||||
if isError {
|
|
||||||
if result, ok := msg["result"].(string); ok {
|
|
||||||
if handler != nil {
|
|
||||||
handler(message.ChunkError, []byte(result))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("Claude CLI error: %s", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if handler != nil {
|
|
||||||
if toolBlockActive {
|
|
||||||
handler(message.ChunkMessageEnd, nil)
|
|
||||||
toolBlockActive = false
|
|
||||||
}
|
|
||||||
if messageStarted {
|
|
||||||
handler(message.ChunkMessageEnd, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// "result" is the terminal message in Claude CLI's stream-json
|
|
||||||
// protocol. Return immediately instead of continuing to
|
|
||||||
// scanner.Scan(), which would block forever if the process
|
|
||||||
// stays alive (e.g. child processes like chrome.exe keep the
|
|
||||||
// stdout pipe open).
|
|
||||||
return errStreamCompleted
|
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
var errMsg string
|
return p.handleError(msg)
|
||||||
switch e := msg["error"].(type) {
|
|
||||||
case string:
|
|
||||||
errMsg = e
|
|
||||||
case map[string]any:
|
|
||||||
errMsg, _ = e["message"].(string)
|
|
||||||
}
|
|
||||||
if errMsg != "" {
|
|
||||||
if handler != nil {
|
|
||||||
handler(message.ChunkError, []byte(errMsg))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("Claude CLI error: %s", errMsg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if stopped {
|
if stopped {
|
||||||
break
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
// If the context was cancelled (upstream timeout / interrupt), the
|
log.Trace("[claude-parse] scanner error: %v (ctx.Err=%v)", err, ctx.Err())
|
||||||
// stdout pipe was closed by the goroutine above. The resulting
|
|
||||||
// read error is expected — surface it as context.Canceled so the
|
|
||||||
// caller can handle it uniformly.
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
@ -344,53 +117,422 @@ func parseStreamJSON(ctx context.Context, stdout io.ReadCloser, handler message.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFirstRequestJSONL builds JSONL with all messages for the first request.
|
// --- Message lifecycle helpers ---
|
||||||
func buildFirstRequestJSONL(messages []agentContext.Message) string {
|
|
||||||
var lines []string
|
func (p *streamParser) beginMessageWithID(id, msgType string) (stopped bool) {
|
||||||
for _, msg := range messages {
|
startData := message.EventMessageStartData{
|
||||||
if msg.Role == "system" {
|
MessageID: id,
|
||||||
continue
|
Type: msgType,
|
||||||
}
|
Timestamp: time.Now().UnixMilli(),
|
||||||
content := msg.Content
|
|
||||||
if content == nil {
|
|
||||||
content = ""
|
|
||||||
}
|
|
||||||
streamMsg := map[string]any{
|
|
||||||
"type": string(msg.Role),
|
|
||||||
"message": map[string]any{
|
|
||||||
"role": string(msg.Role),
|
|
||||||
"content": content,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(streamMsg)
|
|
||||||
lines = append(lines, string(data))
|
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
sd, _ := json.Marshal(startData)
|
||||||
|
return p.handler != nil && p.handler(message.ChunkMessageStart, sd) != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildLastUserMessageJSONL builds JSONL with only the last user message.
|
func (p *streamParser) beginMessage(msgType string) (messageID string, stopped bool) {
|
||||||
func buildLastUserMessageJSONL(messages []agentContext.Message) string {
|
id := fmt.Sprintf("sandbox-%s-%s", msgType, message.GenerateNanoID())
|
||||||
for i := len(messages) - 1; i >= 0; i-- {
|
return id, p.beginMessageWithID(id, msgType)
|
||||||
if messages[i].Role == "user" {
|
}
|
||||||
content := messages[i].Content
|
|
||||||
if content == nil {
|
func (p *streamParser) endMessage() {
|
||||||
content = ""
|
if p.handler != nil {
|
||||||
}
|
p.handler(message.ChunkMessageEnd, nil)
|
||||||
msg := map[string]any{
|
}
|
||||||
"type": "user",
|
}
|
||||||
"message": map[string]any{
|
|
||||||
"role": "user",
|
func (p *streamParser) closeTextMessage() {
|
||||||
"content": content,
|
if p.textActive {
|
||||||
},
|
p.endMessage()
|
||||||
}
|
p.textActive = false
|
||||||
data, _ := json.Marshal(msg)
|
}
|
||||||
return string(data)
|
}
|
||||||
|
|
||||||
|
func (p *streamParser) ensureTextMessage() (stopped bool) {
|
||||||
|
if !p.textActive {
|
||||||
|
_, stopped = p.beginMessage("text")
|
||||||
|
if stopped {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
p.textActive = true
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSummary builds a short human-readable summary from the tool input JSON.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try common field names
|
||||||
|
for _, key := range []string{"path", "file_path", "command", "url", "query"} {
|
||||||
|
if v, ok := obj[key].(string); ok {
|
||||||
|
return truncate(v, 80)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppress unused import warnings — goujson.Parse is used for tool description
|
func truncate(s string, max int) string {
|
||||||
// parsing in V1 and will be used for detailed tool descriptions in future.
|
s = strings.TrimSpace(s)
|
||||||
var _ = goujson.Parse
|
s = strings.ReplaceAll(s, "\n", " ")
|
||||||
var _ = log.Printf
|
if len(s) > max {
|
||||||
|
return s[:max] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event handlers ---
|
||||||
|
|
||||||
|
func (p *streamParser) handleSystem(msg map[string]any) (stopped bool) {
|
||||||
|
if p.handler != nil {
|
||||||
|
data, _ := json.Marshal(msg)
|
||||||
|
return p.handler(message.ChunkMetadata, data) != 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamParser) handleStreamEvent(msg map[string]any) (stopped bool) {
|
||||||
|
event, _ := msg["event"].(map[string]any)
|
||||||
|
if event == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
eventType, _ := event["type"].(string)
|
||||||
|
|
||||||
|
switch eventType {
|
||||||
|
case "content_block_start":
|
||||||
|
return p.onContentBlockStart(event)
|
||||||
|
case "content_block_delta":
|
||||||
|
return p.onContentBlockDelta(event)
|
||||||
|
case "content_block_stop":
|
||||||
|
return p.onContentBlockStop()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamParser) onContentBlockStart(event map[string]any) (stopped bool) {
|
||||||
|
cb, ok := event["content_block"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
blockType, _ := cb["type"].(string)
|
||||||
|
if blockType != "tool_use" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
p.closeTextMessage()
|
||||||
|
|
||||||
|
toolName, _ := cb["name"].(string)
|
||||||
|
toolID, _ := cb["id"].(string)
|
||||||
|
if toolID == "" {
|
||||||
|
toolID = fmt.Sprintf("tool_%d_%d", p.toolIndex, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
msgID, stopped := p.beginMessage("execute")
|
||||||
|
if stopped {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
p.curTool = &toolState{id: toolID, name: toolName, msgID: msgID, index: p.toolIndex}
|
||||||
|
p.toolIndex++
|
||||||
|
p.toolNames[toolID] = toolName
|
||||||
|
p.toolMsgIDs[toolID] = msgID
|
||||||
|
|
||||||
|
if p.handler == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.emitExecute(map[string]any{
|
||||||
|
"tool": toolName,
|
||||||
|
"tool_id": toolID,
|
||||||
|
"status": "running",
|
||||||
|
"runner": "claude-cli",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamParser) onContentBlockStop() (stopped bool) {
|
||||||
|
if p.curTool != nil {
|
||||||
|
toolID := p.curTool.id
|
||||||
|
inputStr := p.curTool.inputJSON.String()
|
||||||
|
if inputStr != "" {
|
||||||
|
p.toolInputs[toolID] = inputStr
|
||||||
|
summary := extractSummary(p.curTool.name, inputStr)
|
||||||
|
if summary != "" {
|
||||||
|
p.toolSummaries[toolID] = summary
|
||||||
|
p.emitExecute(map[string]any{
|
||||||
|
"summary": summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.endMessage()
|
||||||
|
p.curTool = nil
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamParser) onContentBlockDelta(event map[string]any) (stopped bool) {
|
||||||
|
delta, ok := event["delta"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
deltaType, _ := delta["type"].(string)
|
||||||
|
|
||||||
|
switch deltaType {
|
||||||
|
case "text_delta":
|
||||||
|
text, _ := delta["text"].(string)
|
||||||
|
if text == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if p.ensureTextMessage() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return p.emitText(text)
|
||||||
|
|
||||||
|
case "input_json_delta":
|
||||||
|
if p.curTool == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
partial, _ := delta["partial_json"].(string)
|
||||||
|
if partial == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p.curTool.inputJSON.WriteString(partial)
|
||||||
|
if p.handler != nil {
|
||||||
|
return p.emitExecute(map[string]any{
|
||||||
|
"input_delta": p.curTool.inputJSON.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamParser) handleAssistant(msg map[string]any) (stopped bool) {
|
||||||
|
msgData, _ := msg["message"].(map[string]any)
|
||||||
|
if msgData == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage, ok := msgData["usage"].(map[string]any); ok {
|
||||||
|
p.emitMetadata(map[string]any{
|
||||||
|
"usage": usage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
stopReason, _ := msgData["stop_reason"].(string)
|
||||||
|
if stopReason == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
contentArr, _ := msgData["content"].([]any)
|
||||||
|
for _, item := range contentArr {
|
||||||
|
ci, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemType, _ := ci["type"].(string)
|
||||||
|
|
||||||
|
if itemType == "tool_use" && p.handler != nil {
|
||||||
|
p.closeTextMessage()
|
||||||
|
|
||||||
|
toolName, _ := ci["name"].(string)
|
||||||
|
toolID, _ := ci["id"].(string)
|
||||||
|
if toolID == "" {
|
||||||
|
toolID = fmt.Sprintf("tool_%d_%d", p.toolIndex, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
msgID, stopped := p.beginMessage("execute")
|
||||||
|
if stopped {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
p.toolIndex++
|
||||||
|
p.toolNames[toolID] = toolName
|
||||||
|
p.toolMsgIDs[toolID] = msgID
|
||||||
|
|
||||||
|
inputRaw, _ := json.Marshal(ci["input"])
|
||||||
|
inputStr := string(inputRaw)
|
||||||
|
p.toolInputs[toolID] = inputStr
|
||||||
|
summary := extractSummary(toolName, inputStr)
|
||||||
|
p.toolSummaries[toolID] = summary
|
||||||
|
|
||||||
|
if p.emitExecute(map[string]any{
|
||||||
|
"tool": toolName,
|
||||||
|
"tool_id": toolID,
|
||||||
|
"input": json.RawMessage(inputRaw),
|
||||||
|
"summary": summary,
|
||||||
|
"status": "running",
|
||||||
|
"runner": "claude-cli",
|
||||||
|
}) {
|
||||||
|
p.endMessage()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
p.endMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemType == "text" {
|
||||||
|
text, _ := ci["text"].(string)
|
||||||
|
if text == "" || p.handler == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.ensureTextMessage() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if p.emitText(text) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.closeTextMessage()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamParser) handleUser(msg map[string]any) (stopped bool) {
|
||||||
|
msgData, _ := msg["message"].(map[string]any)
|
||||||
|
if msgData == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
contentArr, _ := msgData["content"].([]interface{})
|
||||||
|
for _, item := range contentArr {
|
||||||
|
ci, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ciType, _ := ci["type"].(string)
|
||||||
|
if ciType != "tool_result" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
toolUseID, _ := ci["tool_use_id"].(string)
|
||||||
|
content := ci["content"]
|
||||||
|
isError, _ := ci["is_error"].(bool)
|
||||||
|
|
||||||
|
status := "completed"
|
||||||
|
if isError {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
execProps := map[string]any{
|
||||||
|
"tool_id": toolUseID,
|
||||||
|
"output": content,
|
||||||
|
"status": status,
|
||||||
|
"is_error": isError,
|
||||||
|
}
|
||||||
|
if name, ok := p.toolNames[toolUseID]; ok {
|
||||||
|
execProps["tool"] = name
|
||||||
|
}
|
||||||
|
if input, ok := p.toolInputs[toolUseID]; ok {
|
||||||
|
execProps["input"] = json.RawMessage(input)
|
||||||
|
}
|
||||||
|
if summary, ok := p.toolSummaries[toolUseID]; ok {
|
||||||
|
execProps["summary"] = summary
|
||||||
|
}
|
||||||
|
|
||||||
|
if reuseMsgID, ok := p.toolMsgIDs[toolUseID]; ok {
|
||||||
|
if p.beginMessageWithID(reuseMsgID, "execute") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, stopped := p.beginMessage("execute"); stopped {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.emitExecute(execProps) {
|
||||||
|
p.endMessage()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
p.endMessage()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamParser) handleResult(msg map[string]any) error {
|
||||||
|
isError, _ := msg["is_error"].(bool)
|
||||||
|
if isError {
|
||||||
|
if result, ok := msg["result"].(string); ok {
|
||||||
|
if p.handler != nil {
|
||||||
|
p.handler(message.ChunkError, []byte(result))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("Claude CLI error: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.closeTextMessage()
|
||||||
|
|
||||||
|
if p.handler != nil {
|
||||||
|
p.emitMetadata(map[string]any{
|
||||||
|
"result_summary": map[string]any{
|
||||||
|
"total_cost_usd": msg["total_cost_usd"],
|
||||||
|
"duration_ms": msg["duration_ms"],
|
||||||
|
"num_turns": msg["num_turns"],
|
||||||
|
"usage": msg["usage"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
p.completed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamParser) handleError(msg map[string]any) error {
|
||||||
|
var errMsg string
|
||||||
|
switch e := msg["error"].(type) {
|
||||||
|
case string:
|
||||||
|
errMsg = e
|
||||||
|
case map[string]any:
|
||||||
|
errMsg, _ = e["message"].(string)
|
||||||
|
}
|
||||||
|
if errMsg != "" {
|
||||||
|
if p.handler != nil {
|
||||||
|
p.handler(message.ChunkError, []byte(errMsg))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("Claude CLI error: %s", errMsg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
630
agent/sandbox/v2/claude/parse_test.go
Normal file
630
agent/sandbox/v2/claude/parse_test.go
Normal file
|
|
@ -0,0 +1,630 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
|
)
|
||||||
|
|
||||||
|
type chunkRecord struct {
|
||||||
|
Type message.StreamChunkType
|
||||||
|
Data json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingHandler(out *[]chunkRecord) message.StreamFunc {
|
||||||
|
return func(chunkType message.StreamChunkType, data []byte) int {
|
||||||
|
cp := make([]byte, len(data))
|
||||||
|
copy(cp, data)
|
||||||
|
*out = append(*out, chunkRecord{Type: chunkType, Data: cp})
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stoppingHandler(stopAfter int) (message.StreamFunc, *[]chunkRecord) {
|
||||||
|
var out []chunkRecord
|
||||||
|
count := 0
|
||||||
|
fn := func(chunkType message.StreamChunkType, data []byte) int {
|
||||||
|
cp := make([]byte, len(data))
|
||||||
|
copy(cp, data)
|
||||||
|
out = append(out, chunkRecord{Type: chunkType, Data: cp})
|
||||||
|
count++
|
||||||
|
if count >= stopAfter {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return fn, &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonLine(v interface{}) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipeWithLines(lines ...string) io.ReadCloser {
|
||||||
|
return io.NopCloser(strings.NewReader(strings.Join(lines, "\n") + "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helper: extract message_id from ChunkMessageStart data ---
|
||||||
|
func extractMessageID(data json.RawMessage) string {
|
||||||
|
var d map[string]any
|
||||||
|
json.Unmarshal(data, &d)
|
||||||
|
if id, ok := d["message_id"].(string); ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_TextOnly(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
jsonLine(map[string]any{"type": "system", "session_id": "abc"}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": map[string]any{"type": "text_delta", "text": "Hello "},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": map[string]any{"type": "text_delta", "text": "world"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "assistant",
|
||||||
|
"message": map[string]any{
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"content": []any{map[string]any{"type": "text", "text": "Hello world"}},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "result",
|
||||||
|
"total_cost_usd": 0.001,
|
||||||
|
"duration_ms": 1234,
|
||||||
|
"num_turns": 1,
|
||||||
|
"usage": map[string]any{"input_tokens": 10, "output_tokens": 20},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []chunkRecord
|
||||||
|
p := newStreamParser(recordingHandler(&chunks))
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, p.completed)
|
||||||
|
|
||||||
|
hasMessageStart := false
|
||||||
|
hasText := false
|
||||||
|
hasMessageEnd := false
|
||||||
|
hasResultMeta := false
|
||||||
|
for _, c := range chunks {
|
||||||
|
switch c.Type {
|
||||||
|
case message.ChunkMessageStart:
|
||||||
|
hasMessageStart = true
|
||||||
|
case message.ChunkText:
|
||||||
|
hasText = true
|
||||||
|
case message.ChunkMessageEnd:
|
||||||
|
hasMessageEnd = true
|
||||||
|
case message.ChunkMetadata:
|
||||||
|
var meta map[string]any
|
||||||
|
json.Unmarshal(c.Data, &meta)
|
||||||
|
if _, ok := meta["result_summary"]; ok {
|
||||||
|
hasResultMeta = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, hasMessageStart, "should emit message_start")
|
||||||
|
assert.True(t, hasText, "should emit text chunks")
|
||||||
|
assert.True(t, hasMessageEnd, "should emit message_end")
|
||||||
|
assert.True(t, hasResultMeta, "should emit result_summary metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_ToolUseAndResult(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_start",
|
||||||
|
"content_block": map[string]any{
|
||||||
|
"type": "tool_use",
|
||||||
|
"name": "Bash",
|
||||||
|
"id": "tool_123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": map[string]any{"type": "input_json_delta", "partial_json": `{"command":"ls`},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": map[string]any{"type": "input_json_delta", "partial_json": `"}`},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{"type": "content_block_stop"},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "user",
|
||||||
|
"message": map[string]any{
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "tool_123",
|
||||||
|
"content": "file1.txt\nfile2.txt",
|
||||||
|
"is_error": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "result",
|
||||||
|
"num_turns": 1,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []chunkRecord
|
||||||
|
p := newStreamParser(recordingHandler(&chunks))
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, p.completed)
|
||||||
|
|
||||||
|
var execChunks []map[string]any
|
||||||
|
for _, c := range chunks {
|
||||||
|
if c.Type == message.ChunkExecute {
|
||||||
|
var data map[string]any
|
||||||
|
json.Unmarshal(c.Data, &data)
|
||||||
|
execChunks = append(execChunks, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.GreaterOrEqual(t, len(execChunks), 2, "should have at least 2 execute chunks (start + result)")
|
||||||
|
|
||||||
|
assert.Equal(t, "Bash", execChunks[0]["tool"])
|
||||||
|
assert.Equal(t, "tool_123", execChunks[0]["tool_id"])
|
||||||
|
assert.Equal(t, "running", execChunks[0]["status"])
|
||||||
|
|
||||||
|
lastExec := execChunks[len(execChunks)-1]
|
||||||
|
assert.Equal(t, "tool_123", lastExec["tool_id"])
|
||||||
|
assert.Equal(t, "completed", lastExec["status"])
|
||||||
|
assert.Equal(t, "Bash", lastExec["tool"], "tool_result should carry tool name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParser_ToolIndependentMessages verifies that each tool call gets its own
|
||||||
|
// message_start/message_end pair, and tool_result reuses the same message_id.
|
||||||
|
func TestParser_ToolIndependentMessages(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
// Tool 1: Write
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_start",
|
||||||
|
"content_block": map[string]any{"type": "tool_use", "name": "Write", "id": "t_write"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": map[string]any{"type": "input_json_delta", "partial_json": `{"file_path":"server.js"}`},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{"type": "content_block_stop"},
|
||||||
|
}),
|
||||||
|
// Tool 2: Bash
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_start",
|
||||||
|
"content_block": map[string]any{"type": "tool_use", "name": "Bash", "id": "t_bash"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{"type": "content_block_stop"},
|
||||||
|
}),
|
||||||
|
// Results
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "user",
|
||||||
|
"message": map[string]any{
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "tool_result", "tool_use_id": "t_write", "content": "ok"},
|
||||||
|
map[string]any{"type": "tool_result", "tool_use_id": "t_bash", "content": "done"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{"type": "result", "num_turns": 1}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []chunkRecord
|
||||||
|
p := newStreamParser(recordingHandler(&chunks))
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Collect all message_start IDs and their order
|
||||||
|
var msgStarts []string
|
||||||
|
for _, c := range chunks {
|
||||||
|
if c.Type == message.ChunkMessageStart {
|
||||||
|
msgStarts = append(msgStarts, extractMessageID(c.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 4 message_starts: Write(running), Bash(running), Write(result), Bash(result)
|
||||||
|
require.Equal(t, 4, len(msgStarts), "should have 4 message_start events")
|
||||||
|
|
||||||
|
writeMsgID := msgStarts[0]
|
||||||
|
bashMsgID := msgStarts[1]
|
||||||
|
assert.NotEqual(t, writeMsgID, bashMsgID, "Write and Bash should have different message_ids")
|
||||||
|
|
||||||
|
// tool_result should reuse the original message_id
|
||||||
|
assert.Equal(t, writeMsgID, msgStarts[2], "Write tool_result should reuse Write message_id")
|
||||||
|
assert.Equal(t, bashMsgID, msgStarts[3], "Bash tool_result should reuse Bash message_id")
|
||||||
|
|
||||||
|
// Count message_end events (should match message_start)
|
||||||
|
endCount := 0
|
||||||
|
for _, c := range chunks {
|
||||||
|
if c.Type == message.ChunkMessageEnd {
|
||||||
|
endCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, 4, endCount, "each message_start should have matching message_end")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParser_ToolSummaryExtraction verifies that summary is extracted from tool input.
|
||||||
|
func TestParser_ToolSummaryExtraction(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_start",
|
||||||
|
"content_block": map[string]any{"type": "tool_use", "name": "Bash", "id": "t1"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": map[string]any{"type": "input_json_delta", "partial_json": `{"command":"ls -la /workspace"}`},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{"type": "content_block_stop"},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{"type": "result", "num_turns": 1}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []chunkRecord
|
||||||
|
p := newStreamParser(recordingHandler(&chunks))
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Look for summary in the last execute chunk before message_end
|
||||||
|
var summaryFound bool
|
||||||
|
for _, c := range chunks {
|
||||||
|
if c.Type == message.ChunkExecute {
|
||||||
|
var data map[string]any
|
||||||
|
json.Unmarshal(c.Data, &data)
|
||||||
|
if s, ok := data["summary"].(string); ok && s != "" {
|
||||||
|
summaryFound = true
|
||||||
|
assert.Equal(t, "ls -la /workspace", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, summaryFound, "should emit summary from tool input")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_UsageMetadata(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "assistant",
|
||||||
|
"message": map[string]any{
|
||||||
|
"usage": map[string]any{
|
||||||
|
"input_tokens": 100,
|
||||||
|
"output_tokens": 50,
|
||||||
|
},
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"content": []any{},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{"type": "result", "num_turns": 1}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []chunkRecord
|
||||||
|
p := newStreamParser(recordingHandler(&chunks))
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var usageMeta map[string]any
|
||||||
|
for _, c := range chunks {
|
||||||
|
if c.Type == message.ChunkMetadata {
|
||||||
|
var meta map[string]any
|
||||||
|
json.Unmarshal(c.Data, &meta)
|
||||||
|
if u, ok := meta["usage"]; ok {
|
||||||
|
usageMeta, _ = u.(map[string]any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, usageMeta, "should emit usage metadata")
|
||||||
|
assert.Equal(t, float64(100), usageMeta["input_tokens"])
|
||||||
|
assert.Equal(t, float64(50), usageMeta["output_tokens"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_ResultSummary(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "result",
|
||||||
|
"total_cost_usd": 0.05,
|
||||||
|
"duration_ms": 5000,
|
||||||
|
"num_turns": 3,
|
||||||
|
"usage": map[string]any{"input_tokens": 500, "output_tokens": 200},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []chunkRecord
|
||||||
|
p := newStreamParser(recordingHandler(&chunks))
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, p.completed)
|
||||||
|
|
||||||
|
var summary map[string]any
|
||||||
|
for _, c := range chunks {
|
||||||
|
if c.Type == message.ChunkMetadata {
|
||||||
|
var meta map[string]any
|
||||||
|
json.Unmarshal(c.Data, &meta)
|
||||||
|
if s, ok := meta["result_summary"]; ok {
|
||||||
|
summary, _ = s.(map[string]any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, summary, "should emit result_summary")
|
||||||
|
assert.Equal(t, float64(0.05), summary["total_cost_usd"])
|
||||||
|
assert.Equal(t, float64(5000), summary["duration_ms"])
|
||||||
|
assert.Equal(t, float64(3), summary["num_turns"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_ErrorMessage(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "error",
|
||||||
|
"error": map[string]any{"message": "rate limit exceeded"},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []chunkRecord
|
||||||
|
p := newStreamParser(recordingHandler(&chunks))
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "rate limit exceeded")
|
||||||
|
assert.False(t, p.completed)
|
||||||
|
|
||||||
|
hasError := false
|
||||||
|
for _, c := range chunks {
|
||||||
|
if c.Type == message.ChunkError {
|
||||||
|
hasError = true
|
||||||
|
assert.Contains(t, string(c.Data), "rate limit exceeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, hasError, "should emit error chunk")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_ResultIsError(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "result",
|
||||||
|
"is_error": true,
|
||||||
|
"result": "authentication failed",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []chunkRecord
|
||||||
|
p := newStreamParser(recordingHandler(&chunks))
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "authentication failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_ContextCancel(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
r, w := io.Pipe()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
p := newStreamParser(nil)
|
||||||
|
err := p.parse(ctx, r)
|
||||||
|
_ = w.Close()
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_HandlerStopsStream(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": map[string]any{"type": "text_delta", "text": "first"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": map[string]any{"type": "text_delta", "text": "second"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, chunks := stoppingHandler(2)
|
||||||
|
p := newStreamParser(handler)
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.LessOrEqual(t, len(*chunks), 3, "should stop early")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_EmptyAndInvalidLines(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
"",
|
||||||
|
"not json at all",
|
||||||
|
" ",
|
||||||
|
`{"type": "result", "num_turns": 1}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []chunkRecord
|
||||||
|
p := newStreamParser(recordingHandler(&chunks))
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, p.completed, "should complete despite invalid lines")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_MultiTurnConversation(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_start",
|
||||||
|
"content_block": map[string]any{"type": "tool_use", "name": "Read", "id": "t1"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{"type": "content_block_stop"},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "assistant",
|
||||||
|
"message": map[string]any{
|
||||||
|
"stop_reason": "tool_use",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "tool_use", "name": "Read", "id": "t1", "input": map[string]any{"path": "/tmp"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "user",
|
||||||
|
"message": map[string]any{
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "tool_result", "tool_use_id": "t1", "content": "ok"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": map[string]any{"type": "text_delta", "text": "Done reading"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "assistant",
|
||||||
|
"message": map[string]any{
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"content": []any{map[string]any{"type": "text", "text": "Done reading"}},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{"type": "result", "num_turns": 2}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []chunkRecord
|
||||||
|
p := newStreamParser(recordingHandler(&chunks))
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, p.completed)
|
||||||
|
|
||||||
|
startCount := 0
|
||||||
|
endCount := 0
|
||||||
|
for _, c := range chunks {
|
||||||
|
switch c.Type {
|
||||||
|
case message.ChunkMessageStart:
|
||||||
|
startCount++
|
||||||
|
case message.ChunkMessageEnd:
|
||||||
|
endCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// streaming tool_use(start) + streaming tool_use(stop) + assistant tool_use + tool_result + text = 5 starts
|
||||||
|
assert.GreaterOrEqual(t, startCount, 3, "should have multiple message starts for multi-turn")
|
||||||
|
assert.Equal(t, startCount, endCount, "each message_start should have matching message_end")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_ErrorStringFormat(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "error",
|
||||||
|
"error": "simple string error",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []chunkRecord
|
||||||
|
p := newStreamParser(recordingHandler(&chunks))
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "simple string error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_InputDeltaAccumulation(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_start",
|
||||||
|
"content_block": map[string]any{"type": "tool_use", "name": "Bash", "id": "t1"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": map[string]any{"type": "input_json_delta", "partial_json": `{"com`},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": map[string]any{"type": "input_json_delta", "partial_json": `mand":"ls"}`},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": map[string]any{"type": "content_block_stop"},
|
||||||
|
}),
|
||||||
|
jsonLine(map[string]any{"type": "result", "num_turns": 1}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []chunkRecord
|
||||||
|
p := newStreamParser(recordingHandler(&chunks))
|
||||||
|
err := p.parse(context.Background(), pipeWithLines(lines...))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The last input_delta chunk should contain the full accumulated input
|
||||||
|
var lastDelta string
|
||||||
|
for _, c := range chunks {
|
||||||
|
if c.Type == message.ChunkExecute {
|
||||||
|
var data map[string]any
|
||||||
|
json.Unmarshal(c.Data, &data)
|
||||||
|
if d, ok := data["input_delta"].(string); ok {
|
||||||
|
lastDelta = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, `{"command":"ls"}`, lastDelta, "input_delta should accumulate all fragments")
|
||||||
|
}
|
||||||
39
agent/sandbox/v2/claude/plat_linuxos.go
Normal file
39
agent/sandbox/v2/claude/plat_linuxos.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// linuxPlatform overrides Linux container-specific behavior.
|
||||||
|
type linuxPlatform struct {
|
||||||
|
posixBase
|
||||||
|
hasDisplay bool // true when the container has a DISPLAY env var (Desktop/VNC)
|
||||||
|
sysHome string // original system HOME (e.g., /root) before redirection
|
||||||
|
}
|
||||||
|
|
||||||
|
// XauthoritySetup handles X11 authentication cookie for Desktop containers.
|
||||||
|
//
|
||||||
|
// Desktop containers (VNC/noVNC): X Server creates .Xauthority under the
|
||||||
|
// system home (e.g., /root), but we redirect HOME to the workspace. GUI
|
||||||
|
// tools look for $HOME/.Xauthority, so we must copy the real cookie.
|
||||||
|
//
|
||||||
|
// Headless containers: no X Server, nothing to do.
|
||||||
|
func (p *linuxPlatform) XauthoritySetup(workDir string) string {
|
||||||
|
if !p.hasDisplay || p.sysHome == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
src := p.sysHome + "/.Xauthority"
|
||||||
|
dst := workDir + "/.Xauthority"
|
||||||
|
return fmt.Sprintf("[ -f %q ] && cp %q %q 2>/dev/null\n", src, src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *linuxPlatform) EnvPromptNote() string {
|
||||||
|
if p.hasDisplay {
|
||||||
|
return `
|
||||||
|
- **Desktop Environment**: You have access to a Linux desktop via VNC (GUI applications, browsers, etc.)
|
||||||
|
- **Important**: When you launch GUI applications, do NOT close them unless explicitly asked`
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *linuxPlatform) BuildScript(in scriptInput) (string, []byte) {
|
||||||
|
return p.buildBashScript(in, p.XauthoritySetup(in.workDir)), nil
|
||||||
|
}
|
||||||
17
agent/sandbox/v2/claude/plat_macos.go
Normal file
17
agent/sandbox/v2/claude/plat_macos.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
// darwinPlatform overrides macOS-specific behavior.
|
||||||
|
// Most methods are inherited from posixBase.
|
||||||
|
type darwinPlatform struct {
|
||||||
|
posixBase
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *darwinPlatform) EnvPromptNote() string {
|
||||||
|
return `
|
||||||
|
- **Desktop Environment**: You have access to the macOS desktop (GUI applications, browsers, Finder, etc.)
|
||||||
|
- **Important**: When you launch GUI applications, do NOT close them unless explicitly asked`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *darwinPlatform) BuildScript(in scriptInput) (string, []byte) {
|
||||||
|
return p.buildBashScript(in, ""), nil
|
||||||
|
}
|
||||||
114
agent/sandbox/v2/claude/plat_win.go
Normal file
114
agent/sandbox/v2/claude/plat_win.go
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// windowsPlatform is a standalone implementation for Windows — no posixBase.
|
||||||
|
type windowsPlatform struct {
|
||||||
|
workDir string
|
||||||
|
shell string
|
||||||
|
tempDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWindowsPlatform(workDir, shell, tempDir string) *windowsPlatform {
|
||||||
|
if shell == "" {
|
||||||
|
shell = "pwsh"
|
||||||
|
}
|
||||||
|
if tempDir == "" {
|
||||||
|
tempDir = workDir + `\.tmp`
|
||||||
|
}
|
||||||
|
return &windowsPlatform{workDir: workDir, shell: shell, tempDir: tempDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *windowsPlatform) OS() string { return "windows" }
|
||||||
|
func (w *windowsPlatform) Shell() string { return w.shell }
|
||||||
|
func (w *windowsPlatform) RootDir() string { return `C:\` }
|
||||||
|
func (w *windowsPlatform) ConfigDir() string { return `.claude` }
|
||||||
|
func (w *windowsPlatform) XauthoritySetup(_ string) string { return "" }
|
||||||
|
|
||||||
|
func (w *windowsPlatform) PathJoin(parts ...string) string {
|
||||||
|
return strings.Join(parts, `\`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HomeEnv sets all four HOME-related variables to prevent Windows path
|
||||||
|
// resolution issues. The HOME variable is critical: without it, tools like
|
||||||
|
// Git for Windows inherit the host HOME, causing ~ to expand to the wrong
|
||||||
|
// directory. See: 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) EnvPromptNote() string {
|
||||||
|
return `
|
||||||
|
- **Desktop Environment**: You have full access to the Windows desktop (GUI applications, browsers, etc.)
|
||||||
|
- **Important**: When you launch GUI applications (browsers, editors, etc.), do NOT close them unless explicitly asked — the user expects them to remain open`
|
||||||
|
}
|
||||||
|
|
||||||
|
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) ListDirCmd(dir string) []string {
|
||||||
|
return w.ShellCmd(fmt.Sprintf("Get-ChildItem -Name '%s'", dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *windowsPlatform) BuildScript(in scriptInput) (string, []byte) {
|
||||||
|
var b strings.Builder
|
||||||
|
noBOM := "(New-Object System.Text.UTF8Encoding $false)"
|
||||||
|
|
||||||
|
b.WriteString("[Console]::InputEncoding = [System.Text.Encoding]::UTF8\n")
|
||||||
|
b.WriteString("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n")
|
||||||
|
b.WriteString("$OutputEncoding = [System.Text.Encoding]::UTF8\n")
|
||||||
|
|
||||||
|
b.WriteString("foreach ($d in (Get-ChildItem 'C:\\Users' -Directory -ErrorAction SilentlyContinue)) {\n")
|
||||||
|
b.WriteString(" $p = Join-Path $d.FullName '.local\\bin'\n")
|
||||||
|
b.WriteString(" if (Test-Path (Join-Path $p 'claude.exe')) { $env:PATH = \"$p;$env:PATH\"; break }\n")
|
||||||
|
b.WriteString("}\n")
|
||||||
|
b.WriteString("if ($env:APPDATA) { $env:PATH = \"$env:APPDATA\\npm;$env:PATH\" }\n")
|
||||||
|
|
||||||
|
yaoDir := w.PathJoin(in.workDir, ".yao")
|
||||||
|
b.WriteString(fmt.Sprintf("if (!(Test-Path '%s')) { New-Item -ItemType Directory -Path '%s' -Force | Out-Null }\n", yaoDir, yaoDir))
|
||||||
|
|
||||||
|
if in.systemPrompt != "" {
|
||||||
|
promptDir := in.promptFile[:strings.LastIndex(in.promptFile, `\`)]
|
||||||
|
b.WriteString(fmt.Sprintf("if (!(Test-Path '%s')) { New-Item -ItemType Directory -Path '%s' -Force | Out-Null }\n", promptDir, promptDir))
|
||||||
|
escaped := strings.ReplaceAll(in.systemPrompt, "'", "''")
|
||||||
|
b.WriteString(fmt.Sprintf("[IO.File]::WriteAllText('%s', @'\n%s\n'@, %s)\n", in.promptFile, escaped, noBOM))
|
||||||
|
in.args = append(in.args, "--append-system-prompt-file", in.promptFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("claude -p")
|
||||||
|
for _, arg := range in.args {
|
||||||
|
b.WriteString(fmt.Sprintf(" '%s'", strings.ReplaceAll(arg, "'", "''")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String(), []byte(in.inputJSONL + "\n")
|
||||||
|
}
|
||||||
143
agent/sandbox/v2/claude/platform.go
Normal file
143
agent/sandbox/v2/claude/platform.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
infra "github.com/yaoapp/yao/sandbox/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// platform encapsulates all OS-dependent behaviors for the target environment.
|
||||||
|
// Upper-layer business code uses this interface exclusively, with zero platform
|
||||||
|
// branching (no if isWindows() checks).
|
||||||
|
type platform interface {
|
||||||
|
OS() string
|
||||||
|
Shell() string
|
||||||
|
HomeEnv(workDir string) map[string]string
|
||||||
|
EnvPromptNote() string
|
||||||
|
PathJoin(parts ...string) string
|
||||||
|
RootDir() string
|
||||||
|
ShellCmd(script string) []string
|
||||||
|
KillCmd(pattern string) []string
|
||||||
|
ListDirCmd(dir string) []string
|
||||||
|
ConfigDir() string
|
||||||
|
XauthoritySetup(workDir string) string
|
||||||
|
BuildScript(input scriptInput) (script string, stdin []byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
type scriptInput struct {
|
||||||
|
args []string
|
||||||
|
systemPrompt string
|
||||||
|
inputJSONL string
|
||||||
|
workDir string
|
||||||
|
promptFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
// posixBase provides shared POSIX-compatible implementation (~80% of methods)
|
||||||
|
// for macOS and Linux. darwinPlatform and linuxPlatform embed this.
|
||||||
|
type posixBase struct {
|
||||||
|
os string
|
||||||
|
workDir string
|
||||||
|
shell string
|
||||||
|
tempDir string
|
||||||
|
userHome 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) RootDir() string { return "/" }
|
||||||
|
func (b *posixBase) ConfigDir() string { return ".config/claude" }
|
||||||
|
func (b *posixBase) XauthoritySetup(_ string) string { return "" }
|
||||||
|
|
||||||
|
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) ListDirCmd(dir string) []string {
|
||||||
|
return []string{"ls", dir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBashScript is the shared bash script builder for macOS/Linux.
|
||||||
|
//
|
||||||
|
// When a system prompt is present, the script uses `set -e` to ensure that
|
||||||
|
// any failure in directory creation or prompt file writing aborts the entire
|
||||||
|
// script before Claude CLI is launched. This prevents silent fallback to
|
||||||
|
// running without a system prompt.
|
||||||
|
func (b *posixBase) buildBashScript(in scriptInput, xauthCmd string) string {
|
||||||
|
var s strings.Builder
|
||||||
|
|
||||||
|
if xauthCmd != "" {
|
||||||
|
s.WriteString(xauthCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.systemPrompt != "" {
|
||||||
|
s.WriteString("set -e\n")
|
||||||
|
s.WriteString(fmt.Sprintf("mkdir -p \"$(dirname %q)\"\n", in.promptFile))
|
||||||
|
s.WriteString(fmt.Sprintf("cat << 'PROMPTEOF' > %s\n", in.promptFile))
|
||||||
|
s.WriteString(in.systemPrompt)
|
||||||
|
s.WriteString("\nPROMPTEOF\n")
|
||||||
|
s.WriteString("set +e\n")
|
||||||
|
in.args = append(in.args, "--append-system-prompt-file", in.promptFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.WriteString("cat << 'INPUTEOF' | claude -p")
|
||||||
|
for _, arg := range in.args {
|
||||||
|
s.WriteString(fmt.Sprintf(" %q", arg))
|
||||||
|
}
|
||||||
|
s.WriteString("\n")
|
||||||
|
s.WriteString(in.inputJSONL)
|
||||||
|
s.WriteString("\nINPUTEOF")
|
||||||
|
|
||||||
|
return s.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePlatform creates the appropriate platform implementation based on
|
||||||
|
// the Computer's reported OS.
|
||||||
|
func resolvePlatform(computer infra.Computer) platform {
|
||||||
|
sys := computer.ComputerInfo().System
|
||||||
|
osName := strings.ToLower(sys.OS)
|
||||||
|
workDir := computer.GetWorkDir()
|
||||||
|
shell := sys.Shell
|
||||||
|
tempDir := sys.TempDir
|
||||||
|
|
||||||
|
base := posixBase{
|
||||||
|
os: osName, workDir: workDir,
|
||||||
|
shell: shell, tempDir: tempDir,
|
||||||
|
}
|
||||||
|
if base.shell == "" {
|
||||||
|
base.shell = "bash"
|
||||||
|
}
|
||||||
|
if base.tempDir == "" {
|
||||||
|
base.tempDir = path.Join(workDir, ".tmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DISPLAY and system HOME are not yet available via SystemInfo.
|
||||||
|
// For Desktop Linux containers (VNC/noVNC), these will be populated
|
||||||
|
// once infra reports environment variables. Until then, the Xauthority
|
||||||
|
// copy will be a no-op — same as the old code's behavior.
|
||||||
|
hasDisplay := false
|
||||||
|
sysHome := ""
|
||||||
|
|
||||||
|
switch osName {
|
||||||
|
case "darwin":
|
||||||
|
return &darwinPlatform{posixBase: base}
|
||||||
|
case "windows":
|
||||||
|
return newWindowsPlatform(workDir, shell, tempDir)
|
||||||
|
default:
|
||||||
|
return &linuxPlatform{
|
||||||
|
posixBase: base,
|
||||||
|
hasDisplay: hasDisplay,
|
||||||
|
sysHome: sysHome,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
315
agent/sandbox/v2/claude/platform_test.go
Normal file
315
agent/sandbox/v2/claude/platform_test.go
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestPosixBase(os string) posixBase {
|
||||||
|
return posixBase{
|
||||||
|
os: os,
|
||||||
|
workDir: "/workspace",
|
||||||
|
shell: "bash",
|
||||||
|
tempDir: "/tmp",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosixBase_Accessors(t *testing.T) {
|
||||||
|
b := newTestPosixBase("linux")
|
||||||
|
assert.Equal(t, "linux", b.OS())
|
||||||
|
assert.Equal(t, "bash", b.Shell())
|
||||||
|
assert.Equal(t, "/", b.RootDir())
|
||||||
|
assert.Equal(t, ".config/claude", b.ConfigDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosixBase_PathJoin(t *testing.T) {
|
||||||
|
b := newTestPosixBase("linux")
|
||||||
|
assert.Equal(t, "/workspace/.yao/config", b.PathJoin("/workspace", ".yao", "config"))
|
||||||
|
assert.Equal(t, "a/b/c", b.PathJoin("a", "b", "c"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosixBase_HomeEnv(t *testing.T) {
|
||||||
|
b := newTestPosixBase("linux")
|
||||||
|
env := b.HomeEnv("/workspace/data")
|
||||||
|
assert.Equal(t, "/workspace/data", env["HOME"])
|
||||||
|
assert.Len(t, env, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosixBase_ShellCmd(t *testing.T) {
|
||||||
|
b := newTestPosixBase("linux")
|
||||||
|
cmd := b.ShellCmd("echo hello")
|
||||||
|
require.Len(t, cmd, 3)
|
||||||
|
assert.Equal(t, "bash", cmd[0])
|
||||||
|
assert.Equal(t, "-c", cmd[1])
|
||||||
|
assert.Equal(t, "echo hello", cmd[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosixBase_KillCmd(t *testing.T) {
|
||||||
|
b := newTestPosixBase("linux")
|
||||||
|
cmd := b.KillCmd("claude")
|
||||||
|
require.Len(t, cmd, 3)
|
||||||
|
assert.Equal(t, "sh", cmd[0])
|
||||||
|
assert.Contains(t, cmd[2], "pkill")
|
||||||
|
assert.Contains(t, cmd[2], "claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosixBase_ListDirCmd(t *testing.T) {
|
||||||
|
b := newTestPosixBase("linux")
|
||||||
|
cmd := b.ListDirCmd("/workspace/.claude")
|
||||||
|
require.Len(t, cmd, 2)
|
||||||
|
assert.Equal(t, "ls", cmd[0])
|
||||||
|
assert.Equal(t, "/workspace/.claude", cmd[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosixBase_XauthoritySetup(t *testing.T) {
|
||||||
|
b := newTestPosixBase("linux")
|
||||||
|
assert.Empty(t, b.XauthoritySetup("/workspace"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosixBase_BuildBashScript_NoPrompt(t *testing.T) {
|
||||||
|
b := newTestPosixBase("linux")
|
||||||
|
in := scriptInput{
|
||||||
|
args: []string{"--verbose", "--output-format", "stream-json"},
|
||||||
|
inputJSONL: `{"type":"user","message":{"role":"user","content":"hello"}}`,
|
||||||
|
workDir: "/workspace",
|
||||||
|
promptFile: "/workspace/.yao/.system-prompt.txt",
|
||||||
|
}
|
||||||
|
script := b.buildBashScript(in, "")
|
||||||
|
assert.Contains(t, script, "cat << 'INPUTEOF' | claude -p")
|
||||||
|
assert.Contains(t, script, "--verbose")
|
||||||
|
assert.Contains(t, script, "INPUTEOF")
|
||||||
|
assert.NotContains(t, script, "PROMPTEOF")
|
||||||
|
assert.NotContains(t, script, "set -e", "set -e should not be present when no prompt is written")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosixBase_BuildBashScript_WithPrompt(t *testing.T) {
|
||||||
|
b := newTestPosixBase("linux")
|
||||||
|
in := scriptInput{
|
||||||
|
args: []string{"--verbose"},
|
||||||
|
systemPrompt: "You are a helpful assistant.",
|
||||||
|
inputJSONL: `{"type":"user"}`,
|
||||||
|
workDir: "/workspace",
|
||||||
|
promptFile: "/workspace/.yao/assistants/test-id/system-prompt.txt",
|
||||||
|
}
|
||||||
|
script := b.buildBashScript(in, "")
|
||||||
|
assert.Contains(t, script, "mkdir -p")
|
||||||
|
assert.Contains(t, script, "PROMPTEOF")
|
||||||
|
assert.Contains(t, script, "You are a helpful assistant.")
|
||||||
|
assert.Contains(t, script, "--append-system-prompt-file")
|
||||||
|
|
||||||
|
promptIdx := strings.Index(script, "PROMPTEOF")
|
||||||
|
claudeIdx := strings.Index(script, "claude -p")
|
||||||
|
assert.True(t, strings.Contains(script, "set -e"), "script should enable set -e before prompt write")
|
||||||
|
assert.True(t, strings.Contains(script, "set +e"), "script should disable set -e before claude command")
|
||||||
|
setEIdx := strings.Index(script, "set -e")
|
||||||
|
setNoEIdx := strings.Index(script, "set +e")
|
||||||
|
assert.Less(t, setEIdx, promptIdx, "set -e should come before PROMPTEOF")
|
||||||
|
assert.Less(t, promptIdx, setNoEIdx, "set +e should come after PROMPTEOF")
|
||||||
|
assert.Less(t, setNoEIdx, claudeIdx, "set +e should come before claude -p")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosixBase_BuildBashScript_WithXauth(t *testing.T) {
|
||||||
|
b := newTestPosixBase("linux")
|
||||||
|
in := scriptInput{
|
||||||
|
args: []string{"--verbose"},
|
||||||
|
inputJSONL: `{"type":"user"}`,
|
||||||
|
workDir: "/workspace",
|
||||||
|
promptFile: "/workspace/.yao/.system-prompt.txt",
|
||||||
|
}
|
||||||
|
xauth := "cp /root/.Xauthority /workspace/.Xauthority\n"
|
||||||
|
script := b.buildBashScript(in, xauth)
|
||||||
|
assert.True(t, strings.HasPrefix(script, "cp /root/.Xauthority"),
|
||||||
|
"script should start with xauth command")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Darwin ---
|
||||||
|
|
||||||
|
func TestDarwin_EnvPromptNote(t *testing.T) {
|
||||||
|
p := &darwinPlatform{posixBase: newTestPosixBase("darwin")}
|
||||||
|
note := p.EnvPromptNote()
|
||||||
|
assert.Contains(t, note, "macOS desktop")
|
||||||
|
assert.Contains(t, note, "GUI applications")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDarwin_BuildScript(t *testing.T) {
|
||||||
|
p := &darwinPlatform{posixBase: newTestPosixBase("darwin")}
|
||||||
|
script, stdin := p.BuildScript(scriptInput{
|
||||||
|
args: []string{"--verbose"},
|
||||||
|
inputJSONL: `{"type":"user"}`,
|
||||||
|
workDir: "/workspace",
|
||||||
|
promptFile: "/workspace/.yao/.system-prompt.txt",
|
||||||
|
})
|
||||||
|
assert.Contains(t, script, "claude -p")
|
||||||
|
assert.Nil(t, stdin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Linux ---
|
||||||
|
|
||||||
|
func TestLinux_XauthoritySetup_Headless(t *testing.T) {
|
||||||
|
p := &linuxPlatform{
|
||||||
|
posixBase: newTestPosixBase("linux"),
|
||||||
|
hasDisplay: false,
|
||||||
|
sysHome: "/root",
|
||||||
|
}
|
||||||
|
assert.Empty(t, p.XauthoritySetup("/workspace"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinux_XauthoritySetup_Desktop(t *testing.T) {
|
||||||
|
p := &linuxPlatform{
|
||||||
|
posixBase: newTestPosixBase("linux"),
|
||||||
|
hasDisplay: true,
|
||||||
|
sysHome: "/root",
|
||||||
|
}
|
||||||
|
cmd := p.XauthoritySetup("/workspace")
|
||||||
|
assert.Contains(t, cmd, "/root/.Xauthority")
|
||||||
|
assert.Contains(t, cmd, "/workspace/.Xauthority")
|
||||||
|
assert.Contains(t, cmd, "cp")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinux_XauthoritySetup_NoSysHome(t *testing.T) {
|
||||||
|
p := &linuxPlatform{
|
||||||
|
posixBase: newTestPosixBase("linux"),
|
||||||
|
hasDisplay: true,
|
||||||
|
sysHome: "",
|
||||||
|
}
|
||||||
|
assert.Empty(t, p.XauthoritySetup("/workspace"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinux_EnvPromptNote_Desktop(t *testing.T) {
|
||||||
|
p := &linuxPlatform{posixBase: newTestPosixBase("linux"), hasDisplay: true}
|
||||||
|
note := p.EnvPromptNote()
|
||||||
|
assert.Contains(t, note, "VNC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinux_EnvPromptNote_Headless(t *testing.T) {
|
||||||
|
p := &linuxPlatform{posixBase: newTestPosixBase("linux"), hasDisplay: false}
|
||||||
|
assert.Empty(t, p.EnvPromptNote())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinux_BuildScript_Desktop(t *testing.T) {
|
||||||
|
p := &linuxPlatform{
|
||||||
|
posixBase: newTestPosixBase("linux"),
|
||||||
|
hasDisplay: true,
|
||||||
|
sysHome: "/root",
|
||||||
|
}
|
||||||
|
script, stdin := p.BuildScript(scriptInput{
|
||||||
|
args: []string{"--verbose"},
|
||||||
|
inputJSONL: `{"type":"user"}`,
|
||||||
|
workDir: "/workspace",
|
||||||
|
promptFile: "/workspace/.yao/.system-prompt.txt",
|
||||||
|
})
|
||||||
|
assert.Contains(t, script, ".Xauthority")
|
||||||
|
assert.Nil(t, stdin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Windows ---
|
||||||
|
|
||||||
|
func TestWindows_NewDefaults(t *testing.T) {
|
||||||
|
w := newWindowsPlatform(`C:\workspace`, "", "")
|
||||||
|
assert.Equal(t, "pwsh", w.Shell())
|
||||||
|
assert.Equal(t, `C:\workspace\.tmp`, w.tempDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindows_Accessors(t *testing.T) {
|
||||||
|
w := newWindowsPlatform(`C:\workspace`, "pwsh", `C:\temp`)
|
||||||
|
assert.Equal(t, "windows", w.OS())
|
||||||
|
assert.Equal(t, `C:\`, w.RootDir())
|
||||||
|
assert.Equal(t, `.claude`, w.ConfigDir())
|
||||||
|
assert.Empty(t, w.XauthoritySetup(`C:\workspace`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindows_PathJoin(t *testing.T) {
|
||||||
|
w := newWindowsPlatform(`C:\workspace`, "pwsh", "")
|
||||||
|
assert.Equal(t, `C:\workspace\.yao\config`, w.PathJoin(`C:\workspace`, ".yao", "config"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindows_HomeEnv_ShortPath(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 paths without drive letter")
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
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_KillCmd(t *testing.T) {
|
||||||
|
w := newWindowsPlatform(`C:\ws`, "pwsh", "")
|
||||||
|
cmd := w.KillCmd("claude")
|
||||||
|
require.Len(t, cmd, 4)
|
||||||
|
assert.Equal(t, "pwsh", cmd[0])
|
||||||
|
assert.Contains(t, cmd[3], "claude")
|
||||||
|
assert.Contains(t, cmd[3], "taskkill")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindows_ListDirCmd(t *testing.T) {
|
||||||
|
w := newWindowsPlatform(`C:\ws`, "pwsh", "")
|
||||||
|
cmd := w.ListDirCmd(`C:\ws\.claude`)
|
||||||
|
require.Len(t, cmd, 4)
|
||||||
|
assert.Contains(t, cmd[3], "Get-ChildItem")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindows_BuildScript_NoPrompt(t *testing.T) {
|
||||||
|
w := newWindowsPlatform(`C:\workspace`, "pwsh", `C:\temp`)
|
||||||
|
script, stdin := w.BuildScript(scriptInput{
|
||||||
|
args: []string{"--verbose"},
|
||||||
|
inputJSONL: `{"type":"user"}`,
|
||||||
|
workDir: `C:\workspace`,
|
||||||
|
promptFile: `C:\workspace\.yao\.system-prompt.txt`,
|
||||||
|
})
|
||||||
|
assert.Contains(t, script, "UTF8")
|
||||||
|
assert.Contains(t, script, "claude -p")
|
||||||
|
assert.Contains(t, script, "'--verbose'")
|
||||||
|
require.NotNil(t, stdin)
|
||||||
|
assert.Contains(t, string(stdin), `{"type":"user"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindows_BuildScript_WithPrompt(t *testing.T) {
|
||||||
|
w := newWindowsPlatform(`C:\workspace`, "pwsh", `C:\temp`)
|
||||||
|
script, stdin := w.BuildScript(scriptInput{
|
||||||
|
args: []string{"--verbose"},
|
||||||
|
systemPrompt: "You are helpful",
|
||||||
|
inputJSONL: `{"type":"user"}`,
|
||||||
|
workDir: `C:\workspace`,
|
||||||
|
promptFile: `C:\workspace\.yao\assistants\test\system-prompt.txt`,
|
||||||
|
})
|
||||||
|
assert.Contains(t, script, "WriteAllText")
|
||||||
|
assert.Contains(t, script, "You are helpful")
|
||||||
|
assert.Contains(t, script, "--append-system-prompt-file")
|
||||||
|
require.NotNil(t, stdin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindows_EnvPromptNote(t *testing.T) {
|
||||||
|
w := newWindowsPlatform(`C:\ws`, "pwsh", "")
|
||||||
|
note := w.EnvPromptNote()
|
||||||
|
assert.Contains(t, note, "Windows desktop")
|
||||||
|
}
|
||||||
|
|
@ -3,31 +3,25 @@ package claude
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
|
"github.com/yaoapp/kun/log"
|
||||||
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
||||||
infra "github.com/yaoapp/yao/sandbox/v2"
|
infra "github.com/yaoapp/yao/sandbox/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultProxyPort = 3456
|
|
||||||
|
|
||||||
// ClaudeRunner implements the Runner interface for Claude CLI (mode=cli).
|
// ClaudeRunner implements the Runner interface for Claude CLI (mode=cli).
|
||||||
type ClaudeRunner struct {
|
type ClaudeRunner struct {
|
||||||
mode string
|
mode string
|
||||||
hasMCP bool
|
hasMCP bool
|
||||||
mcpToolPattern string // e.g. "mcp__yao__*,mcp__github__*"
|
mcpToolPattern string
|
||||||
servicePort int
|
lastCompleted bool
|
||||||
servicePath string
|
logger *agentContext.RequestLogger
|
||||||
serviceProtocol string
|
|
||||||
streamCompleted bool // set when Stream received "result"; Cleanup skips kill
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new ClaudeRunner.
|
// New creates a new ClaudeRunner.
|
||||||
|
|
@ -44,13 +38,19 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e
|
||||||
r.mode = "cli"
|
r.mode = "cli"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assistantID := req.Config.ID
|
||||||
|
prefix := ".yao/assistants/" + assistantID
|
||||||
|
if assistantID == "" {
|
||||||
|
prefix = ".claude"
|
||||||
|
}
|
||||||
|
|
||||||
steps := append([]types.PrepareStep{}, req.Config.Prepare...)
|
steps := append([]types.PrepareStep{}, req.Config.Prepare...)
|
||||||
|
|
||||||
if req.SkillsDir != "" {
|
if req.SkillsDir != "" {
|
||||||
ws := req.Computer.Workplace()
|
ws := req.Computer.Workplace()
|
||||||
if ws != nil {
|
if ws != nil {
|
||||||
src := "local:///" + req.SkillsDir
|
src := "local:///" + req.SkillsDir
|
||||||
dst := ".claude/skills"
|
dst := prefix + "/skills"
|
||||||
if _, err := ws.Copy(src, dst); err != nil {
|
if _, err := ws.Copy(src, dst); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[claude] warn: copy skills %s -> %s: %v\n", src, dst, err)
|
fmt.Fprintf(os.Stderr, "[claude] warn: copy skills %s -> %s: %v\n", src, dst, err)
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +63,7 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e
|
||||||
mcpJSON := buildMCPConfig(req.MCPServers)
|
mcpJSON := buildMCPConfig(req.MCPServers)
|
||||||
steps = append(steps, types.PrepareStep{
|
steps = append(steps, types.PrepareStep{
|
||||||
Action: "file",
|
Action: "file",
|
||||||
Path: ".claude/mcp.json",
|
Path: prefix + "/mcp.json",
|
||||||
Content: mcpJSON,
|
Content: mcpJSON,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -84,11 +84,15 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han
|
||||||
return fmt.Errorf("computer is nil")
|
return fmt.Errorf("computer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
oe := resolveOSEnv(computer, req.Config)
|
p := resolvePlatform(computer)
|
||||||
|
|
||||||
|
// Inject connector config into a2o proxy (best-effort, errors ignored).
|
||||||
|
if req.Connector != nil && req.Connector.Is(connector.OPENAI) {
|
||||||
|
injectA2OConfig(ctx, computer, req.Connector)
|
||||||
|
}
|
||||||
|
|
||||||
if req.ChatID != "" {
|
if req.ChatID != "" {
|
||||||
ws := computer.Workplace()
|
if ws := computer.Workplace(); ws != nil {
|
||||||
if ws != nil {
|
|
||||||
processed, err := prepareAttachments(ctx, req.Messages, req.ChatID, ws)
|
processed, err := prepareAttachments(ctx, req.Messages, req.ChatID, ws)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("prepareAttachments: %w", err)
|
return fmt.Errorf("prepareAttachments: %w", err)
|
||||||
|
|
@ -97,332 +101,128 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isContinuation := hasExistingSession(ctx, computer, oe)
|
cmd := r.buildCommand(ctx, req, p)
|
||||||
|
|
||||||
cmd, env, stdin := r.buildCLICommand(req, oe, isContinuation)
|
r.logger = req.Logger
|
||||||
|
if r.logger == nil {
|
||||||
streamOpts := []infra.ExecOption{infra.WithWorkDir(oe.WorkDir), infra.WithEnv(env)}
|
r.logger = agentContext.NoopLogger()
|
||||||
if len(stdin) > 0 {
|
|
||||||
streamOpts = append(streamOpts, infra.WithStdin(stdin))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "[claude] Stream cmd=%v hasMCP=%v isContinuation=%v stdinLen=%d workDir=%q\n", cmd, r.hasMCP, isContinuation, len(stdin), oe.WorkDir)
|
sess, err := startSession(ctx, computer, p, cmd, r.logger)
|
||||||
|
|
||||||
execStream, err := computer.Stream(ctx, cmd, streamOpts...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("computer.Stream: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
streamCtx, streamCancel := context.WithCancel(ctx)
|
completed, err := sess.runStream(handler)
|
||||||
defer streamCancel()
|
r.lastCompleted = completed
|
||||||
|
return err
|
||||||
// Kill claude processes only when the context is cancelled externally
|
|
||||||
// (upstream timeout, user interrupt) — NOT on normal return.
|
|
||||||
go func() {
|
|
||||||
<-streamCtx.Done()
|
|
||||||
if ctx.Err() == nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "[claude] streamCtx done: normal return, skipping kill (ctx.Err=nil)\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "[claude] streamCtx done: context cancelled externally (ctx.Err=%v), killing processes\n", ctx.Err())
|
|
||||||
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
computer.Exec(killCtx, oe.killProcessCmd("claude"))
|
|
||||||
execStream.Cancel()
|
|
||||||
}()
|
|
||||||
|
|
||||||
var stderrBuf strings.Builder
|
|
||||||
go func() {
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
for {
|
|
||||||
n, err := execStream.Stderr.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
stderrBuf.Write(buf[:n])
|
|
||||||
chunk := string(buf[:n])
|
|
||||||
if strings.Contains(strings.ToLower(chunk), "error") {
|
|
||||||
streamCancel()
|
|
||||||
io.Copy(&stderrBuf, execStream.Stderr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
parseErr := parseStreamJSON(streamCtx, execStream.Stdout, handler)
|
|
||||||
fmt.Fprintf(os.Stderr, "[claude] parseStreamJSON returned: %v\n", parseErr)
|
|
||||||
|
|
||||||
// Received "result" — Claude finished normally. Return immediately.
|
|
||||||
if errors.Is(parseErr, errStreamCompleted) {
|
|
||||||
r.streamCompleted = true
|
|
||||||
fmt.Fprintf(os.Stderr, "[claude] stream completed normally, returning nil\n")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse failed or stream ended without "result" — wait for process.
|
|
||||||
fmt.Fprintf(os.Stderr, "[claude] stream did NOT complete normally, waiting for process exit...\n")
|
|
||||||
exitCode, waitErr := execStream.Wait()
|
|
||||||
stderrStr := strings.TrimSpace(stderrBuf.String())
|
|
||||||
|
|
||||||
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 {
|
|
||||||
fmt.Fprintf(os.Stderr, "[claude] exit code=%d stderr=%q\n", exitCode, stderrStr)
|
|
||||||
if stderrStr != "" {
|
|
||||||
return fmt.Errorf("claude CLI exited with code %d: %s", exitCode, stderrStr)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("claude CLI exited with code %d", exitCode)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup kills any remaining claude processes. If the stream completed
|
// Cleanup kills any remaining claude processes. If the stream completed
|
||||||
// normally (received "result"), child processes are preserved — the user
|
// normally (received "result"), child processes are preserved.
|
||||||
// may have asked Claude to launch a browser, server, etc.
|
|
||||||
func (r *ClaudeRunner) Cleanup(ctx context.Context, computer infra.Computer) error {
|
func (r *ClaudeRunner) Cleanup(ctx context.Context, computer infra.Computer) error {
|
||||||
if computer == nil {
|
if computer == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.streamCompleted {
|
if r.lastCompleted {
|
||||||
fmt.Fprintf(os.Stderr, "[claude] cleanup: stream completed normally, skipping process kill (child processes preserved)\n")
|
if r.logger != nil {
|
||||||
|
r.logger.Info("cleanup: stream completed normally, preserving child processes")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.mode != "service" {
|
if r.mode != "service" {
|
||||||
oe := resolveOSEnv(computer, nil)
|
p := resolvePlatform(computer)
|
||||||
computer.Exec(ctx, oe.killProcessCmd("claude"))
|
computer.Exec(ctx, p.KillCmd("claude"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasExistingSession checks if a Claude CLI session exists in the workspace.
|
type a2oConnectorConfig struct {
|
||||||
func hasExistingSession(ctx context.Context, computer infra.Computer, oe *osEnv) bool {
|
Backend string `json:"backend"`
|
||||||
sessionDir := oe.pathJoin(oe.WorkDir, ".claude", "projects")
|
Model string `json:"model"`
|
||||||
result, err := computer.Exec(ctx, oe.listDirCmd(sessionDir))
|
APIKey string `json:"api_key"`
|
||||||
if err != nil || result.ExitCode != 0 {
|
Options map[string]interface{} `json:"options,omitempty"`
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(result.Stdout) != ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildCLICommand constructs the Claude CLI command, environment variables, and optional stdin bytes.
|
func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig {
|
||||||
func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, oe *osEnv, isContinuation bool) ([]string, map[string]string, []byte) {
|
settings := conn.Setting()
|
||||||
env := make(map[string]string)
|
if settings == nil {
|
||||||
|
return nil
|
||||||
if oe.isWindows() {
|
|
||||||
env["USERPROFILE"] = oe.WorkDir
|
|
||||||
if len(oe.WorkDir) >= 2 && oe.WorkDir[1] == ':' {
|
|
||||||
env["HOMEDRIVE"] = oe.WorkDir[:2]
|
|
||||||
env["HOMEPATH"] = oe.WorkDir[2:]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
env["HOME"] = oe.WorkDir
|
|
||||||
if oe.UserHome != "" {
|
|
||||||
env["XAUTHORITY"] = path.Join(oe.UserHome, ".Xauthority")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Connector != nil {
|
cfg := &a2oConnectorConfig{}
|
||||||
setting := req.Connector.Setting()
|
|
||||||
host, _ := setting["host"].(string)
|
|
||||||
key, _ := setting["key"].(string)
|
|
||||||
model, _ := setting["model"].(string)
|
|
||||||
|
|
||||||
if req.Connector.Is(connector.ANTHROPIC) {
|
if host, ok := settings["host"].(string); ok && host != "" {
|
||||||
env["ANTHROPIC_BASE_URL"] = host
|
cfg.Backend = connector.BuildAPIURL(host, "/chat/completions")
|
||||||
env["ANTHROPIC_API_KEY"] = key
|
} else if proxy, ok := settings["proxy"].(string); ok && proxy != "" {
|
||||||
} else {
|
cfg.Backend = connector.BuildAPIURL(proxy, "/chat/completions")
|
||||||
env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d", defaultProxyPort)
|
}
|
||||||
env["ANTHROPIC_API_KEY"] = "dummy"
|
if model, ok := settings["model"].(string); ok && model != "" {
|
||||||
}
|
cfg.Model = model
|
||||||
|
}
|
||||||
if model != "" {
|
if key, ok := settings["key"].(string); ok && key != "" {
|
||||||
env["ANTHROPIC_MODEL"] = model
|
cfg.APIKey = key
|
||||||
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model
|
|
||||||
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model
|
|
||||||
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model
|
|
||||||
env["CLAUDE_CODE_SUBAGENT_MODEL"] = model
|
|
||||||
}
|
|
||||||
|
|
||||||
if thinking, ok := setting["thinking"].(map[string]interface{}); ok {
|
|
||||||
thinkType, _ := thinking["type"].(string)
|
|
||||||
switch thinkType {
|
|
||||||
case "disabled":
|
|
||||||
env["MAX_THINKING_TOKENS"] = "0"
|
|
||||||
case "enabled":
|
|
||||||
if budget, ok := thinking["budget_tokens"].(float64); ok && budget > 0 {
|
|
||||||
env["MAX_THINKING_TOKENS"] = fmt.Sprintf("%d", int(budget))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Config != nil && len(req.Config.Secrets) > 0 {
|
extra := make(map[string]interface{})
|
||||||
for k, v := range req.Config.Secrets {
|
for k, v := range settings {
|
||||||
env[k] = v
|
switch k {
|
||||||
}
|
case "host", "model", "key", "proxy", "type":
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var systemPrompt string
|
|
||||||
envPrompt := buildSandboxEnvPrompt(oe)
|
|
||||||
if !isContinuation && req.SystemPrompt != "" {
|
|
||||||
systemPrompt = req.SystemPrompt + "\n\n" + envPrompt
|
|
||||||
} else if !isContinuation {
|
|
||||||
systemPrompt = envPrompt
|
|
||||||
}
|
|
||||||
|
|
||||||
var inputJSONL string
|
|
||||||
if isContinuation {
|
|
||||||
inputJSONL = buildLastUserMessageJSONL(req.Messages)
|
|
||||||
} else {
|
|
||||||
inputJSONL = buildFirstRequestJSONL(req.Messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
var args []string
|
|
||||||
|
|
||||||
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")
|
|
||||||
args = append(args, "--permission-mode", permMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
args = append(args, "--input-format", "stream-json")
|
|
||||||
args = append(args, "--output-format", "stream-json")
|
|
||||||
args = append(args, "--include-partial-messages")
|
|
||||||
args = append(args, "--verbose")
|
|
||||||
|
|
||||||
if isContinuation {
|
|
||||||
args = append(args, "--continue")
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Config != nil && req.Config.Runner.Options != nil {
|
|
||||||
for key, val := range req.Config.Runner.Options {
|
|
||||||
if flag, ok := claudeArgWhitelist[key]; ok {
|
|
||||||
args = append(args, flag, fmt.Sprintf("%v", val))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.hasMCP {
|
|
||||||
mcpPath := oe.pathJoin(oe.WorkDir, ".claude", "mcp.json")
|
|
||||||
args = append(args, "--mcp-config", mcpPath)
|
|
||||||
if r.mcpToolPattern != "" {
|
|
||||||
args = append(args, "--allowedTools", r.mcpToolPattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
script, stdin := oe.buildCLIScript(args, systemPrompt, inputJSONL)
|
|
||||||
return oe.shellCmd(script), env, stdin
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildMCPConfig creates the .mcp.json for Claude CLI based on declared servers.
|
|
||||||
// Each server delegates to "tai mcp" which implements the standard MCP protocol
|
|
||||||
// over stdio and bridges to Yao gRPC with authentication.
|
|
||||||
// Connection is configured via env vars (YAO_GRPC_ADDR, YAO_TOKEN, etc.)
|
|
||||||
// injected by the sandbox infrastructure at container start.
|
|
||||||
func buildMCPConfig(servers []types.MCPServer) []byte {
|
|
||||||
mcpServers := make(map[string]any, len(servers))
|
|
||||||
for _, s := range servers {
|
|
||||||
name := s.ServerID
|
|
||||||
if name == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
default:
|
||||||
mcpServers[name] = map[string]any{
|
extra[k] = v
|
||||||
"command": "tai",
|
|
||||||
"args": []string{"mcp", name},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(mcpServers) == 0 {
|
if len(extra) > 0 {
|
||||||
mcpServers["yao"] = map[string]any{
|
cfg.Options = extra
|
||||||
"command": "tai",
|
|
||||||
"args": []string{"mcp"},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
config := map[string]any{"mcpServers": mcpServers}
|
|
||||||
data, _ := json.Marshal(config)
|
if cfg.Backend == "" {
|
||||||
return data
|
return nil
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMCPAllowedTools generates the --allowedTools pattern from server IDs.
|
// injectA2OConfig pushes the connector config to the a2o proxy.
|
||||||
func buildMCPAllowedTools(servers []types.MCPServer) string {
|
// For box (Linux container): uses sh pipe since Docker exec stdin may not work.
|
||||||
patterns := make([]string, 0, len(servers))
|
// For host: uses WithStdin which works reliably on all platforms.
|
||||||
for _, s := range servers {
|
// Best-effort: errors are logged and ignored.
|
||||||
if s.ServerID != "" {
|
func injectA2OConfig(ctx context.Context, computer infra.Computer, conn connector.Connector) {
|
||||||
patterns = append(patterns, fmt.Sprintf("mcp__%s__*", s.ServerID))
|
cfg := buildSingleA2OConfig(conn)
|
||||||
}
|
if cfg == nil {
|
||||||
|
log.Trace("[claude] injectA2OConfig: no valid config for connector %s", conn.ID())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if len(patterns) == 0 {
|
|
||||||
return "mcp__yao__*"
|
data, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Trace("[claude] injectA2OConfig: marshal error: %v", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return strings.Join(patterns, ",")
|
|
||||||
}
|
connID := conn.ID()
|
||||||
|
var result *infra.ExecResult
|
||||||
// buildSandboxEnvPrompt generates the sandbox environment prompt with system info and working directory.
|
|
||||||
func buildSandboxEnvPrompt(oe *osEnv) string {
|
info := computer.ComputerInfo()
|
||||||
workDir := oe.WorkDir
|
if info.Kind == "host" {
|
||||||
|
result, err = computer.Exec(ctx, []string{"tai", "a2o", "config", "put", connID}, infra.WithStdin(data))
|
||||||
osName := oe.OS
|
} else {
|
||||||
if osName == "" {
|
escaped := strings.ReplaceAll(string(data), "'", "'\\''")
|
||||||
osName = "linux"
|
script := fmt.Sprintf("echo '%s' | tai a2o config put %s", escaped, connID)
|
||||||
}
|
result, err = computer.Exec(ctx, []string{"sh", "-c", script})
|
||||||
shell := oe.Shell
|
}
|
||||||
if shell == "" {
|
|
||||||
shell = "bash"
|
if err != nil {
|
||||||
}
|
log.Trace("[claude] injectA2OConfig: exec error (ignored): %v", err)
|
||||||
|
return
|
||||||
shellNote := ""
|
}
|
||||||
if oe.isWindows() {
|
if result.ExitCode != 0 {
|
||||||
shellNote = `
|
log.Trace("[claude] injectA2OConfig: exit %d stderr=%s (ignored)", result.ExitCode, result.Stderr)
|
||||||
- **Desktop Environment**: You have full access to the Windows desktop (GUI applications, browsers, etc.)
|
return
|
||||||
- **Important**: When you launch GUI applications (browsers, editors, etc.), do NOT close them unless explicitly asked — the user expects them to remain open`
|
}
|
||||||
}
|
|
||||||
|
log.Trace("[claude] injectA2OConfig: connector=%s injected ok", connID)
|
||||||
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%[4]s
|
|
||||||
|
|
||||||
## User Attachments
|
|
||||||
|
|
||||||
User-uploaded files (images, documents, code files, etc.) are placed in %[1]s/.attachments/{chatID}/
|
|
||||||
Each chat session has its own subdirectory to avoid conflicts.
|
|
||||||
When the user references an attached file, read it from this directory using the Read or Bash tool.
|
|
||||||
For image files, you can view them directly as Claude supports vision on local files.
|
|
||||||
`, workDir, osName, shell, shellNote)
|
|
||||||
}
|
|
||||||
|
|
||||||
var claudeArgWhitelist = map[string]string{
|
|
||||||
"max_turns": "--max-turns",
|
|
||||||
"disallowed_tools": "--disallowed-tools",
|
|
||||||
"allowed_tools": "--allowedTools",
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,14 @@ var cases = []e2eCase{
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var toolCallCases = []e2eCase{
|
||||||
|
{
|
||||||
|
ID: "tests.sandbox-v2.oneshot-cli",
|
||||||
|
Prompt: "Run the command 'echo refactor-ok' and tell me the output.",
|
||||||
|
Timeout: 3 * time.Minute,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func TestSandboxV2_Claude_E2E(t *testing.T) {
|
func TestSandboxV2_Claude_E2E(t *testing.T) {
|
||||||
sandboxtestutils.Prepare(t)
|
sandboxtestutils.Prepare(t)
|
||||||
defer sandboxtestutils.Clean(t)
|
defer sandboxtestutils.Clean(t)
|
||||||
|
|
@ -277,3 +285,87 @@ func mapKeys(m map[string]interface{}) []string {
|
||||||
}
|
}
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSandboxV2_Claude_ToolCallE2E verifies that tool call execution emits
|
||||||
|
// "execute" messages and that usage/result_summary metadata is propagated.
|
||||||
|
func TestSandboxV2_Claude_ToolCallE2E(t *testing.T) {
|
||||||
|
sandboxtestutils.Prepare(t)
|
||||||
|
defer sandboxtestutils.Clean(t)
|
||||||
|
|
||||||
|
require.NotNil(t, caller.AgentGetterFunc, "AgentGetterFunc should be registered after Prepare")
|
||||||
|
|
||||||
|
for _, tc := range toolCallCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.ID+"_tool_call", func(t *testing.T) {
|
||||||
|
agent, err := caller.AgentGetterFunc(tc.ID)
|
||||||
|
require.NoError(t, err, "should load assistant %s", tc.ID)
|
||||||
|
|
||||||
|
timeout := tc.Timeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 3 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID := fmt.Sprintf("e2e-tool-%s-%d", tc.ID, time.Now().UnixMilli())
|
||||||
|
ctx := agentcontext.New(
|
||||||
|
context.Background(),
|
||||||
|
&oauthtypes.AuthorizedInfo{
|
||||||
|
TeamID: "test-team-e2e",
|
||||||
|
UserID: "test-user-e2e",
|
||||||
|
},
|
||||||
|
chatID,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages := []agentcontext.Message{
|
||||||
|
{Role: "user", Content: tc.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, streamErr, "Stream should not return error")
|
||||||
|
require.NotNil(t, resp, "response should not be nil")
|
||||||
|
require.NotNil(t, resp.Completion, "completion should not be nil")
|
||||||
|
|
||||||
|
contentStr, ok := resp.Completion.Content.(string)
|
||||||
|
require.True(t, ok, "Content should be a string, got %T", resp.Completion.Content)
|
||||||
|
t.Logf("Response (%d chars): %s", len(contentStr), contentStr)
|
||||||
|
|
||||||
|
assert.Contains(t, strings.ToLower(contentStr), "refactor-ok",
|
||||||
|
"response should contain the command output")
|
||||||
|
|
||||||
|
// ── Verify buffer has execute messages ──
|
||||||
|
require.NotNil(t, ctx.Buffer, "ctx.Buffer should not be nil")
|
||||||
|
msgs := ctx.Buffer.GetMessages()
|
||||||
|
t.Logf("buffer message count: %d", len(msgs))
|
||||||
|
|
||||||
|
var executeCount int
|
||||||
|
for _, m := range msgs {
|
||||||
|
t.Logf(" seq=%d role=%s type=%s streaming=%v props_keys=%v",
|
||||||
|
m.Sequence, m.Role, m.Type, m.IsStreaming, mapKeys(m.Props))
|
||||||
|
if m.Type == "execute" {
|
||||||
|
executeCount++
|
||||||
|
assert.NotNil(t, m.Props, "execute message should have props")
|
||||||
|
if m.Props != nil {
|
||||||
|
if toolName, ok := m.Props["tool"].(string); ok {
|
||||||
|
t.Logf(" execute tool=%s status=%v", toolName, m.Props["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.GreaterOrEqual(t, executeCount, 1,
|
||||||
|
"should have at least 1 execute message (tool call)")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
186
agent/sandbox/v2/claude/session.go
Normal file
186
agent/sandbox/v2/claude/session.go
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"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 Claude 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func startSession(ctx context.Context, computer infra.Computer, p platform, cmd command, logger *agentContext.RequestLogger) (*session, error) {
|
||||||
|
opts := []infra.ExecOption{infra.WithWorkDir(cmd.workDir), infra.WithEnv(cmd.env)}
|
||||||
|
if len(cmd.stdin) > 0 {
|
||||||
|
opts = append(opts, infra.WithStdin(cmd.stdin))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("claude session starting: cmd=%s workDir=%s platform=%s stdinLen=%d",
|
||||||
|
cmd.shell, cmd.workDir, p.OS(), len(cmd.stdin))
|
||||||
|
|
||||||
|
execStream, err := computer.Stream(ctx, cmd.shell, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("computer.Stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &session{
|
||||||
|
ctx: ctx,
|
||||||
|
computer: computer,
|
||||||
|
plat: p,
|
||||||
|
exec: execStream,
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runStream executes the main stream processing loop.
|
||||||
|
// Returns (completed, error) where completed=true means Claude CLI sent
|
||||||
|
// a "result" message and the stream finished normally.
|
||||||
|
func (s *session) runStream(handler message.StreamFunc) (completed bool, err error) {
|
||||||
|
s.collectStderr()
|
||||||
|
|
||||||
|
cleanup := s.watchCancel()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
parser := newStreamParser(handler)
|
||||||
|
parseErr := parser.parse(s.ctx, s.exec.Stdout)
|
||||||
|
|
||||||
|
if parser.completed {
|
||||||
|
s.logger.Info("claude 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("claude exited with code 0 but stream incomplete and stderr present: %s", stderrStr)
|
||||||
|
errMsg := fmt.Errorf("claude CLI setup failed: %s", stderrStr)
|
||||||
|
if handler != nil {
|
||||||
|
handler(message.ChunkError, []byte(errMsg.Error()))
|
||||||
|
}
|
||||||
|
return false, errMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectStderr reads stderr in a background goroutine.
|
||||||
|
// Unlike the old code, this NEVER triggers stream cancellation.
|
||||||
|
// stderr is purely informational — logged and collected for error reporting.
|
||||||
|
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("claude stderr: %s", chunk)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchCancel monitors context cancellation and kills the Claude process.
|
||||||
|
// Returns a cleanup function that must be deferred.
|
||||||
|
func (s *session) watchCancel() func() {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
s.logger.Info("context cancelled, killing claude: %v", s.ctx.Err())
|
||||||
|
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
s.computer.Exec(killCtx, s.plat.KillCmd("claude"))
|
||||||
|
s.exec.Cancel()
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return func() { close(done) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForExit waits for the Claude process to exit with timeout protection.
|
||||||
|
// This fixes the old code's issue where Wait() could block forever.
|
||||||
|
func (s *session) waitForExit(parseErr error) error {
|
||||||
|
s.logger.Info("claude 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("claude did not exit after kill, timeout")
|
||||||
|
return fmt.Errorf("claude 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("claude exited with non-zero code: exitCode=%d stderr=%s", exitCode, stderrStr)
|
||||||
|
if stderrStr != "" {
|
||||||
|
return fmt.Errorf("claude CLI exited with code %d: %s", exitCode, stderrStr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("claude CLI exited with code %d", exitCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -5,13 +5,15 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
mathrand "math/rand"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/kun/log"
|
||||||
agentContext "github.com/yaoapp/yao/agent/context"
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
||||||
infra "github.com/yaoapp/yao/sandbox/v2"
|
infra "github.com/yaoapp/yao/sandbox/v2"
|
||||||
"github.com/yaoapp/yao/tai"
|
"github.com/yaoapp/yao/tai"
|
||||||
|
"github.com/yaoapp/yao/tai/registry"
|
||||||
"github.com/yaoapp/yao/workspace"
|
"github.com/yaoapp/yao/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -50,47 +52,53 @@ func ResolveNodeID(ctx *agentContext.Context, cfg *types.SandboxConfig, manager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ownerID := resolveOwnerID(ctx)
|
ownerID := resolveOwnerID(ctx)
|
||||||
if workspaceID == "" {
|
|
||||||
workspaceID = ownerID
|
|
||||||
}
|
|
||||||
|
|
||||||
if workspaceID != "" && workspaceID != ownerID {
|
log.Trace("[sandbox/v2] ResolveNodeID: computerID=%q workspaceID=%q ownerID=%q image=%q", computerID, workspaceID, ownerID, cfg.Computer.Image)
|
||||||
|
|
||||||
|
if workspaceID != "" {
|
||||||
wsNode, err := workspace.M().NodeForWorkspace(context.Background(), workspaceID)
|
wsNode, err := workspace.M().NodeForWorkspace(context.Background(), workspaceID)
|
||||||
if err == nil && wsNode != "" {
|
if err == nil && wsNode != "" {
|
||||||
|
log.Trace("[sandbox/v2] ResolveNodeID: workspace %s -> node %s", workspaceID, wsNode)
|
||||||
computerID = wsNode
|
computerID = wsNode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if computerID != "" {
|
if computerID == "" {
|
||||||
if node, ok := tai.GetNodeMeta(computerID); ok {
|
pickedID, err := pickNodeByFilter(cfg.Filter, cfg.Computer.Image)
|
||||||
hasContainerRuntime := node.Capabilities.Docker || node.Capabilities.K8s
|
if err != nil {
|
||||||
if node.Capabilities.HostExec && !hasContainerRuntime {
|
return "", "", fmt.Errorf("auto-select node for ResolveNodeID: %w", err)
|
||||||
return computerID, "host", nil
|
|
||||||
}
|
|
||||||
if node.Capabilities.HostExec && hasContainerRuntime && cfg.Computer.Image == "" {
|
|
||||||
return computerID, "host", nil
|
|
||||||
}
|
|
||||||
if !hasContainerRuntime {
|
|
||||||
return "", "", fmt.Errorf("node %q has no container runtime and no host_exec capability", computerID)
|
|
||||||
}
|
|
||||||
return computerID, "box", nil
|
|
||||||
}
|
}
|
||||||
|
log.Trace("[sandbox/v2] ResolveNodeID: pickNodeByFilter -> %s", pickedID)
|
||||||
|
computerID = pickedID
|
||||||
|
cfg.NodeID = pickedID
|
||||||
|
}
|
||||||
|
|
||||||
|
if node, ok := tai.GetNodeMeta(computerID); ok {
|
||||||
|
hasContainerRuntime := node.Capabilities.Docker || node.Capabilities.K8s
|
||||||
|
log.Trace("[sandbox/v2] ResolveNodeID: node=%q HostExec=%v Docker=%v K8s=%v hasContainer=%v", computerID, node.Capabilities.HostExec, node.Capabilities.Docker, node.Capabilities.K8s, hasContainerRuntime)
|
||||||
|
if node.Capabilities.HostExec && !hasContainerRuntime {
|
||||||
|
log.Trace("[sandbox/v2] ResolveNodeID: -> host (host-only node)")
|
||||||
|
return computerID, "host", nil
|
||||||
|
}
|
||||||
|
if node.Capabilities.HostExec && hasContainerRuntime && cfg.Computer.Image == "" {
|
||||||
|
log.Trace("[sandbox/v2] ResolveNodeID: -> host (dual-capable, no image)")
|
||||||
|
return computerID, "host", nil
|
||||||
|
}
|
||||||
|
if !hasContainerRuntime {
|
||||||
|
return "", "", fmt.Errorf("node %q has no container runtime and no host_exec capability", computerID)
|
||||||
|
}
|
||||||
|
log.Trace("[sandbox/v2] ResolveNodeID: -> box")
|
||||||
return computerID, "box", nil
|
return computerID, "box", nil
|
||||||
}
|
}
|
||||||
|
log.Trace("[sandbox/v2] ResolveNodeID: node %q not found in registry, assuming box", computerID)
|
||||||
if cfg.Computer.Image == "" {
|
return computerID, "box", nil
|
||||||
nodeID := cfg.NodeID
|
|
||||||
return nodeID, "host", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeID := cfg.NodeID
|
|
||||||
return nodeID, "box", nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetComputer obtains or creates a Computer for the current request.
|
// GetComputer obtains or creates a Computer for the current request.
|
||||||
// An optional connector may be passed to inject OPENAI_PROXY_* env vars.
|
// Connector config is injected per-execution inside ClaudeRunner.Stream
|
||||||
|
// via "tai a2o config put".
|
||||||
// Returns the Computer, the resolved identifier, and any error.
|
// Returns the Computer, the resolved identifier, and any error.
|
||||||
func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager, conn ...connector.Connector) (infra.Computer, string, error) {
|
func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager) (infra.Computer, string, error) {
|
||||||
ownerID := resolveOwnerID(ctx)
|
ownerID := resolveOwnerID(ctx)
|
||||||
|
|
||||||
workspaceID := ""
|
workspaceID := ""
|
||||||
|
|
@ -99,9 +107,6 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
|
||||||
workspaceID = ws
|
workspaceID = ws
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if workspaceID == "" {
|
|
||||||
workspaceID = ownerID
|
|
||||||
}
|
|
||||||
|
|
||||||
identifier := BuildIdentifier(cfg, ownerID, ctx.ChatID, ctx.AssistantID, workspaceID, ctx.Metadata)
|
identifier := BuildIdentifier(cfg, ownerID, ctx.ChatID, ctx.AssistantID, workspaceID, ctx.Metadata)
|
||||||
|
|
||||||
|
|
@ -118,24 +123,27 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workspace-wins rule: when both workspace_id and computer_id are present,
|
// Workspace-wins rule: when workspace_id is present,
|
||||||
// the workspace's bound node takes precedence over computer_id.
|
// the workspace's bound node takes precedence over computer_id.
|
||||||
if workspaceID != "" && workspaceID != ownerID {
|
if workspaceID != "" {
|
||||||
wsNode, err := workspace.M().NodeForWorkspace(context.Background(), workspaceID)
|
wsNode, err := workspace.M().NodeForWorkspace(context.Background(), workspaceID)
|
||||||
if err == nil && wsNode != "" {
|
if err == nil && wsNode != "" {
|
||||||
if computerID != "" && computerID != wsNode {
|
if computerID != "" && computerID != wsNode {
|
||||||
log.Printf("[sandbox/v2] workspace %s bound to node %s overrides computer_id %s", workspaceID, wsNode, computerID)
|
log.Trace("[sandbox/v2] workspace %s bound to node %s overrides computer_id %s", workspaceID, wsNode, computerID)
|
||||||
}
|
}
|
||||||
computerID = wsNode
|
computerID = wsNode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Trace("[sandbox/v2] GetComputer: computerID=%q workspaceID=%q ownerID=%q cfgNodeID=%q image=%q", computerID, workspaceID, ownerID, cfg.NodeID, cfg.Computer.Image)
|
||||||
|
|
||||||
if computerID != "" {
|
if computerID != "" {
|
||||||
return resolveComputerByID(cfg, manager, computerID, ownerID, identifier, workspaceID, conn...)
|
log.Trace("[sandbox/v2] GetComputer: -> resolveComputerByID(%s)", computerID)
|
||||||
|
return resolveComputerByID(cfg, manager, computerID, ownerID, identifier, workspaceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No computer_id: fall back to DSL-based dispatch (original logic).
|
log.Trace("[sandbox/v2] GetComputer: -> resolveComputerByDSL (no computerID)")
|
||||||
return resolveComputerByDSL(cfg, manager, ownerID, identifier, workspaceID, conn...)
|
return resolveComputerByDSL(cfg, manager, ownerID, identifier, workspaceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveComputerByID dispatches based on the runtime computer_id from metadata.
|
// resolveComputerByID dispatches based on the runtime computer_id from metadata.
|
||||||
|
|
@ -143,16 +151,16 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
|
||||||
func resolveComputerByID(
|
func resolveComputerByID(
|
||||||
cfg *types.SandboxConfig, manager *infra.Manager,
|
cfg *types.SandboxConfig, manager *infra.Manager,
|
||||||
computerID, ownerID, identifier, workspaceID string,
|
computerID, ownerID, identifier, workspaceID string,
|
||||||
conn ...connector.Connector,
|
|
||||||
) (infra.Computer, string, error) {
|
) (infra.Computer, string, error) {
|
||||||
|
|
||||||
// 1) Check if computer_id is a known Tai node (host or node kind).
|
// 1) Check if computer_id is a known Tai node (host or node kind).
|
||||||
if node, ok := tai.GetNodeMeta(computerID); ok {
|
if node, ok := tai.GetNodeMeta(computerID); ok {
|
||||||
cfg.NodeID = computerID
|
cfg.NodeID = computerID
|
||||||
hasContainerRuntime := node.Capabilities.Docker || node.Capabilities.K8s
|
hasContainerRuntime := node.Capabilities.Docker || node.Capabilities.K8s
|
||||||
|
log.Trace("[sandbox/v2] resolveComputerByID: node=%q found=true HostExec=%v Docker=%v K8s=%v hasContainer=%v image=%q", computerID, node.Capabilities.HostExec, node.Capabilities.Docker, node.Capabilities.K8s, hasContainerRuntime, cfg.Computer.Image)
|
||||||
|
|
||||||
if node.Capabilities.HostExec && !hasContainerRuntime {
|
if node.Capabilities.HostExec && !hasContainerRuntime {
|
||||||
// Host-only node: must use host mode regardless of DSL image config.
|
log.Trace("[sandbox/v2] resolveComputerByID: -> host (host-only node)")
|
||||||
cfg.Kind = "host"
|
cfg.Kind = "host"
|
||||||
host, err := manager.Host(context.Background(), computerID)
|
host, err := manager.Host(context.Background(), computerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -179,7 +187,7 @@ func resolveComputerByID(
|
||||||
|
|
||||||
// Node with container runtime and DSL has image: create/reuse a box.
|
// Node with container runtime and DSL has image: create/reuse a box.
|
||||||
cfg.Kind = "box"
|
cfg.Kind = "box"
|
||||||
return resolveBox(cfg, manager, ownerID, identifier, workspaceID, conn...)
|
return resolveBox(cfg, manager, ownerID, identifier, workspaceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Check if computer_id is an existing box ID.
|
// 2) Check if computer_id is an existing box ID.
|
||||||
|
|
@ -199,42 +207,45 @@ func resolveComputerByID(
|
||||||
func resolveComputerByDSL(
|
func resolveComputerByDSL(
|
||||||
cfg *types.SandboxConfig, manager *infra.Manager,
|
cfg *types.SandboxConfig, manager *infra.Manager,
|
||||||
ownerID, identifier, workspaceID string,
|
ownerID, identifier, workspaceID string,
|
||||||
conn ...connector.Connector,
|
|
||||||
) (infra.Computer, string, error) {
|
) (infra.Computer, string, error) {
|
||||||
|
|
||||||
// Host mode: no image → host computer.
|
log.Trace("[sandbox/v2] resolveComputerByDSL: cfgNodeID=%q image=%q", cfg.NodeID, cfg.Computer.Image)
|
||||||
if cfg.Computer.Image == "" {
|
|
||||||
cfg.Kind = "host"
|
if cfg.NodeID == "" {
|
||||||
nodeID := cfg.NodeID
|
pickedID, err := pickNodeByFilter(cfg.Filter, cfg.Computer.Image)
|
||||||
if nodeID == "" {
|
|
||||||
return nil, identifier, fmt.Errorf("host mode requires a nodeID (set in sandbox.yao or workspace)")
|
|
||||||
}
|
|
||||||
host, err := manager.Host(context.Background(), nodeID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, identifier, fmt.Errorf("get host computer: %w", err)
|
return nil, identifier, fmt.Errorf("auto-select node: %w", err)
|
||||||
}
|
}
|
||||||
host.BindWorkplace(workspaceID)
|
log.Trace("[sandbox/v2] resolveComputerByDSL: pickNodeByFilter -> %s", pickedID)
|
||||||
return host, identifier, nil
|
cfg.NodeID = pickedID
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.Kind = "box"
|
log.Trace("[sandbox/v2] resolveComputerByDSL: -> resolveComputerByID(%s)", cfg.NodeID)
|
||||||
return resolveBox(cfg, manager, ownerID, identifier, workspaceID, conn...)
|
return resolveComputerByID(cfg, manager, cfg.NodeID, ownerID, identifier, workspaceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveBox reuses or creates a box container.
|
// resolveBox reuses or creates a box container.
|
||||||
func resolveBox(
|
func resolveBox(
|
||||||
cfg *types.SandboxConfig, manager *infra.Manager,
|
cfg *types.SandboxConfig, manager *infra.Manager,
|
||||||
ownerID, identifier, workspaceID string,
|
ownerID, identifier, workspaceID string,
|
||||||
conn ...connector.Connector,
|
|
||||||
) (infra.Computer, string, error) {
|
) (infra.Computer, string, error) {
|
||||||
|
|
||||||
|
if workspaceID == "" && cfg.NodeID != "" {
|
||||||
|
workspaceID = workspace.DefaultWorkspaceID(ownerID, cfg.NodeID)
|
||||||
|
cfg.WorkspaceID = workspaceID
|
||||||
|
if dot := strings.LastIndex(identifier, "."); dot >= 0 {
|
||||||
|
identifier = identifier[:dot+1] + workspaceID
|
||||||
|
cfg.ID = identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reuse: non-empty identifier → try Get first.
|
// Reuse: non-empty identifier → try Get first.
|
||||||
if identifier != "" {
|
if identifier != "" {
|
||||||
box, err := manager.Get(context.Background(), identifier)
|
box, err := manager.Get(context.Background(), identifier)
|
||||||
if err == nil && box != nil {
|
if err == nil && box != nil {
|
||||||
if box.IsStopped() {
|
if box.IsStopped() {
|
||||||
if startErr := manager.StartBox(context.Background(), identifier); startErr != nil {
|
if startErr := manager.StartBox(context.Background(), identifier); startErr != nil {
|
||||||
log.Printf("[sandbox/v2] auto-start stopped box %s failed: %v, creating new", identifier, startErr)
|
log.Trace("[sandbox/v2] auto-start stopped box %s failed: %v, creating new", identifier, startErr)
|
||||||
} else {
|
} else {
|
||||||
box.BindWorkplace(workspaceID)
|
box.BindWorkplace(workspaceID)
|
||||||
return box, identifier, nil
|
return box, identifier, nil
|
||||||
|
|
@ -247,14 +258,11 @@ func resolveBox(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new box.
|
// Create new box.
|
||||||
var c connector.Connector
|
createOpts, err := BuildCreateOptions(cfg, identifier, ownerID, workspaceID)
|
||||||
if len(conn) > 0 {
|
|
||||||
c = conn[0]
|
|
||||||
}
|
|
||||||
createOpts, err := BuildCreateOptions(cfg, identifier, ownerID, workspaceID, c)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, identifier, fmt.Errorf("build create options: %w", err)
|
return nil, identifier, fmt.Errorf("build create options: %w", err)
|
||||||
}
|
}
|
||||||
|
log.Trace("[sandbox/v2] resolveBox: createOpts NodeID=%q Image=%q WorkspaceID=%q ID=%q Owner=%q", createOpts.NodeID, createOpts.Image, createOpts.WorkspaceID, createOpts.ID, createOpts.Owner)
|
||||||
|
|
||||||
// Oneshot with empty identifier: generate a random one.
|
// Oneshot with empty identifier: generate a random one.
|
||||||
if createOpts.ID == "" {
|
if createOpts.ID == "" {
|
||||||
|
|
@ -283,7 +291,7 @@ func LifecycleAction(ctx context.Context, cfg *types.SandboxConfig, computer inf
|
||||||
case "oneshot":
|
case "oneshot":
|
||||||
if info.Kind == "box" && manager != nil {
|
if info.Kind == "box" && manager != nil {
|
||||||
if err := manager.Remove(ctx, cfg.ID); err != nil {
|
if err := manager.Remove(ctx, cfg.ID); err != nil {
|
||||||
log.Printf("[sandbox/v2] oneshot remove %s: %v", cfg.ID, err)
|
log.Trace("[sandbox/v2] oneshot remove %s: %v", cfg.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,6 +318,71 @@ func resolveOwnerID(ctx *agentContext.Context) string {
|
||||||
return "anonymous"
|
return "anonymous"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pickNodeByFilter selects a random online node that satisfies the given filter
|
||||||
|
// and image requirement. If image is non-empty, candidate nodes must have a
|
||||||
|
// container runtime (Docker or K8s).
|
||||||
|
func pickNodeByFilter(filter *types.ComputerFilter, image string) (string, error) {
|
||||||
|
reg := registry.Global()
|
||||||
|
if reg == nil {
|
||||||
|
return "", fmt.Errorf("tai registry not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := reg.List()
|
||||||
|
var candidates []string
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.Status != "online" && n.Status != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter != nil {
|
||||||
|
if filter.OS != "" && !strings.EqualFold(n.System.OS, filter.OS) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter.Arch != "" && !strings.EqualFold(n.System.Arch, filter.Arch) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(filter.Kind) > 0 {
|
||||||
|
matched := false
|
||||||
|
for _, k := range filter.Kind {
|
||||||
|
switch strings.ToLower(k) {
|
||||||
|
case "host":
|
||||||
|
if n.Capabilities.HostExec {
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
case "box":
|
||||||
|
if n.Capabilities.Docker || n.Capabilities.K8s {
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if image != "" && !(n.Capabilities.Docker || n.Capabilities.K8s) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates = append(candidates, n.TaiID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
kind := ""
|
||||||
|
os := ""
|
||||||
|
arch := ""
|
||||||
|
if filter != nil {
|
||||||
|
kind = fmt.Sprintf("%v", []string(filter.Kind))
|
||||||
|
os = filter.OS
|
||||||
|
arch = filter.Arch
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no online node matches filter (kind=%s os=%s arch=%s image=%s)", kind, os, arch, image)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[mathrand.Intn(len(candidates))], nil
|
||||||
|
}
|
||||||
|
|
||||||
func randomID() string {
|
func randomID() string {
|
||||||
b := make([]byte, 8)
|
b := make([]byte, 8)
|
||||||
_, _ = rand.Read(b)
|
_, _ = rand.Read(b)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
package sandboxv2
|
package sandboxv2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector"
|
|
||||||
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
"github.com/yaoapp/yao/agent/sandbox/v2/types"
|
||||||
infra "github.com/yaoapp/yao/sandbox/v2"
|
infra "github.com/yaoapp/yao/sandbox/v2"
|
||||||
)
|
)
|
||||||
|
|
@ -21,12 +19,13 @@ func resolveEnvRef(value string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildCreateOptions converts a SandboxConfig into the V2 infrastructure
|
// BuildCreateOptions converts a SandboxConfig into the V2 infrastructure
|
||||||
// CreateOptions. An optional connector is used to inject OPENAI_PROXY_*
|
// CreateOptions. Connector config injection is handled separately via the
|
||||||
// environment variables when the connector is OpenAI-compatible (non-Anthropic).
|
// a2o HTTP API (POST /config) after the container starts.
|
||||||
func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspaceID string, conn ...connector.Connector) (infra.CreateOptions, error) {
|
func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspaceID string) (infra.CreateOptions, error) {
|
||||||
opts := infra.CreateOptions{
|
opts := infra.CreateOptions{
|
||||||
ID: identifier,
|
ID: identifier,
|
||||||
Owner: ownerID,
|
Owner: ownerID,
|
||||||
|
NodeID: cfg.NodeID,
|
||||||
Image: cfg.Computer.Image,
|
Image: cfg.Computer.Image,
|
||||||
WorkDir: cfg.Computer.WorkDir,
|
WorkDir: cfg.Computer.WorkDir,
|
||||||
User: cfg.Computer.User,
|
User: cfg.Computer.User,
|
||||||
|
|
@ -132,12 +131,6 @@ func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspace
|
||||||
opts.Env = make(map[string]string)
|
opts.Env = make(map[string]string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject OPENAI_PROXY_* when connector is OpenAI-compatible (non-Anthropic).
|
|
||||||
// The a2o proxy inside the container translates Anthropic API → OpenAI API.
|
|
||||||
if len(conn) > 0 && conn[0] != nil && !conn[0].Is(connector.ANTHROPIC) {
|
|
||||||
injectProxyEnv(opts.Env, conn[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject VNC_* environment variables from config.
|
// Inject VNC_* environment variables from config.
|
||||||
if cfg.Computer.VNC.Enabled {
|
if cfg.Computer.VNC.Enabled {
|
||||||
opts.Env["VNC_ENABLED"] = "true"
|
opts.Env["VNC_ENABLED"] = "true"
|
||||||
|
|
@ -155,42 +148,6 @@ func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspace
|
||||||
return opts, nil
|
return opts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// injectProxyEnv extracts backend URL, model, and API key from an
|
|
||||||
// OpenAI-compatible connector's settings and writes them as OPENAI_PROXY_*
|
|
||||||
// environment variables into env.
|
|
||||||
func injectProxyEnv(env map[string]string, conn connector.Connector) {
|
|
||||||
settings := conn.Setting()
|
|
||||||
if settings == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if host, ok := settings["host"].(string); ok && host != "" {
|
|
||||||
env["OPENAI_PROXY_BACKEND"] = host
|
|
||||||
}
|
|
||||||
if model, ok := settings["model"].(string); ok && model != "" {
|
|
||||||
env["OPENAI_PROXY_MODEL"] = model
|
|
||||||
}
|
|
||||||
if key, ok := settings["key"].(string); ok && key != "" {
|
|
||||||
env["OPENAI_PROXY_API_KEY"] = key
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward extra options as JSON.
|
|
||||||
extra := make(map[string]interface{})
|
|
||||||
for k, v := range settings {
|
|
||||||
switch k {
|
|
||||||
case "host", "model", "key", "proxy", "type":
|
|
||||||
continue
|
|
||||||
default:
|
|
||||||
extra[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(extra) > 0 {
|
|
||||||
if data, err := json.Marshal(extra); err == nil {
|
|
||||||
env["OPENAI_PROXY_OPTIONS"] = string(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseMemory converts a human-readable memory string to bytes.
|
// parseMemory converts a human-readable memory string to bytes.
|
||||||
// Supported formats: "4GB", "4G", "4g", "512MB", "512M", "512m", "1024KB", "1024K", "1024".
|
// Supported formats: "4GB", "4G", "4g", "512MB", "512M", "512m", "1024KB", "1024K", "1024".
|
||||||
func parseMemory(s string) (int64, error) {
|
func parseMemory(s string) (int64, error) {
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ func ExecuteSandboxStream(
|
||||||
loadingClosed := false
|
loadingClosed := false
|
||||||
wrappedHandler := func(chunkType message.StreamChunkType, data []byte) int {
|
wrappedHandler := func(chunkType message.StreamChunkType, data []byte) int {
|
||||||
if !loadingClosed && req.LoadingMsgID != "" {
|
if !loadingClosed && req.LoadingMsgID != "" {
|
||||||
if chunkType == message.ChunkText || chunkType == message.ChunkToolCall || chunkType == message.ChunkMessageStart {
|
if chunkType == message.ChunkText || chunkType == message.ChunkToolCall || chunkType == message.ChunkExecute || chunkType == message.ChunkMessageStart {
|
||||||
closeLoading(ctx, req.LoadingMsgID)
|
closeLoading(ctx, req.LoadingMsgID)
|
||||||
loadingClosed = true
|
loadingClosed = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,30 @@ type SandboxConfig struct {
|
||||||
DisplayName string `json:"-" yaml:"-"`
|
DisplayName string `json:"-" yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StringOrArray accepts both a single string and an array of strings in JSON/YAML.
|
||||||
|
//
|
||||||
|
// "host" → ["host"]
|
||||||
|
// ["host", "box"] → ["host", "box"]
|
||||||
|
type StringOrArray []string
|
||||||
|
|
||||||
|
func (s *StringOrArray) UnmarshalJSON(data []byte) error {
|
||||||
|
var str string
|
||||||
|
if err := json.Unmarshal(data, &str); err == nil {
|
||||||
|
*s = []string{str}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var arr []string
|
||||||
|
if err := json.Unmarshal(data, &arr); err == nil {
|
||||||
|
*s = arr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("StringOrArray: expected a string or an array of strings")
|
||||||
|
}
|
||||||
|
|
||||||
// ComputerFilter defines the query parameters for GET /computer/options.
|
// ComputerFilter defines the query parameters for GET /computer/options.
|
||||||
// Declared in DSL sandbox.filter; frontend passes it through to the API.
|
// Declared in DSL sandbox.filter; frontend passes it through to the API.
|
||||||
type ComputerFilter struct {
|
type ComputerFilter struct {
|
||||||
Kind string `json:"kind,omitempty" yaml:"kind,omitempty"`
|
Kind StringOrArray `json:"kind,omitempty" yaml:"kind,omitempty"`
|
||||||
Image string `json:"image,omitempty" yaml:"image,omitempty"`
|
Image string `json:"image,omitempty" yaml:"image,omitempty"`
|
||||||
VNC *bool `json:"vnc,omitempty" yaml:"vnc,omitempty"`
|
VNC *bool `json:"vnc,omitempty" yaml:"vnc,omitempty"`
|
||||||
OS string `json:"os,omitempty" yaml:"os,omitempty"`
|
OS string `json:"os,omitempty" yaml:"os,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -53,5 +53,6 @@ type StreamRequest struct {
|
||||||
Messages []agentContext.Message
|
Messages []agentContext.Message
|
||||||
SystemPrompt string
|
SystemPrompt string
|
||||||
ChatID string
|
ChatID string
|
||||||
Token *SandboxToken // current user's sandbox token for MCP callbacks
|
Token *SandboxToken // current user's sandbox token for MCP callbacks
|
||||||
|
Logger *agentContext.RequestLogger // request-scoped logger propagated from agent context
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,19 @@ package workspace
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
|
"github.com/yaoapp/yao/tai/registry"
|
||||||
ws "github.com/yaoapp/yao/workspace"
|
ws "github.com/yaoapp/yao/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -96,13 +98,24 @@ type renameRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type workspaceResponse struct {
|
type workspaceResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Labels map[string]string `json:"labels,omitempty"`
|
NodeName string `json:"node_name,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
NodeOS string `json:"node_os,omitempty"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
NodeArch string `json:"node_arch,omitempty"`
|
||||||
|
NodeKind string `json:"node_kind,omitempty"`
|
||||||
|
NodeOnline bool `json:"node_online"`
|
||||||
|
NodeCapabilities map[string]bool `json:"node_capabilities,omitempty"`
|
||||||
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type optionsResponse struct {
|
||||||
|
Data []workspaceResponse `json:"data"`
|
||||||
|
HasOnlineNodes bool `json:"has_online_nodes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toResponse(w *ws.Workspace) workspaceResponse {
|
func toResponse(w *ws.Workspace) workspaceResponse {
|
||||||
|
|
@ -178,12 +191,13 @@ func handleList(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleOptions returns workspace options for the InputArea selector.
|
// handleOptions returns workspace options for the InputArea selector.
|
||||||
// Reuses the same logic as handleList (Manager.List with owner+node filter).
|
// Each workspace is enriched with its node's display info (name, OS, arch, kind, online).
|
||||||
// Separated as a dedicated endpoint for clear API responsibility boundary.
|
// The response also includes has_online_nodes so the frontend can determine sendBlocked
|
||||||
|
// even when the workspace list is empty.
|
||||||
func handleOptions(c *gin.Context) {
|
func handleOptions(c *gin.Context) {
|
||||||
m := mgr()
|
m := mgr()
|
||||||
if m == nil {
|
if m == nil {
|
||||||
response.RespondWithSuccess(c, http.StatusOK, []workspaceResponse{})
|
response.RespondWithSuccess(c, http.StatusOK, optionsResponse{Data: []workspaceResponse{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,21 +206,95 @@ func handleOptions(c *gin.Context) {
|
||||||
|
|
||||||
list, err := m.List(context.Background(), ws.ListOptions{
|
list, err := m.List(context.Background(), ws.ListOptions{
|
||||||
Owner: owner,
|
Owner: owner,
|
||||||
Node: c.Query("node"),
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nodeMap := buildNodeMap()
|
||||||
|
hasOnline := false
|
||||||
|
for _, n := range nodeMap {
|
||||||
|
if n.online {
|
||||||
|
hasOnline = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result := make([]workspaceResponse, 0, len(list))
|
result := make([]workspaceResponse, 0, len(list))
|
||||||
for _, w := range list {
|
for _, w := range list {
|
||||||
result = append(result, toResponse(w))
|
r := toResponse(w)
|
||||||
|
if info, ok := nodeMap[w.Node]; ok {
|
||||||
|
r.NodeName = info.displayName
|
||||||
|
r.NodeOS = info.os
|
||||||
|
r.NodeArch = info.arch
|
||||||
|
r.NodeKind = info.kind
|
||||||
|
r.NodeOnline = info.online
|
||||||
|
r.NodeCapabilities = info.capabilities
|
||||||
|
}
|
||||||
|
result = append(result, r)
|
||||||
}
|
}
|
||||||
sort.Slice(result, func(i, j int) bool {
|
sort.Slice(result, func(i, j int) bool {
|
||||||
return result[i].CreatedAt > result[j].CreatedAt
|
return result[i].CreatedAt > result[j].CreatedAt
|
||||||
})
|
})
|
||||||
response.RespondWithSuccess(c, http.StatusOK, result)
|
|
||||||
|
response.RespondWithSuccess(c, http.StatusOK, optionsResponse{
|
||||||
|
Data: result,
|
||||||
|
HasOnlineNodes: hasOnline,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type nodeInfo struct {
|
||||||
|
displayName string
|
||||||
|
os string
|
||||||
|
arch string
|
||||||
|
kind string
|
||||||
|
online bool
|
||||||
|
capabilities map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNodeMap() map[string]nodeInfo {
|
||||||
|
reg := registry.Global()
|
||||||
|
if reg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
nodes := reg.List()
|
||||||
|
m := make(map[string]nodeInfo, len(nodes))
|
||||||
|
for _, n := range nodes {
|
||||||
|
kind := "node"
|
||||||
|
if n.Mode == "local" {
|
||||||
|
kind = "host"
|
||||||
|
}
|
||||||
|
name := n.DisplayName
|
||||||
|
if name == "" {
|
||||||
|
name = n.System.Hostname
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = n.TaiID
|
||||||
|
}
|
||||||
|
caps := map[string]bool{}
|
||||||
|
if n.Capabilities.HostExec {
|
||||||
|
caps["host_exec"] = true
|
||||||
|
}
|
||||||
|
if n.Capabilities.Docker {
|
||||||
|
caps["docker"] = true
|
||||||
|
}
|
||||||
|
if n.Capabilities.K8s {
|
||||||
|
caps["k8s"] = true
|
||||||
|
}
|
||||||
|
if n.Capabilities.VNC {
|
||||||
|
caps["vnc"] = true
|
||||||
|
}
|
||||||
|
m[n.TaiID] = nodeInfo{
|
||||||
|
displayName: name,
|
||||||
|
os: strings.ToLower(n.System.OS),
|
||||||
|
arch: strings.ToLower(n.System.Arch),
|
||||||
|
kind: kind,
|
||||||
|
online: n.Status == "online" || n.Status == "",
|
||||||
|
capabilities: caps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCreate(c *gin.Context) {
|
func handleCreate(c *gin.Context) {
|
||||||
|
|
@ -329,16 +417,16 @@ func handleReadFile(c *gin.Context) {
|
||||||
path = path[1:]
|
path = path[1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[workspace] handleReadFile id=%s path=%q\n", c.Param("id"), path)
|
log.Trace("[workspace] handleReadFile id=%s path=%q", c.Param("id"), path)
|
||||||
|
|
||||||
data, err := mgr().ReadFile(context.Background(), c.Param("id"), path)
|
data, err := mgr().ReadFile(context.Background(), c.Param("id"), path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[workspace] ReadFile error: %v\n", err)
|
log.Trace("[workspace] ReadFile error: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[workspace] ReadFile ok, size=%d, encoding=%q\n", len(data), c.Query("encoding"))
|
log.Trace("[workspace] ReadFile ok, size=%d, encoding=%q", len(data), c.Query("encoding"))
|
||||||
|
|
||||||
if c.Query("encoding") == "base64" {
|
if c.Query("encoding") == "base64" {
|
||||||
response.RespondWithSuccess(c, http.StatusOK, gin.H{
|
response.RespondWithSuccess(c, http.StatusOK, gin.H{
|
||||||
|
|
@ -353,7 +441,7 @@ func handleReadFile(c *gin.Context) {
|
||||||
if mimeType == "" {
|
if mimeType == "" {
|
||||||
mimeType = "application/octet-stream"
|
mimeType = "application/octet-stream"
|
||||||
}
|
}
|
||||||
fmt.Printf("[workspace] serving ext=%q mime=%q size=%d\n", ext, mimeType, len(data))
|
log.Trace("[workspace] serving ext=%q mime=%q size=%d", ext, mimeType, len(data))
|
||||||
c.Data(http.StatusOK, mimeType, data)
|
c.Data(http.StatusOK, mimeType, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
goruntime "runtime"
|
goruntime "runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -123,15 +124,20 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
|
||||||
if targetNode == "" {
|
if targetNode == "" {
|
||||||
return nil, fmt.Errorf("sandbox: resolve workspace %q: no available node", opts.WorkspaceID)
|
return nil, fmt.Errorf("sandbox: resolve workspace %q: no available node", opts.WorkspaceID)
|
||||||
}
|
}
|
||||||
|
wsID := opts.WorkspaceID
|
||||||
|
if wsID == opts.Owner {
|
||||||
|
wsID = workspace.DefaultWorkspaceID(opts.Owner, targetNode)
|
||||||
|
}
|
||||||
_, err = wsm.Create(ctx, workspace.CreateOptions{
|
_, err = wsm.Create(ctx, workspace.CreateOptions{
|
||||||
ID: opts.WorkspaceID,
|
ID: wsID,
|
||||||
Name: opts.WorkspaceID,
|
Name: defaultWorkspaceName(opts.Locale),
|
||||||
Owner: opts.Owner,
|
Owner: opts.Owner,
|
||||||
Node: targetNode,
|
Node: targetNode,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("sandbox: auto-create workspace %q: %w", opts.WorkspaceID, err)
|
return nil, fmt.Errorf("sandbox: auto-create workspace %q: %w", wsID, err)
|
||||||
}
|
}
|
||||||
|
opts.WorkspaceID = wsID
|
||||||
nodeID = targetNode
|
nodeID = targetNode
|
||||||
} else {
|
} else {
|
||||||
nodeID = node
|
nodeID = node
|
||||||
|
|
@ -482,6 +488,13 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultWorkspaceName(locale string) string {
|
||||||
|
if strings.HasPrefix(strings.ToLower(locale), "zh") {
|
||||||
|
return "默认工作区"
|
||||||
|
}
|
||||||
|
return "Default Workspace"
|
||||||
|
}
|
||||||
|
|
||||||
// inferSystemInfo derives static SystemInfo for a container from image metadata
|
// inferSystemInfo derives static SystemInfo for a container from image metadata
|
||||||
// and Tai host resources. OS/Arch/Shell come from the image; Hostname/NumCPU/TotalMem
|
// and Tai host resources. OS/Arch/Shell come from the image; Hostname/NumCPU/TotalMem
|
||||||
// come from the Tai host.
|
// come from the Tai host.
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ type CreateOptions struct {
|
||||||
MountMode string
|
MountMode string
|
||||||
MountPath string
|
MountPath string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
|
Locale string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListOptions struct {
|
type ListOptions struct {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package workspace
|
package workspace
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -65,6 +66,14 @@ func generateID() string {
|
||||||
return fmt.Sprintf("ws-%s", uuid.New().String()[:12])
|
return fmt.Sprintf("ws-%s", uuid.New().String()[:12])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultWorkspaceID returns a deterministic workspace ID for the given
|
||||||
|
// owner+node pair. The same inputs always produce the same ID, while
|
||||||
|
// different nodes produce different IDs.
|
||||||
|
func DefaultWorkspaceID(ownerID, nodeID string) string {
|
||||||
|
h := sha256.Sum256([]byte(ownerID + ":" + nodeID))
|
||||||
|
return fmt.Sprintf("ws-%x", h[:6])
|
||||||
|
}
|
||||||
|
|
||||||
func marshalMeta(ws *Workspace) ([]byte, error) {
|
func marshalMeta(ws *Workspace) ([]byte, error) {
|
||||||
return json.MarshalIndent(ws, "", " ")
|
return json.MarshalIndent(ws, "", " ")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue