Merge pull request #1515 from trheyi/main
feat(workspace): add workspace ID retrieval and integration into environment configuration
This commit is contained in:
commit
36d92f1901
19 changed files with 1243 additions and 699 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -105,6 +113,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 +144,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 +160,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 +397,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",
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
94
agent/sandbox/v2/types/config_test.go
Normal file
94
agent/sandbox/v2/types/config_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
27
go.mod
27
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
|
||||
|
|
|
|||
74
go.sum
74
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue