From 2084e204796e8080dfa71542a38f2ff3522d12c8 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 13 Apr 2026 14:47:08 +0800 Subject: [PATCH 1/4] feat(workspace): add workspace ID retrieval and integration into environment configuration - Implemented GetWorkspaceID method in the context package to retrieve the workspace ID. - Updated buildContextVariables to include WORKSPACE_ID in the context variables if available. - Enhanced buildEnv function to set CTX_WORKSPACE_ID in the environment configuration based on the workspace ID. - Refactored role connector handling to support multiple connectors in the environment setup. - Added tests for new role connector functionality and workspace ID integration. --- agent/assistant/build.go | 5 + agent/assistant/sandbox_v2.go | 1 + agent/context/context.go | 8 + agent/sandbox/v2/claude/command.go | 122 ++++++++- agent/sandbox/v2/claude/command_test.go | 347 ++++++++++++++++++++++++ agent/sandbox/v2/claude/runner.go | 90 +++++- agent/sandbox/v2/types/config.go | 36 ++- agent/sandbox/v2/types/config_test.go | 94 +++++++ agent/sandbox/v2/types/runner.go | 1 + tai/workspace/workspace.go | 7 + workspace/manager.go | 14 +- 11 files changed, 708 insertions(+), 17 deletions(-) create mode 100644 agent/sandbox/v2/types/config_test.go diff --git a/agent/assistant/build.go b/agent/assistant/build.go index 260b22ba..e7d7f35c 100644 --- a/agent/assistant/build.go +++ b/agent/assistant/build.go @@ -203,6 +203,11 @@ func (ast *Assistant) buildContextVariables(ctx *context.Context) map[string]str } } + // Workspace info + if workspaceID, err := ctx.GetWorkspaceID(); err == nil { + vars["WORKSPACE_ID"] = workspaceID + } + if ctx == nil { return vars } diff --git a/agent/assistant/sandbox_v2.go b/agent/assistant/sandbox_v2.go index d01e33ba..28c7cf45 100644 --- a/agent/assistant/sandbox_v2.go +++ b/agent/assistant/sandbox_v2.go @@ -214,6 +214,7 @@ func (ast *Assistant) executeSandboxV2Stream( ChatID: ctx.ChatID, Token: tok, Logger: ctx.Logger, + UserExplicit: p.Options != nil && p.Options.Connector != "", } execReq := &sandboxv2.ExecuteRequest{ diff --git a/agent/context/context.go b/agent/context/context.go index 05582029..3926c369 100644 --- a/agent/context/context.go +++ b/agent/context/context.go @@ -559,3 +559,11 @@ func (ctx *Context) MergeMetadata(metadata map[string]interface{}) { ctx.Metadata[k] = v } } + +// GetWorkspaceID returns the ID of the workspace +func (ctx *Context) GetWorkspaceID() (string, error) { + if ctx.workspace == nil { + return "", nil + } + return ctx.workspace.GetID() +} diff --git a/agent/sandbox/v2/claude/command.go b/agent/sandbox/v2/claude/command.go index 6e75d674..0ff2d323 100644 --- a/agent/sandbox/v2/claude/command.go +++ b/agent/sandbox/v2/claude/command.go @@ -105,6 +105,15 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string { env := make(map[string]string) workDir := req.Computer.GetWorkDir() + // Workspace ID + workspace := req.Computer.Workplace() + if workspace != nil { + workspaceID, err := workspace.GetID() + if err == nil { + env["CTX_WORKSPACE_ID"] = workspaceID + } + } + for k, v := range p.HomeEnv(workDir) { env[k] = v } @@ -127,6 +136,12 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string { key, _ := setting["key"].(string) model, _ := setting["model"].(string) + roleConnectors := getRoleConnectors(req) + getConn := func(id string) connector.Connector { + c, _ := connector.Connectors[id] + return c + } + if req.Connector.Is(connector.ANTHROPIC) { env["ANTHROPIC_BASE_URL"] = host env["ANTHROPIC_API_KEY"] = key @@ -137,18 +152,48 @@ func buildEnv(req *types.StreamRequest, p platform) map[string]string { env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model env["CLAUDE_CODE_SUBAGENT_MODEL"] = model } + + if len(roleConnectors) > 0 { + primaryHost := host + for role, rm := range claudeRoleEnvMap { + if role == "primary" { + continue + } + rc := resolveRoleConnector(role, roleConnectors, req.UserExplicit, getConn) + if rc == nil { + continue + } + rcHost := connectorHost(rc) + if rcHost == primaryHost && supportsProtocol(rc, "anthropic") { + rcModel, _ := rc.Setting()["model"].(string) + if rcModel != "" { + env[rm.EnvVar] = rcModel + } + } + } + } } 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 len(roleConnectors) > 0 { + for role, rm := range claudeRoleEnvMap { + if role == "primary" { + continue + } + rc := resolveRoleConnector(role, roleConnectors, req.UserExplicit, getConn) + if rc == nil { + continue + } + env[rm.EnvVar] = rm.ModelName + } + } } if thinking, ok := setting["thinking"].(map[string]interface{}); ok { @@ -344,6 +389,77 @@ func buildLastUserMessageJSONL(messages []agentContext.Message) string { return "" } +// claudeRoleEnvMap maps abstract Yao model roles to Claude CLI environment +// variables and virtual model name identifiers used as A2O route keys. +// ModelName uniqueness is only required among roles that have independent +// connectors (i.e. are added to the A2O routes map). +var claudeRoleEnvMap = map[string]struct { + EnvVar string + ModelName string +}{ + "primary": {EnvVar: "ANTHROPIC_MODEL", ModelName: "claude-sonnet-4-6"}, + "heavy": {EnvVar: "ANTHROPIC_DEFAULT_OPUS_MODEL", ModelName: "claude-opus-4-6"}, + "light": {EnvVar: "ANTHROPIC_DEFAULT_HAIKU_MODEL", ModelName: "claude-haiku-4-5"}, + "subagent": {EnvVar: "CLAUDE_CODE_SUBAGENT_MODEL", ModelName: "claude-subagent-4-6"}, + "vision": {EnvVar: "ANTHROPIC_DEFAULT_SONNET_MODEL", ModelName: "claude-vision-4-5"}, +} + +func connectorHost(c connector.Connector) string { + if c == nil { + return "" + } + host, _ := c.Setting()["host"].(string) + return host +} + +func connectorProtocols(c connector.Connector) []string { + if c == nil { + return nil + } + setting := c.Setting() + if ps, ok := setting["protocols"].([]string); ok && len(ps) > 0 { + return ps + } + if c.Is(connector.ANTHROPIC) { + return []string{"anthropic"} + } + return []string{"openai"} +} + +func supportsProtocol(c connector.Connector, proto string) bool { + for _, p := range connectorProtocols(c) { + if p == proto { + return true + } + } + return false +} + +// resolveRoleConnector determines which connector to use for a given role. +// Returns nil when the role should use the primary connector (caller decides). +func resolveRoleConnector( + role string, + roleConnectors map[string]*types.RoleConnector, + userExplicit bool, + getConnector func(id string) connector.Connector, +) connector.Connector { + rc, ok := roleConnectors[role] + if !ok || rc == nil { + return nil + } + if rc.Override == "user" && userExplicit { + return nil + } + return getConnector(rc.Connector) +} + +func getRoleConnectors(req *types.StreamRequest) map[string]*types.RoleConnector { + if req.Config == nil { + return nil + } + return req.Config.Runner.Connectors +} + var claudeArgWhitelist = map[string]string{ "max_turns": "--max-turns", "disallowed_tools": "--disallowed-tools", diff --git a/agent/sandbox/v2/claude/command_test.go b/agent/sandbox/v2/claude/command_test.go index 5500cf6d..8b4d8d54 100644 --- a/agent/sandbox/v2/claude/command_test.go +++ b/agent/sandbox/v2/claude/command_test.go @@ -8,6 +8,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/yaoapp/gou/connector" + gouTypes "github.com/yaoapp/gou/types" + "github.com/yaoapp/xun/dbal/query" + "github.com/yaoapp/xun/dbal/schema" agentContext "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/sandbox/v2/types" infra "github.com/yaoapp/yao/sandbox/v2" @@ -434,3 +438,346 @@ func TestBuildArgs_EmptyChatID_Continuation(t *testing.T) { assert.NotContains(t, args, "--session-id") assert.NotContains(t, args, "--name") } + +// --- fakeConnector implements connector.Connector for unit tests --- + +type fakeConnector struct { + id string + typ int + settings map[string]interface{} +} + +func (f *fakeConnector) Register(string, string, []byte) error { return nil } +func (f *fakeConnector) Query() (query.Query, error) { return nil, nil } +func (f *fakeConnector) Schema() (schema.Schema, error) { return nil, nil } +func (f *fakeConnector) Close() error { return nil } +func (f *fakeConnector) ID() string { return f.id } +func (f *fakeConnector) Is(t int) bool { return f.typ == t } +func (f *fakeConnector) Setting() map[string]interface{} { return f.settings } +func (f *fakeConnector) GetMetaInfo() gouTypes.MetaInfo { return gouTypes.MetaInfo{} } + +func newOpenAIConnector(id, host, model, key string) *fakeConnector { + return &fakeConnector{ + id: id, + typ: connector.OPENAI, + settings: map[string]interface{}{ + "host": host, + "model": model, + "key": key, + }, + } +} + +func newAnthropicConnector(id, host, model, key string) *fakeConnector { + return &fakeConnector{ + id: id, + typ: connector.ANTHROPIC, + settings: map[string]interface{}{ + "host": host, + "model": model, + "key": key, + }, + } +} + +func newDualProtoConnector(id, host, model, key string) *fakeConnector { + return &fakeConnector{ + id: id, + typ: connector.OPENAI, + settings: map[string]interface{}{ + "host": host, + "model": model, + "key": key, + "protocols": []string{"openai", "anthropic"}, + }, + } +} + +// --- helper functions tests --- + +func TestConnectorHost(t *testing.T) { + c := newOpenAIConnector("test", "https://api.openai.com", "gpt-4", "k") + assert.Equal(t, "https://api.openai.com", connectorHost(c)) + assert.Equal(t, "", connectorHost(nil)) +} + +func TestConnectorProtocols(t *testing.T) { + oai := newOpenAIConnector("oai", "https://api.openai.com", "gpt-4", "k") + assert.Equal(t, []string{"openai"}, connectorProtocols(oai)) + + anth := newAnthropicConnector("anth", "https://api.anthropic.com", "claude", "k") + assert.Equal(t, []string{"anthropic"}, connectorProtocols(anth)) + + dual := newDualProtoConnector("dual", "https://api.yao.run", "model", "k") + assert.Equal(t, []string{"openai", "anthropic"}, connectorProtocols(dual)) + + assert.Nil(t, connectorProtocols(nil)) +} + +func TestSupportsProtocol(t *testing.T) { + dual := newDualProtoConnector("dual", "https://api.yao.run", "model", "k") + assert.True(t, supportsProtocol(dual, "anthropic")) + assert.True(t, supportsProtocol(dual, "openai")) + assert.False(t, supportsProtocol(dual, "grpc")) + + oai := newOpenAIConnector("oai", "https://api.openai.com", "gpt-4", "k") + assert.False(t, supportsProtocol(oai, "anthropic")) + assert.True(t, supportsProtocol(oai, "openai")) +} + +func TestResolveRoleConnector_Undeclared(t *testing.T) { + roles := map[string]*types.RoleConnector{} + result := resolveRoleConnector("heavy", roles, false, func(id string) connector.Connector { return nil }) + assert.Nil(t, result) +} + +func TestResolveRoleConnector_Force(t *testing.T) { + heavyConn := newOpenAIConnector("thinking", "https://api.thinking.com", "think-model", "k") + roles := map[string]*types.RoleConnector{ + "heavy": {Connector: "thinking", Override: "force"}, + } + result := resolveRoleConnector("heavy", roles, true, func(id string) connector.Connector { + if id == "thinking" { + return heavyConn + } + return nil + }) + assert.Equal(t, heavyConn, result) +} + +func TestResolveRoleConnector_UserExplicit(t *testing.T) { + roles := map[string]*types.RoleConnector{ + "heavy": {Connector: "thinking", Override: "user"}, + } + result := resolveRoleConnector("heavy", roles, true, func(id string) connector.Connector { + return newOpenAIConnector("thinking", "h", "m", "k") + }) + assert.Nil(t, result, "override=user + userExplicit=true => use user's connector") +} + +func TestResolveRoleConnector_UserNotExplicit(t *testing.T) { + heavyConn := newOpenAIConnector("thinking", "h", "m", "k") + roles := map[string]*types.RoleConnector{ + "heavy": {Connector: "thinking", Override: "user"}, + } + result := resolveRoleConnector("heavy", roles, false, func(id string) connector.Connector { + if id == "thinking" { + return heavyConn + } + return nil + }) + assert.Equal(t, heavyConn, result, "override=user + userExplicit=false => use sandbox connector") +} + +// --- buildEnv with multi-connector --- + +func registerTestConnectors(t *testing.T, connectors map[string]connector.Connector) func() { + t.Helper() + for id, c := range connectors { + connector.Connectors[id] = c + } + return func() { + for id := range connectors { + delete(connector.Connectors, id) + } + } +} + +func TestBuildEnv_OpenAI_SingleConnector(t *testing.T) { + oai := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test") + req := &types.StreamRequest{ + Config: &types.SandboxConfig{}, + Connector: oai, + } + req.Computer = newFakeComputer("/workspace") + p := testPlatform() + + env := buildEnv(req, p) + assert.Contains(t, env["ANTHROPIC_BASE_URL"], "127.0.0.1") + assert.Contains(t, env["ANTHROPIC_BASE_URL"], "kimi") + assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_MODEL"]) + assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_DEFAULT_OPUS_MODEL"]) +} + +func TestBuildEnv_OpenAI_MultiConnector(t *testing.T) { + primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-test") + vision := newOpenAIConnector("vision-conn", "https://api.vision.com", "vis-model", "sk-v") + + cleanup := registerTestConnectors(t, map[string]connector.Connector{ + "vision-conn": vision, + }) + defer cleanup() + + req := &types.StreamRequest{ + Config: &types.SandboxConfig{ + Runner: types.RunnerConfig{ + Connectors: map[string]*types.RoleConnector{ + "vision": {Connector: "vision-conn", Override: "force"}, + }, + }, + }, + Connector: primary, + } + req.Computer = newFakeComputer("/workspace") + p := testPlatform() + + env := buildEnv(req, p) + assert.Equal(t, "claude-vision-4-5", env["ANTHROPIC_DEFAULT_SONNET_MODEL"], + "vision role should get its virtual model name for A2O routing") + assert.Equal(t, "claude-sonnet-4-6", env["ANTHROPIC_MODEL"], + "primary should keep default virtual model") +} + +func TestBuildEnv_Anthropic_SingleConnector(t *testing.T) { + anth := newAnthropicConnector("claude", "https://api.anthropic.com", "claude-sonnet-4-20250514", "sk-ant-test") + req := &types.StreamRequest{ + Config: &types.SandboxConfig{}, + Connector: anth, + } + req.Computer = newFakeComputer("/workspace") + p := testPlatform() + + env := buildEnv(req, p) + assert.Equal(t, "https://api.anthropic.com", env["ANTHROPIC_BASE_URL"]) + assert.Equal(t, "sk-ant-test", env["ANTHROPIC_API_KEY"]) + assert.Equal(t, "claude-sonnet-4-20250514", env["ANTHROPIC_MODEL"]) +} + +func TestBuildEnv_Anthropic_MultiConnector_Compatible(t *testing.T) { + primary := newAnthropicConnector("claude", "https://api.yao.run", "claude-sonnet-4-20250514", "sk-ant") + lightConn := newDualProtoConnector("light-conn", "https://api.yao.run", "claude-haiku-3-5-20241022", "sk-light") + + cleanup := registerTestConnectors(t, map[string]connector.Connector{ + "light-conn": lightConn, + }) + defer cleanup() + + req := &types.StreamRequest{ + Config: &types.SandboxConfig{ + Runner: types.RunnerConfig{ + Connectors: map[string]*types.RoleConnector{ + "light": {Connector: "light-conn", Override: "force"}, + }, + }, + }, + Connector: primary, + } + req.Computer = newFakeComputer("/workspace") + p := testPlatform() + + env := buildEnv(req, p) + assert.Equal(t, "claude-haiku-3-5-20241022", env["ANTHROPIC_DEFAULT_HAIKU_MODEL"], + "compatible connector: use real model name") + assert.Equal(t, "https://api.yao.run", env["ANTHROPIC_BASE_URL"], + "primary base URL unchanged") +} + +// --- buildSingleA2OConfig + injectA2OConfigWithRoutes --- + +func TestBuildSingleA2OConfig_Basic(t *testing.T) { + conn := newOpenAIConnector("test", "https://api.openai.com", "gpt-4", "sk-test") + cfg := buildSingleA2OConfig(conn) + require.NotNil(t, cfg) + assert.Contains(t, cfg.Backend, "api.openai.com") + assert.Contains(t, cfg.Backend, "chat/completions") + assert.Equal(t, "gpt-4", cfg.Model) + assert.Equal(t, "sk-test", cfg.APIKey) +} + +func TestBuildSingleA2OConfig_Nil(t *testing.T) { + conn := &fakeConnector{id: "empty", typ: connector.OPENAI, settings: map[string]interface{}{}} + cfg := buildSingleA2OConfig(conn) + assert.Nil(t, cfg, "no host => nil config") +} + +func TestInjectA2OConfigWithRoutes_BuildsCorrectJSON(t *testing.T) { + primary := newOpenAIConnector("kimi", "https://api.moonshot.cn", "kimi-k2.5", "sk-kimi") + vision := newOpenAIConnector("vision", "https://api.vision.com", "vis-model", "sk-v") + + roleConnectors := map[string]connector.Connector{ + "claude-vision-4-5": vision, + } + + primaryCfg := buildSingleA2OConfig(primary) + require.NotNil(t, primaryCfg) + + routes := make(map[string]*a2oConnectorConfig, len(roleConnectors)) + for modelName, rc := range roleConnectors { + routeCfg := buildSingleA2OConfig(rc) + if routeCfg != nil { + routes[modelName] = routeCfg + } + } + primaryCfg.Routes = routes + + data, err := json.Marshal(primaryCfg) + require.NoError(t, err) + + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(data, &parsed)) + + routesMap, ok := parsed["routes"].(map[string]interface{}) + require.True(t, ok, "routes should be present in JSON") + assert.Len(t, routesMap, 1) + + visionRoute, ok := routesMap["claude-vision-4-5"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "vis-model", visionRoute["model"]) + assert.Contains(t, visionRoute["backend"], "api.vision.com") +} + +func TestResolveAllRoleConnectors_Empty(t *testing.T) { + req := &types.StreamRequest{ + Config: &types.SandboxConfig{}, + Connector: newOpenAIConnector("test", "h", "m", "k"), + } + result := resolveAllRoleConnectors(req) + assert.Nil(t, result) +} + +func TestResolveAllRoleConnectors_WithRoles(t *testing.T) { + vision := newOpenAIConnector("vis", "https://vis.com", "vis-m", "sk") + cleanup := registerTestConnectors(t, map[string]connector.Connector{"vis": vision}) + defer cleanup() + + req := &types.StreamRequest{ + Config: &types.SandboxConfig{ + Runner: types.RunnerConfig{ + Connectors: map[string]*types.RoleConnector{ + "vision": {Connector: "vis", Override: "force"}, + }, + }, + }, + Connector: newOpenAIConnector("primary", "h", "m", "k"), + } + result := resolveAllRoleConnectors(req) + assert.Len(t, result, 1) + assert.Equal(t, vision, result["claude-vision-4-5"]) +} + +func TestBuildEnv_Anthropic_MultiConnector_Incompatible(t *testing.T) { + primary := newAnthropicConnector("claude", "https://api.anthropic.com", "claude-sonnet-4-20250514", "sk-ant") + visionConn := newOpenAIConnector("vision-oai", "https://api.openai.com", "gpt-4o", "sk-oai") + + cleanup := registerTestConnectors(t, map[string]connector.Connector{ + "vision-oai": visionConn, + }) + defer cleanup() + + req := &types.StreamRequest{ + Config: &types.SandboxConfig{ + Runner: types.RunnerConfig{ + Connectors: map[string]*types.RoleConnector{ + "vision": {Connector: "vision-oai", Override: "force"}, + }, + }, + }, + Connector: primary, + } + req.Computer = newFakeComputer("/workspace") + p := testPlatform() + + env := buildEnv(req, p) + assert.Equal(t, "claude-sonnet-4-20250514", env["ANTHROPIC_DEFAULT_SONNET_MODEL"], + "incompatible connector: vision should keep primary model (different host, no anthropic protocol)") +} diff --git a/agent/sandbox/v2/claude/runner.go b/agent/sandbox/v2/claude/runner.go index d57f4ba7..944eab3e 100644 --- a/agent/sandbox/v2/claude/runner.go +++ b/agent/sandbox/v2/claude/runner.go @@ -90,7 +90,12 @@ func (r *Runner) Stream(ctx context.Context, req *types.StreamRequest, handler m // Inject connector config into a2o proxy (best-effort, errors ignored). if req.Connector != nil && req.Connector.Is(connector.OPENAI) { - injectA2OConfig(ctx, computer, req.Connector) + roleConnectors := resolveAllRoleConnectors(req) + if len(roleConnectors) > 0 { + injectA2OConfigWithRoutes(ctx, computer, req.Connector, roleConnectors) + } else { + injectA2OConfig(ctx, computer, req.Connector) + } } if req.ChatID != "" { @@ -179,10 +184,11 @@ func (r *Runner) Cleanup(ctx context.Context, computer infra.Computer) error { } type a2oConnectorConfig struct { - Backend string `json:"backend"` - Model string `json:"model"` - APIKey string `json:"api_key"` - Options map[string]interface{} `json:"options,omitempty"` + Backend string `json:"backend"` + Model string `json:"model"` + APIKey string `json:"api_key"` + Options map[string]interface{} `json:"options,omitempty"` + Routes map[string]*a2oConnectorConfig `json:"routes,omitempty"` } func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig { @@ -224,6 +230,80 @@ func buildSingleA2OConfig(conn connector.Connector) *a2oConnectorConfig { return cfg } +// resolveAllRoleConnectors resolves all declared role connectors and returns +// a map of virtual model name -> connector for roles that have independent connectors. +func resolveAllRoleConnectors(req *types.StreamRequest) map[string]connector.Connector { + roleConns := getRoleConnectors(req) + if len(roleConns) == 0 { + return nil + } + + result := make(map[string]connector.Connector) + for role, rm := range claudeRoleEnvMap { + if role == "primary" { + continue + } + rc := resolveRoleConnector(role, roleConns, req.UserExplicit, func(id string) connector.Connector { + c, _ := connector.Connectors[id] + return c + }) + if rc == nil { + continue + } + result[rm.ModelName] = rc + } + return result +} + +// injectA2OConfigWithRoutes pushes the primary connector config along with +// role-based routes to the a2o proxy. Each route maps a virtual model name +// to a different backend connector. +func injectA2OConfigWithRoutes(ctx context.Context, computer infra.Computer, primaryConn connector.Connector, roleConnectors map[string]connector.Connector) { + primaryCfg := buildSingleA2OConfig(primaryConn) + if primaryCfg == nil { + log.Trace("[claude] injectA2OConfigWithRoutes: no valid primary config for connector %s", primaryConn.ID()) + return + } + + routes := make(map[string]*a2oConnectorConfig, len(roleConnectors)) + for modelName, rc := range roleConnectors { + routeCfg := buildSingleA2OConfig(rc) + if routeCfg != nil { + routes[modelName] = routeCfg + } + } + primaryCfg.Routes = routes + + data, err := json.Marshal(primaryCfg) + if err != nil { + log.Trace("[claude] injectA2OConfigWithRoutes: marshal error: %v", err) + return + } + + connID := primaryConn.ID() + var result *infra.ExecResult + + info := computer.ComputerInfo() + if info.Kind == "host" { + result, err = computer.Exec(ctx, []string{"tai", "a2o", "config", "put", connID}, infra.WithStdin(data)) + } else { + escaped := strings.ReplaceAll(string(data), "'", "'\\''") + script := fmt.Sprintf("echo '%s' | tai a2o config put %s", escaped, connID) + result, err = computer.Exec(ctx, []string{"sh", "-c", script}) + } + + if err != nil { + log.Trace("[claude] injectA2OConfigWithRoutes: exec error (ignored): %v", err) + return + } + if result.ExitCode != 0 { + log.Trace("[claude] injectA2OConfigWithRoutes: exit %d stderr=%s (ignored)", result.ExitCode, result.Stderr) + return + } + + log.Trace("[claude] injectA2OConfigWithRoutes: connector=%s injected with %d routes", connID, len(routes)) +} + // injectA2OConfig pushes the connector config to the a2o proxy. // For box (Linux container): uses sh pipe since Docker exec stdin may not work. // For host: uses WithStdin which works reliably on all platforms. diff --git a/agent/sandbox/v2/types/config.go b/agent/sandbox/v2/types/config.go index 6a790004..e1345d10 100644 --- a/agent/sandbox/v2/types/config.go +++ b/agent/sandbox/v2/types/config.go @@ -83,9 +83,39 @@ type ComputerConfig struct { // RunnerConfig identifies which Runner to use and how. type RunnerConfig struct { - Name string `json:"name" yaml:"name"` - Mode string `json:"mode,omitempty" yaml:"mode,omitempty"` - Options map[string]any `json:"options,omitempty" yaml:"options,omitempty"` + Name string `json:"name" yaml:"name"` + Mode string `json:"mode,omitempty" yaml:"mode,omitempty"` + Options map[string]any `json:"options,omitempty" yaml:"options,omitempty"` + Connectors map[string]*RoleConnector `json:"connectors,omitempty" yaml:"connectors,omitempty"` +} + +// RoleConnector maps an abstract model role to a connector. +// Accepts both a plain string and a structured object in JSON/YAML: +// +// "thinking" → {Connector: "thinking", Override: "force"} +// {"connector": "thinking", "override": "user"} → as-is +type RoleConnector struct { + Connector string `json:"connector"` + Override string `json:"override,omitempty"` // "force" (default) or "user" +} + +func (r *RoleConnector) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err == nil { + r.Connector = s + r.Override = "force" + return nil + } + type alias RoleConnector + var a alias + if err := json.Unmarshal(data, &a); err != nil { + return fmt.Errorf("RoleConnector: expected a string or {connector, override} object") + } + *r = RoleConnector(a) + if r.Override == "" { + r.Override = "force" + } + return nil } // PrepareStep is a single action executed during Runner.Prepare. diff --git a/agent/sandbox/v2/types/config_test.go b/agent/sandbox/v2/types/config_test.go new file mode 100644 index 00000000..6864f75c --- /dev/null +++ b/agent/sandbox/v2/types/config_test.go @@ -0,0 +1,94 @@ +package types + +import ( + "encoding/json" + "testing" +) + +func TestRoleConnector_UnmarshalJSON_String(t *testing.T) { + var rc RoleConnector + if err := json.Unmarshal([]byte(`"thinking"`), &rc); err != nil { + t.Fatal(err) + } + if rc.Connector != "thinking" { + t.Errorf("expected connector=thinking, got %s", rc.Connector) + } + if rc.Override != "force" { + t.Errorf("expected override=force, got %s", rc.Override) + } +} + +func TestRoleConnector_UnmarshalJSON_Object(t *testing.T) { + var rc RoleConnector + if err := json.Unmarshal([]byte(`{"connector":"thinking","override":"user"}`), &rc); err != nil { + t.Fatal(err) + } + if rc.Connector != "thinking" { + t.Errorf("expected connector=thinking, got %s", rc.Connector) + } + if rc.Override != "user" { + t.Errorf("expected override=user, got %s", rc.Override) + } +} + +func TestRoleConnector_UnmarshalJSON_ObjectDefaultForce(t *testing.T) { + var rc RoleConnector + if err := json.Unmarshal([]byte(`{"connector":"thinking"}`), &rc); err != nil { + t.Fatal(err) + } + if rc.Connector != "thinking" { + t.Errorf("expected connector=thinking, got %s", rc.Connector) + } + if rc.Override != "force" { + t.Errorf("expected default override=force, got %s", rc.Override) + } +} + +func TestRoleConnector_UnmarshalJSON_InvalidJSON(t *testing.T) { + var rc RoleConnector + if err := json.Unmarshal([]byte(`123`), &rc); err == nil { + t.Fatal("expected error for invalid JSON type") + } +} + +func TestRunnerConfig_UnmarshalJSON_WithConnectors(t *testing.T) { + raw := `{ + "name": "claude", + "mode": "interactive", + "connectors": { + "heavy": "thinking", + "light": {"connector": "fast", "override": "user"}, + "vision": "multimodal" + } + }` + + var rc RunnerConfig + if err := json.Unmarshal([]byte(raw), &rc); err != nil { + t.Fatal(err) + } + + if rc.Name != "claude" { + t.Errorf("expected name=claude, got %s", rc.Name) + } + if rc.Mode != "interactive" { + t.Errorf("expected mode=interactive, got %s", rc.Mode) + } + if len(rc.Connectors) != 3 { + t.Fatalf("expected 3 connectors, got %d", len(rc.Connectors)) + } + + heavy := rc.Connectors["heavy"] + if heavy.Connector != "thinking" || heavy.Override != "force" { + t.Errorf("heavy: expected {thinking, force}, got {%s, %s}", heavy.Connector, heavy.Override) + } + + light := rc.Connectors["light"] + if light.Connector != "fast" || light.Override != "user" { + t.Errorf("light: expected {fast, user}, got {%s, %s}", light.Connector, light.Override) + } + + vision := rc.Connectors["vision"] + if vision.Connector != "multimodal" || vision.Override != "force" { + t.Errorf("vision: expected {multimodal, force}, got {%s, %s}", vision.Connector, vision.Override) + } +} diff --git a/agent/sandbox/v2/types/runner.go b/agent/sandbox/v2/types/runner.go index fb7c1796..3929ab3c 100644 --- a/agent/sandbox/v2/types/runner.go +++ b/agent/sandbox/v2/types/runner.go @@ -57,4 +57,5 @@ type StreamRequest struct { ChatID string Token *SandboxToken // current user's sandbox token for MCP callbacks Logger *agentContext.RequestLogger // request-scoped logger propagated from agent context + UserExplicit bool // true when the user explicitly selected the primary connector } diff --git a/tai/workspace/workspace.go b/tai/workspace/workspace.go index 08c518b1..344e5e8c 100644 --- a/tai/workspace/workspace.go +++ b/tai/workspace/workspace.go @@ -34,6 +34,9 @@ type FS interface { // GetRoot returns the absolute path of this workspace's root directory on the host filesystem. GetRoot() (string, error) + + // GetID returns the ID of this workspace. + GetID() (string, error) } // New creates an FS backed by the given Volume for the specified session. @@ -126,6 +129,10 @@ func (w *workspaceFS) GetRoot() (string, error) { return w.vol.Abs(context.Background(), w.session, ".") } +func (w *workspaceFS) GetID() (string, error) { + return w.session, nil +} + func (w *workspaceFS) Close() error { return nil } // --- fs.FileInfo adapter --- diff --git a/workspace/manager.go b/workspace/manager.go index 3864dbbd..abfb29a3 100644 --- a/workspace/manager.go +++ b/workspace/manager.go @@ -216,9 +216,10 @@ func (m *Manager) ListDir(ctx context.Context, id string, path string) ([]DirEnt result := make([]DirEntry, len(entries)) for i, e := range entries { result[i] = DirEntry{ - Name: e.Path, - IsDir: e.IsDir, - Size: e.Size, + Name: e.Path, + IsDir: e.IsDir, + Size: e.Size, + ModTime: e.Mtime, } } return result, nil @@ -314,7 +315,8 @@ func listNodes() []taitypes.NodeMeta { // DirEntry represents a file or directory entry in a workspace listing. type DirEntry struct { - Name string `json:"name"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size"` + Name string `json:"name"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + ModTime time.Time `json:"mod_time,omitempty"` } From c987bd687f727ee5ab18681121ca8e4c024c37a0 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 13 Apr 2026 18:26:41 +0800 Subject: [PATCH 2/4] fix(sandbox): pass explicit user to docker exec for UID-mapped containers After tai sandbox containers start as root with dynamic UID mapping, docker exec defaults to root. Add User field to ExecOptions and hardcode "sandbox" in Box.Exec/Stream to ensure commands run as the correct user. Made-with: Cursor --- sandbox/v2/box.go | 2 ++ tai/runtime/docker_core.go | 2 ++ tai/runtime/sandbox.go | 1 + 3 files changed, 5 insertions(+) diff --git a/sandbox/v2/box.go b/sandbox/v2/box.go index 9e3a58c5..fc42a355 100644 --- a/sandbox/v2/box.go +++ b/sandbox/v2/box.go @@ -93,6 +93,7 @@ func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*Exec result, err := res.Runtime.Exec(ctx, b.containerID, cmd, tairuntime.ExecOptions{ WorkDir: cfg.WorkDir, Env: cfg.Env, + User: "sandbox", }) if err != nil { return nil, err @@ -123,6 +124,7 @@ func (b *Box) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*Ex handle, err := res.Runtime.ExecStream(ctx, b.containerID, cmd, tairuntime.ExecOptions{ WorkDir: cfg.WorkDir, Env: cfg.Env, + User: "sandbox", }) if err != nil { return nil, err diff --git a/tai/runtime/docker_core.go b/tai/runtime/docker_core.go index 27c99363..e6601e4a 100644 --- a/tai/runtime/docker_core.go +++ b/tai/runtime/docker_core.go @@ -106,6 +106,7 @@ func (d *dockerCore) exec(ctx context.Context, id string, cmd []string, opts Exe Cmd: cmd, WorkingDir: opts.WorkDir, Env: envSlice(opts.Env), + User: opts.User, AttachStdout: true, AttachStderr: true, } @@ -143,6 +144,7 @@ func (d *dockerCore) execStream(ctx context.Context, id string, cmd []string, op Cmd: cmd, WorkingDir: opts.WorkDir, Env: envSlice(opts.Env), + User: opts.User, AttachStdin: true, AttachStdout: true, AttachStderr: true, diff --git a/tai/runtime/sandbox.go b/tai/runtime/sandbox.go index 48da51a0..00d3af0f 100644 --- a/tai/runtime/sandbox.go +++ b/tai/runtime/sandbox.go @@ -70,6 +70,7 @@ type ContainerInfo struct { type ExecOptions struct { WorkDir string Env map[string]string + User string // container exec user; empty = container default (container.Config.User) } // ExecResult holds output from an exec command. From 0cbc7d961d3996c8154d7b08c121c1703573826b Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 13 Apr 2026 22:24:40 +0800 Subject: [PATCH 3/4] refactor(parser): streamline event handling and tool state management - Replaced chunkRecord with recordedEvent to simplify event recording. - Introduced activeToolID to manage the currently streaming tool, allowing for better state handling during parsing. - Enhanced closeStreamingTool and suspendStreamingTool methods for improved tool message management. - Updated parsing logic to handle multiple concurrent tool calls more effectively. - Added utility functions for extracting message groups and properties from recorded events. --- agent/sandbox/v2/claude/parse.go | 130 ++-- agent/sandbox/v2/claude/parse_test.go | 962 ++++++++++---------------- 2 files changed, 464 insertions(+), 628 deletions(-) diff --git a/agent/sandbox/v2/claude/parse.go b/agent/sandbox/v2/claude/parse.go index a5039aa8..ca4542cc 100644 --- a/agent/sandbox/v2/claude/parse.go +++ b/agent/sandbox/v2/claude/parse.go @@ -19,16 +19,20 @@ import ( // // 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) +// content_block_stop -> message_end(exec-N-xxx) (streaming phase ends, tool kept in buffer) // ...later... // user/tool_result -> message_start(id=exec-N-xxx, reuse!) + ChunkExecute{status:completed, output:...} + message_end +// +// For parallel tool calls, multiple tools may be in-flight simultaneously. +// The tools buffer keeps each tool's state until its tool_result arrives. type streamParser struct { handler message.StreamFunc completed bool - textActive bool - toolIndex int - curTool *toolState + textActive bool + toolIndex int + activeToolID string // tool currently receiving content_block_delta + tools map[string]*toolState // tool_id -> buffered tool state toolNames map[string]string // tool_id -> tool_name toolMsgIDs map[string]string // tool_id -> message_id (for result reuse) @@ -47,6 +51,7 @@ type toolState struct { func newStreamParser(handler message.StreamFunc) *streamParser { return &streamParser{ handler: handler, + tools: make(map[string]*toolState), toolNames: make(map[string]string), toolMsgIDs: make(map[string]string), toolInputs: make(map[string]string), @@ -54,6 +59,13 @@ func newStreamParser(handler message.StreamFunc) *streamParser { } } +func (p *streamParser) activeTool() *toolState { + if p.activeToolID == "" { + return nil + } + return p.tools[p.activeToolID] +} + func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error { doneParsing := make(chan struct{}) defer close(doneParsing) @@ -84,8 +96,8 @@ func (p *streamParser) parse(ctx context.Context, stdout io.ReadCloser) error { if time.Since(lastHeartbeat) > 30*time.Second { builderLen := 0 - if p.curTool != nil { - builderLen = p.curTool.inputJSON.Len() + if t := p.activeTool(); t != nil { + builderLen = t.inputJSON.Len() } log.Trace("[claude-parse] heartbeat: lines=%d elapsed=%v lastEvent=%s toolBuilderLen=%d", lineCount, time.Since(startTime).Round(time.Second), lastEventType, builderLen) @@ -172,28 +184,52 @@ func (p *streamParser) closeTextMessage() { } } -// closeCurrentTool closes the in-flight streaming tool message (if any), -// flushing its accumulated input and emitting message_end. This must be -// called before opening a new message group so that the downstream handler -// never sees interleaved message_start/message_end pairs. -func (p *streamParser) closeCurrentTool() { - if p.curTool == nil { +// closeStreamingTool closes the currently streaming tool's message group, +// flushing accumulated input and emitting message_end. The tool remains +// in p.tools so handleUser can later reuse its msgID for the completed phase. +func (p *streamParser) closeStreamingTool() { + t := p.activeTool() + if t == nil { return } - toolID := p.curTool.id - inputStr := p.curTool.inputJSON.String() + inputStr := t.inputJSON.String() if inputStr != "" { - p.toolInputs[toolID] = inputStr - summary := extractSummary(p.curTool.name, inputStr) + p.toolInputs[t.id] = inputStr + summary := extractSummary(t.name, inputStr) if summary != "" { - p.toolSummaries[toolID] = summary + p.toolSummaries[t.id] = summary p.emitExecute(map[string]any{ "summary": summary, }) } } p.endMessage() - p.curTool = nil + p.activeToolID = "" +} + +// suspendStreamingTool temporarily closes the active tool's message group +// (emits message_end) so another message group can be opened. The tool +// remains in p.tools and p.activeToolID is cleared. Call resumeStreamingTool +// to reopen it. +func (p *streamParser) suspendStreamingTool() { + t := p.activeTool() + if t == nil { + return + } + p.endMessage() + p.activeToolID = "" +} + +// resumeStreamingTool reopens a previously suspended tool's message group +// by emitting a new message_start with the same msgID, and restores it as +// the active streaming tool. +func (p *streamParser) resumeStreamingTool(toolID string) { + t, ok := p.tools[toolID] + if !ok { + return + } + p.beginMessageWithID(t.msgID, "execute") + p.activeToolID = toolID } func (p *streamParser) ensureTextMessage() (stopped bool) { @@ -255,7 +291,6 @@ func extractSummary(toolName string, inputJSON string) string { } } - // 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) @@ -324,7 +359,9 @@ func (p *streamParser) onContentBlockStart(event map[string]any) (stopped bool) return true } - p.curTool = &toolState{id: toolID, name: toolName, msgID: msgID, index: p.toolIndex} + ts := &toolState{id: toolID, name: toolName, msgID: msgID, index: p.toolIndex} + p.tools[toolID] = ts + p.activeToolID = toolID p.toolIndex++ p.toolNames[toolID] = toolName p.toolMsgIDs[toolID] = msgID @@ -342,7 +379,7 @@ func (p *streamParser) onContentBlockStart(event map[string]any) (stopped bool) } func (p *streamParser) onContentBlockStop() (stopped bool) { - p.closeCurrentTool() + p.closeStreamingTool() return false } @@ -360,9 +397,6 @@ func (p *streamParser) onContentBlockDelta(event map[string]any) (stopped bool) if text == "" { return false } - // If there is no active text message and this delta is only - // whitespace, buffer it instead of opening a brand-new message - // group just for spaces/indentation between tool calls. if !p.textActive && strings.TrimSpace(text) == "" { return false } @@ -372,21 +406,22 @@ func (p *streamParser) onContentBlockDelta(event map[string]any) (stopped bool) return p.emitText(text) case "input_json_delta": - if p.curTool == nil { + t := p.activeTool() + if t == nil { return false } partial, _ := delta["partial_json"].(string) if partial == "" { return false } - p.curTool.inputJSON.WriteString(partial) - builderLen := p.curTool.inputJSON.Len() + t.inputJSON.WriteString(partial) + builderLen := t.inputJSON.Len() if builderLen > 0 && builderLen%100000 < len(partial) { - log.Trace("[claude-parse] WARN: tool %s inputJSON growing: %d bytes", p.curTool.name, builderLen) + log.Trace("[claude-parse] WARN: tool %s inputJSON growing: %d bytes", t.name, builderLen) } if p.handler != nil { return p.emitExecute(map[string]any{ - "input_delta": p.curTool.inputJSON.String(), + "input_delta": t.inputJSON.String(), }) } } @@ -426,7 +461,7 @@ func (p *streamParser) handleAssistant(msg map[string]any) (stopped bool) { } p.closeTextMessage() - p.closeCurrentTool() + p.closeStreamingTool() toolName, _ := ci["name"].(string) if toolID == "" { @@ -498,18 +533,25 @@ func (p *streamParser) handleUser(msg map[string]any) (stopped bool) { continue } - // Close any open text message before opening an execute message. + toolUseID, _ := ci["tool_use_id"].(string) + + // If the result belongs to the actively streaming tool, close its + // streaming phase (emits message_end for the running group). + if p.activeToolID == toolUseID { + p.closeStreamingTool() + } + + // If a DIFFERENT tool is currently streaming, we must suspend its + // message group before opening the completed-result group, because + // the downstream handler only tracks one currentGroupID at a time. + suspendedToolID := "" + if p.activeToolID != "" && p.activeToolID != toolUseID { + suspendedToolID = p.activeToolID + p.suspendStreamingTool() + } + p.closeTextMessage() - // When Claude CLI executes tools in parallel, tool_result messages - // can arrive while a new tool_use is still streaming. The downstream - // handler (stream.go) tracks only a single currentGroupID, so we - // must close the in-flight streaming tool message before opening - // the result message — otherwise the message_start/message_end - // pairs become interleaved and chunks lose their message_id. - p.closeCurrentTool() - - toolUseID, _ := ci["tool_use_id"].(string) content := ci["content"] isError, _ := ci["is_error"].(bool) @@ -549,6 +591,14 @@ func (p *streamParser) handleUser(msg map[string]any) (stopped bool) { return true } p.endMessage() + + delete(p.tools, toolUseID) + + // Resume the suspended tool's message group so subsequent + // content_block_delta events land in the correct group. + if suspendedToolID != "" { + p.resumeStreamingTool(suspendedToolID) + } } return false } diff --git a/agent/sandbox/v2/claude/parse_test.go b/agent/sandbox/v2/claude/parse_test.go index a6c38c5d..f4f17471 100644 --- a/agent/sandbox/v2/claude/parse_test.go +++ b/agent/sandbox/v2/claude/parse_test.go @@ -6,625 +6,411 @@ import ( "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 +// recordedEvent captures a single StreamFunc callback invocation. +type recordedEvent struct { + chunkType message.StreamChunkType + data []byte } -func recordingHandler(out *[]chunkRecord) message.StreamFunc { +func mockStreamFunc(events *[]recordedEvent) 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}) + *events = append(*events, recordedEvent{chunkType: 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()) +// runParser feeds JSONL lines to a streamParser and returns all recorded events. +func runParser(t *testing.T, jsonl string) []recordedEvent { + t.Helper() + var events []recordedEvent + p := newStreamParser(mockStreamFunc(&events)) r, w := io.Pipe() - go func() { - time.Sleep(50 * time.Millisecond) - cancel() + defer w.Close() + w.Write([]byte(jsonl)) }() - - p := newStreamParser(nil) - err := p.parse(ctx, r) - _ = w.Close() - - require.Error(t, err) - assert.ErrorIs(t, err, context.Canceled) + if err := p.parse(context.Background(), r); err != nil { + t.Fatalf("parse error: %v", err) + } + return events } -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"}, - }, - }), - } +// --- helpers to inspect recorded events --- - 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") +type messageGroup struct { + messageID string + events []recordedEvent } -func TestParser_EmptyAndInvalidLines(t *testing.T) { - lines := []string{ - "", - "not json at all", - " ", - `{"type": "result", "num_turns": 1}`, +// extractMessageGroups splits the flat event list into message_start..message_end +// groups, each carrying the messageID from the start event. +func extractMessageGroups(events []recordedEvent) []messageGroup { + var groups []messageGroup + var cur *messageGroup + for _, ev := range events { + if ev.chunkType == message.ChunkMessageStart { + var sd message.EventMessageStartData + json.Unmarshal(ev.data, &sd) + cur = &messageGroup{messageID: sd.MessageID} + } + if cur != nil { + cur.events = append(cur.events, ev) + } + if ev.chunkType == message.ChunkMessageEnd { + if cur != nil { + groups = append(groups, *cur) + cur = nil + } + } } - - 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") + return groups } -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}), - } +func extractExecuteProps(ev recordedEvent) map[string]any { + var m map[string]any + json.Unmarshal(ev.data, &m) + return m +} - var chunks []chunkRecord - p := newStreamParser(recordingHandler(&chunks)) - err := p.parse(context.Background(), pipeWithLines(lines...)) - require.NoError(t, err) - assert.True(t, p.completed) +// --- Mock JSONL data based on real terminal logs --- - startCount := 0 - endCount := 0 - for _, c := range chunks { - switch c.Type { +// parallelToolCallJSONL reproduces the exact interleaved event sequence from +// the production log: two Bash tool calls (pip --version + npm --version) where +// tool0's tool_result arrives while tool1 is still streaming content_block_delta. +const parallelToolCallJSONL = `{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_mock","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-20250514","stop_reason":null,"usage":{"input_tokens":100,"output_tokens":1}}}} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_pip","name":"Bash"}}} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"command\""}}} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":":\"pip --version\"}"}}} +{"type":"assistant","message":{"id":"msg_mock","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_pip","name":"Bash","input":{"command":"pip --version"}}],"model":"claude-sonnet-4-20250514","stop_reason":null,"usage":{"input_tokens":100,"output_tokens":30}}} +{"type":"stream_event","event":{"type":"content_block_stop","index":0}} +{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_npm","name":"Bash"}}} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"command\""}}} +{"type":"user","message":{"id":"msg_user_pip","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_pip","content":"pip 24.0 (python 3.12)"}]}} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":":\"npm --version\"}"}}} +{"type":"assistant","message":{"id":"msg_mock2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_npm","name":"Bash","input":{"command":"npm --version"}}],"model":"claude-sonnet-4-20250514","stop_reason":null,"usage":{"input_tokens":200,"output_tokens":60}}} +{"type":"stream_event","event":{"type":"content_block_stop","index":1}} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":60}}} +{"type":"stream_event","event":{"type":"message_stop"}} +{"type":"user","message":{"id":"msg_user_npm","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_npm","content":"10.9.7"}]}} +{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_final","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-20250514","stop_reason":null,"usage":{"input_tokens":300,"output_tokens":1}}}} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pip 和 npm 都可以正常使用。"}}} +{"type":"assistant","message":{"id":"msg_final","type":"message","role":"assistant","content":[{"type":"text","text":"pip 和 npm 都可以正常使用。"}],"model":"claude-sonnet-4-20250514","stop_reason":"end_turn","usage":{"input_tokens":300,"output_tokens":20}}} +{"type":"stream_event","event":{"type":"content_block_stop","index":0}} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":20}}} +{"type":"stream_event","event":{"type":"message_stop"}} +{"type":"result","result":"pip 和 npm 都可以正常使用。","is_error":false} +` + +const singleToolCallJSONL = `{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_s","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-20250514","stop_reason":null,"usage":{"input_tokens":50,"output_tokens":1}}}} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_single","name":"Bash"}}} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"command\":\"ls\"}"}}} +{"type":"assistant","message":{"id":"msg_s","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_single","name":"Bash","input":{"command":"ls"}}],"model":"claude-sonnet-4-20250514","stop_reason":null,"usage":{"input_tokens":50,"output_tokens":10}}} +{"type":"stream_event","event":{"type":"content_block_stop","index":0}} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":10}}} +{"type":"stream_event","event":{"type":"message_stop"}} +{"type":"user","message":{"id":"msg_u","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_single","content":"file1.txt\nfile2.txt"}]}} +{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_s2","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-20250514","stop_reason":null,"usage":{"input_tokens":80,"output_tokens":1}}}} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Done."}}} +{"type":"stream_event","event":{"type":"content_block_stop","index":0}} +{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}} +{"type":"stream_event","event":{"type":"message_stop"}} +{"type":"result","result":"Done.","is_error":false} +` + +// ---------- Test 1: message_start / message_end pairing ---------- + +func TestParseParallelToolCalls_MessagePairing(t *testing.T) { + events := runParser(t, parallelToolCallJSONL) + + var startCount, endCount int + depth := 0 + for _, ev := range events { + switch ev.chunkType { case message.ChunkMessageStart: startCount++ + depth++ + if depth > 1 { + t.Fatalf("nested message_start detected (depth %d) — message_start/message_end not strictly paired", depth) + } 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 + depth-- + if depth < 0 { + t.Fatal("message_end without preceding message_start") } } } - assert.Equal(t, `{"command":"ls"}`, lastDelta, "input_delta should accumulate all fragments") + + if startCount != endCount { + t.Fatalf("message_start count (%d) != message_end count (%d)", startCount, endCount) + } + // 4 execute phases (pip running, npm running, pip completed, npm completed) + 1 text = 5 + if startCount < 5 { + t.Errorf("expected at least 5 message groups, got %d", startCount) + } +} + +// ---------- Test 2: each tool has running + completed lifecycle ---------- + +func TestParseParallelToolCalls_ToolLifecycle(t *testing.T) { + events := runParser(t, parallelToolCallJSONL) + groups := extractMessageGroups(events) + + // Collect execute groups by message_id + type toolPhase struct { + messageID string + status string + tool string + output any + } + var phases []toolPhase + for _, g := range groups { + for _, ev := range g.events { + if ev.chunkType == message.ChunkExecute { + props := extractExecuteProps(ev) + status, _ := props["status"].(string) + if status != "" { + phases = append(phases, toolPhase{ + messageID: g.messageID, + status: status, + tool: strDefault(props["tool"]), + output: props["output"], + }) + } + } + } + } + + // Find pip and npm phases + var pipRunning, pipCompleted, npmRunning, npmCompleted *toolPhase + for i := range phases { + p := &phases[i] + if p.tool == "Bash" && p.status == "running" && pipRunning == nil { + pipRunning = p + } else if p.tool == "Bash" && p.status == "running" && npmRunning == nil { + npmRunning = p + } + out := strDefault(p.output) + if p.status == "completed" && strings.Contains(out, "pip") { + pipCompleted = p + } + if p.status == "completed" && strings.Contains(out, "10.9.7") { + npmCompleted = p + } + } + + if pipRunning == nil { + t.Fatal("pip running phase not found") + } + if pipCompleted == nil { + t.Fatal("pip completed phase not found") + } + if npmRunning == nil { + t.Fatal("npm running phase not found") + } + if npmCompleted == nil { + t.Fatal("npm completed phase not found") + } + + // running and completed phases must share message_id (reuse) + if pipRunning.messageID != pipCompleted.messageID { + t.Errorf("pip: running message_id %q != completed message_id %q", pipRunning.messageID, pipCompleted.messageID) + } + if npmRunning.messageID != npmCompleted.messageID { + t.Errorf("npm: running message_id %q != completed message_id %q", npmRunning.messageID, npmCompleted.messageID) + } + + // two tools should have different message_ids + if pipRunning.messageID == npmRunning.messageID { + t.Error("pip and npm should have different message_ids") + } +} + +// ---------- Test 3: interleaved result does not corrupt message_id ---------- + +func TestParseParallelToolCalls_InterleavedResult(t *testing.T) { + events := runParser(t, parallelToolCallJSONL) + groups := extractMessageGroups(events) + + // Find the npm running group (the one with tool_id toolu_npm or second Bash running) + var npmRunningGroup *messageGroup + bashRunningCount := 0 + for i := range groups { + for _, ev := range groups[i].events { + if ev.chunkType == message.ChunkExecute { + props := extractExecuteProps(ev) + if props["status"] == "running" && props["tool"] == "Bash" { + bashRunningCount++ + if bashRunningCount == 2 { + npmRunningGroup = &groups[i] + } + } + } + } + } + + if npmRunningGroup == nil { + t.Fatal("npm running message group not found") + } + + // The npm running group must have a non-empty message_id + if npmRunningGroup.messageID == "" { + t.Fatal("npm running group has empty message_id") + } + + // All execute chunks in the npm running group must have input_delta data + // (the streaming deltas must be present, proving they weren't lost) + var hasDelta bool + for _, ev := range npmRunningGroup.events { + if ev.chunkType == message.ChunkExecute { + props := extractExecuteProps(ev) + if _, ok := props["input_delta"]; ok { + hasDelta = true + } + } + } + if !hasDelta { + t.Error("npm running group has no input_delta chunks — streaming was interrupted") + } + + // pip completed group must not contain npm delta events: + // find pip completed group + var pipCompletedGroup *messageGroup + for i := range groups { + for _, ev := range groups[i].events { + if ev.chunkType == message.ChunkExecute { + props := extractExecuteProps(ev) + out := strDefault(props["output"]) + if props["status"] == "completed" && strings.Contains(out, "pip") { + pipCompletedGroup = &groups[i] + } + } + } + } + + if pipCompletedGroup == nil { + t.Fatal("pip completed group not found") + } + + // pip completed group should only contain its own events, not npm deltas + for _, ev := range pipCompletedGroup.events { + if ev.chunkType == message.ChunkExecute { + props := extractExecuteProps(ev) + if _, ok := props["input_delta"]; ok { + t.Error("pip completed group contains input_delta — npm delta leaked into pip group") + } + } + } +} + +// ---------- Test 4: final text after all tools complete ---------- + +func TestParseParallelToolCalls_FinalText(t *testing.T) { + events := runParser(t, parallelToolCallJSONL) + + var textChunks []string + inTextGroup := false + for _, ev := range events { + if ev.chunkType == message.ChunkMessageStart { + var sd message.EventMessageStartData + json.Unmarshal(ev.data, &sd) + inTextGroup = sd.Type == "text" + } + if ev.chunkType == message.ChunkText && inTextGroup { + textChunks = append(textChunks, string(ev.data)) + } + if ev.chunkType == message.ChunkMessageEnd { + inTextGroup = false + } + } + + fullText := strings.Join(textChunks, "") + if !strings.Contains(fullText, "pip") || !strings.Contains(fullText, "npm") { + t.Errorf("final text should mention pip and npm, got %q", fullText) + } +} + +// ---------- Test 5: single tool call regression ---------- + +func TestParseSingleToolCall(t *testing.T) { + events := runParser(t, singleToolCallJSONL) + groups := extractMessageGroups(events) + + // Should have: 1 running group + 1 completed group + 1 text group = 3 + if len(groups) < 3 { + t.Fatalf("expected at least 3 message groups for single tool call, got %d", len(groups)) + } + + // Verify message_start / message_end pairing + depth := 0 + for _, ev := range events { + switch ev.chunkType { + case message.ChunkMessageStart: + depth++ + if depth > 1 { + t.Fatal("nested message_start in single tool call") + } + case message.ChunkMessageEnd: + depth-- + if depth < 0 { + t.Fatal("unmatched message_end in single tool call") + } + } + } + if depth != 0 { + t.Fatalf("unbalanced message_start/message_end (depth=%d)", depth) + } + + // Verify tool lifecycle: running then completed with same message_id + var runningID, completedID string + for _, g := range groups { + for _, ev := range g.events { + if ev.chunkType == message.ChunkExecute { + props := extractExecuteProps(ev) + switch props["status"] { + case "running": + runningID = g.messageID + case "completed": + completedID = g.messageID + } + } + } + } + + if runningID == "" { + t.Fatal("no running phase found") + } + if completedID == "" { + t.Fatal("no completed phase found") + } + if runningID != completedID { + t.Errorf("running message_id %q != completed message_id %q", runningID, completedID) + } + + // Verify text output + var hasText bool + for _, ev := range events { + if ev.chunkType == message.ChunkText && strings.Contains(string(ev.data), "Done") { + hasText = true + } + } + if !hasText { + t.Error("final text 'Done.' not found") + } +} + +func strDefault(v any) string { + if v == nil { + return "" + } + s, _ := v.(string) + return s } From c976a3e3982b81a30cea12de60eb22db725fcc3c Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 13 Apr 2026 23:43:17 +0800 Subject: [PATCH 4/4] chore(dependencies): update Go module dependencies to latest versions - Upgraded several dependencies in go.mod and go.sum, including golang.org/x/crypto, golang.org/x/net, golang.org/x/sys, and google.golang.org/grpc to their latest versions for improved security and performance. - Updated opentelemetry packages to v1.43.0 and added new indirect dependencies for better observability support. - Adjusted the handling of execute messages in stream.go to ensure proper persistence based on message status. - Enhanced command handling in command.go to maintain system prompt constraints during continuation turns. --- agent/assistant/handlers/stream.go | 11 +++-- agent/sandbox/v2/claude/command.go | 8 ++++ go.mod | 27 ++++++----- go.sum | 74 +++++++++++++++--------------- 4 files changed, 66 insertions(+), 54 deletions(-) diff --git a/agent/assistant/handlers/stream.go b/agent/assistant/handlers/stream.go index 51ed071c..399764a7 100644 --- a/agent/assistant/handlers/stream.go +++ b/agent/assistant/handlers/stream.go @@ -408,12 +408,15 @@ func (s *streamState) handleMessageEnd(data []byte) int { shouldSkipHistory := s.ctx.Stack != nil && s.ctx.Stack.Options != nil && s.ctx.Stack.Options.Skip != nil && s.ctx.Stack.Options.Skip.History - // Execute messages have two phases sharing the same message_id: - // 1. running — streamed for UI display only, NOT persisted + // Execute messages have two (or more) phases sharing the same message_id: + // 1. running / suspended / resumed — streamed for UI display only, NOT persisted // 2. completed / error — the final state, persisted to the buffer - isExecuteRunning := msgType == message.TypeExecute && s.lastExecStatus == "running" + // Only persist when we have an explicit terminal status. + isExecuteFinal := msgType == message.TypeExecute && + (s.lastExecStatus == "completed" || s.lastExecStatus == "error") + skipExecute := msgType == message.TypeExecute && !isExecuteFinal - if s.ctx.Buffer != nil && len(s.buffer) > 0 && !shouldSkipHistory && !isExecuteRunning { + if s.ctx.Buffer != nil && len(s.buffer) > 0 && !shouldSkipHistory && !skipExecute { assistantID := "" if s.ctx.Stack != nil { assistantID = s.ctx.Stack.AssistantID diff --git a/agent/sandbox/v2/claude/command.go b/agent/sandbox/v2/claude/command.go index 0ff2d323..3c5bc703 100644 --- a/agent/sandbox/v2/claude/command.go +++ b/agent/sandbox/v2/claude/command.go @@ -85,6 +85,14 @@ func (r *Runner) buildCommand(ctx context.Context, req *types.StreamRequest, p p promptFile = p.PathJoin(workDir, ".yao", ".system-prompt.txt") } + // On continuation turns the system prompt is not re-sent, but the previously + // written prompt file is still on disk. Pass --append-system-prompt-file so + // Claude keeps the same constraints (e.g. workspace path rules) across the + // entire session without injecting a duplicate system turn. + if isContinuation { + args = append(args, "--append-system-prompt-file", promptFile) + } + script, stdin := p.BuildScript(scriptInput{ args: args, systemPrompt: systemPrompt, diff --git a/go.mod b/go.mod index 062d5241..406475c4 100644 --- a/go.mod +++ b/go.mod @@ -44,11 +44,11 @@ require ( github.com/yaoapp/kun v0.9.0 github.com/yaoapp/xun v0.9.0 go.mongodb.org/mongo-driver v1.17.3 - golang.org/x/crypto v0.48.0 - golang.org/x/net v0.50.0 - golang.org/x/sys v0.41.0 + golang.org/x/crypto v0.49.0 + golang.org/x/net v0.52.0 + golang.org/x/sys v0.42.0 golang.org/x/text v0.35.0 - google.golang.org/grpc v1.79.3 + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 @@ -198,12 +198,12 @@ require ( github.com/yuin/goldmark v1.7.16 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect @@ -213,13 +213,12 @@ require ( golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect golang.org/x/image v0.38.0 // indirect golang.org/x/mod v0.33.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/term v0.40.0 // indirect + golang.org/x/term v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.42.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 39e551c7..8eb4a57e 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,8 @@ github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/I github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= @@ -207,8 +209,8 @@ github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= github.com/gotd/td v0.140.0 h1:trNBzTnhNtNwHsFp5qwKnNxQRAZJ6/BRE+uH3Lojauk= github.com/gotd/td v0.140.0/go.mod h1:0ZkRxG7N+5ooG7/zdRXcnGautGPM6IKmyPQvdsAeF20= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -460,22 +462,22 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= @@ -501,8 +503,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= @@ -528,10 +530,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -564,8 +566,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -575,8 +577,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -606,14 +608,14 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=