feat: enhance CLI adapters with oneshot mode, update edge/node configs and add tests

This commit is contained in:
toki 2026-05-04 09:17:24 +09:00
parent 157a8a7076
commit 03c02d738a
16 changed files with 186 additions and 53 deletions

View file

@ -14,11 +14,11 @@ node 등록/레지스트리/transport와 edge 콘솔 기반 수동 통신 테스
**전제 조건**: node 호스트의 PATH에 `claude` 명령이 있어야 한다.
같은 `cli` 어댑터 안에 `gemini`, `codex` profile도 포함할 수 있으며, 각각 `gemini --approval-mode yolo -p <prompt>`, `codex exec --dangerously-bypass-approvals-and-sandbox`처럼 headless+bypass 조합으로 설정한다. Claude는 `claude -p --dangerously-skip-permissions --output-format stream-json --include-partial-messages --verbose <prompt>` 형태로 호출되어 token-level streaming이 활성화된다.
같은 `cli` 어댑터 안에 `gemini`, `codex`, `opencode` profile도 포함할 수 있으며, 각각 `gemini --approval-mode yolo -p <prompt>`, `codex exec --dangerously-bypass-approvals-and-sandbox`, `opencode run --model ollama-dgx/qwen3.6:35b-a3b-bf16 --format default --dangerously-skip-permissions <prompt>`처럼 headless 조합으로 설정한다. Claude는 `claude -p --dangerously-skip-permissions --output-format stream-json --include-partial-messages --verbose <prompt>` 형태로 호출되어 token-level streaming이 활성화된다.
실행 순서:
1. edge 호스트에서 `configs/edge.yaml``server.listen`, `nodes[].token`을 확인한다. `console.adapter=cli`, `console.model=claude`, `console.session_id=default`가 기본값이다.
1. edge 호스트에서 `configs/edge.yaml``server.listen`, `nodes[].token`을 확인한다. `console.adapter=cli`, `console.agent=claude`, `console.session_id=default`가 기본값이다.
2. node 호스트에서 `configs/node.yaml``transport.edge_addr`를 edge 호스트 주소로, `transport.token`을 edge token과 같게 맞춘다.
3. edge 호스트의 `9090/tcp` 포트를 node 호스트에서 접근 가능하게 연다.
4. edge 호스트에서 `./bin/edge.sh`
@ -32,7 +32,7 @@ node 등록/레지스트리/transport와 edge 콘솔 기반 수동 통신 테스
```text
edge> hello
[edge] sent run_id=manual-... node=local-node adapter=cli model=claude session=default background=false
[edge] sent run_id=manual-... node=local-node adapter=cli agent=claude session=default background=false
[node-local-node-event] start run_id=manual-...
[node-local-node-event] complete run_id=manual-... detail="cli execution complete"
[node-local-node-message] <Codex 응답 텍스트>
@ -44,7 +44,7 @@ edge> hello
|---|---|
| `/session <id>` | 현재 console이 사용할 logical session 변경 |
| `/background on\|off` | background 실행 모드 토글 (on: 응답 기다리지 않음) |
| `/terminate-session` | 현재 `adapter/model/session_id`의 worker process 종료 |
| `/terminate-session` | 현재 `adapter/agent/session_id`의 worker process 종료 |
| `/nodes` | 연결된 node 목록 확인 |
| `/exit` | 콘솔 종료 |

View file

@ -49,15 +49,15 @@ func runConsole(ctx context.Context, cfg *config.EdgeConfig, in io.Reader, out i
}
}()
// Console state: adapter, model, session, background mode.
// Console state: adapter, agent, session, background mode.
adapter := cfg.Console.Adapter
model := cfg.Console.Model
agent := cfg.Console.ResolveAgent()
sessionID := normalizeConsoleSessionID(cfg.Console.SessionID)
background := cfg.Console.Background
timeoutSec := cfg.Console.TimeoutSec
fmt.Fprintf(out, "IOP Edge console listening on %s\n", cfg.Server.Listen)
fmt.Fprintf(out, "Console target adapter=%s model=%s session=%s background=%v\n", adapter, model, sessionID, background)
fmt.Fprintf(out, "Console target adapter=%s agent=%s session=%s background=%v\n", adapter, agent, sessionID, background)
fmt.Fprintln(out, "Start node.sh on another host, then type a message here.")
fmt.Fprintln(out, "Commands: /nodes, /session <id>, /background on|off, /terminate-session, /exit")
@ -104,13 +104,13 @@ func runConsole(ctx context.Context, cfg *config.EdgeConfig, in io.Reader, out i
fmt.Fprintln(out, "usage: /background on|off")
}
case lower == "/terminate-session":
if err := sendTerminateSession(ctx, registry, adapter, model, sessionID); err != nil {
if err := sendTerminateSession(ctx, registry, adapter, agent, sessionID); err != nil {
fmt.Fprintf(out, "error: %v\n", err)
} else {
fmt.Fprintf(out, "terminated session %s\n", sessionID)
}
default:
if err := sendConsoleRun(ctx, registry, events, out, adapter, model, sessionID, background, timeoutSec, message); err != nil {
if err := sendConsoleRun(ctx, registry, events, out, adapter, agent, sessionID, background, timeoutSec, message); err != nil {
fmt.Fprintf(out, "error: %v\n", err)
}
}
@ -129,7 +129,9 @@ func printNodes(out io.Writer, registry *edgenode.Registry) {
}
// buildRunRequest constructs the RunRequest for a console send.
func buildRunRequest(adapter, model, sessionID string, background bool, timeoutSec int, message string) (*iop.RunRequest, string, error) {
// The wire schema still uses RunRequest.model; for the CLI adapter this value
// semantically carries the selected agent/profile name.
func buildRunRequest(adapter, agent, sessionID string, background bool, timeoutSec int, message string) (*iop.RunRequest, string, error) {
input, err := structpb.NewStruct(map[string]any{"prompt": message})
if err != nil {
return nil, "", err
@ -141,7 +143,7 @@ func buildRunRequest(adapter, model, sessionID string, background bool, timeoutS
req := &iop.RunRequest{
RunId: runID,
Adapter: adapter,
Model: model,
Model: agent,
SessionId: normalizeConsoleSessionID(sessionID),
SessionMode: iop.RunSessionMode_RUN_SESSION_MODE_CREATE_IF_MISSING,
Background: background,
@ -154,13 +156,13 @@ func buildRunRequest(adapter, model, sessionID string, background bool, timeoutS
return req, runID, nil
}
func sendConsoleRun(ctx context.Context, registry *edgenode.Registry, events *consoleEventRouter, out io.Writer, adapter, model, sessionID string, background bool, timeoutSec int, message string) error {
func sendConsoleRun(ctx context.Context, registry *edgenode.Registry, events *consoleEventRouter, out io.Writer, adapter, agent, sessionID string, background bool, timeoutSec int, message string) error {
entry, err := registry.Pick()
if err != nil {
return err
}
req, runID, err := buildRunRequest(adapter, model, sessionID, background, timeoutSec, message)
req, runID, err := buildRunRequest(adapter, agent, sessionID, background, timeoutSec, message)
if err != nil {
return err
}
@ -173,8 +175,8 @@ func sendConsoleRun(ctx context.Context, registry *edgenode.Registry, events *co
}
nodeAlias := entry.Alias
fmt.Fprintf(out, "[edge] sent run_id=%s node=%s adapter=%s model=%s session=%s background=%v\n",
runID, nodeAlias, adapter, model, req.GetSessionId(), background)
fmt.Fprintf(out, "[edge] sent run_id=%s node=%s adapter=%s agent=%s session=%s background=%v\n",
runID, nodeAlias, adapter, agent, req.GetSessionId(), background)
if err := entry.Client.Send(req); err != nil {
return err
}

View file

@ -20,7 +20,7 @@ func TestBuildRunRequest_SessionAndBackground(t *testing.T) {
t.Errorf("Adapter: got %q want %q", req.GetAdapter(), "cli")
}
if req.GetModel() != "codex" {
t.Errorf("Model: got %q want %q", req.GetModel(), "codex")
t.Errorf("wire model: got %q want %q", req.GetModel(), "codex")
}
if req.GetSessionId() != "session-a" {
t.Errorf("SessionId: got %q want %q", req.GetSessionId(), "session-a")

View file

@ -198,6 +198,7 @@ func buildConfigPayload(rec *edgenode.NodeRecord) (*iop.NodeConfigPayload, error
"terminal": p.Terminal,
"response_idle_timeout_ms": p.ResponseIdleTimeoutMS,
"startup_idle_timeout_ms": p.StartupIdleTimeoutMS,
"output_format": p.OutputFormat,
}
}
if err := addAdapter("cli", true, map[string]any{"profiles": profiles}); err != nil {

View file

@ -23,6 +23,7 @@ func TestBuildConfigPayload_CLIArgsRoundtrip(t *testing.T) {
Terminal: true,
ResponseIdleTimeoutMS: 1500,
StartupIdleTimeoutMS: 300,
OutputFormat: "codex-json",
},
},
},
@ -86,4 +87,7 @@ func TestBuildConfigPayload_CLIArgsRoundtrip(t *testing.T) {
if got, ok := profile["startup_idle_timeout_ms"].(float64); !ok || got != 300 {
t.Fatalf("expected startup_idle_timeout_ms=300, got %v", profile["startup_idle_timeout_ms"])
}
if got, ok := profile["output_format"].(string); !ok || got != "codex-json" {
t.Fatalf("expected output_format=%q, got %v", "codex-json", profile["output_format"])
}
}

View file

@ -61,7 +61,7 @@ edge-node transport 연결은 **호스트당 1개**를 유지한다. 그 연결
| 개념 | 설명 |
|---|---|
| transport 연결 | edge-node 호스트 쌍당 1개 TCP 연결 |
| logical session | `(adapter, model, session_id)` 로 식별되는 장수 worker process |
| logical session | `(adapter, agent, session_id)` 로 식별되는 장수 worker process |
| run | session 위에서 실행되는 단일 요청 |
**cancel vs terminate:**
@ -83,7 +83,7 @@ edge-node transport 연결은 **호스트당 1개**를 유지한다. 그 연결
| `vllm` | vLLM OpenAI-compatible API | TODO |
| `cli` | claude/gemini/codex/opencode CLI 실행 | 구현 완료 |
`claude`, `gemini`, `codex`처럼 기본이 interactive TUI인 CLI는 이 파이프라인에서 non-interactive 모드로 설정해야 한다. 예: `claude -p --dangerously-skip-permissions`, `gemini --approval-mode yolo -p <prompt>`, `codex exec --dangerously-bypass-approvals-and-sandbox`.
`claude`, `gemini`, `codex`, `opencode`처럼 기본이 interactive TUI인 CLI는 이 파이프라인에서 non-interactive 모드로 설정해야 한다. 예: `claude -p --dangerously-skip-permissions`, `gemini --approval-mode yolo -p <prompt>`, `codex exec --dangerously-bypass-approvals-and-sandbox`, `opencode run --model ollama-dgx/qwen3.6:35b-a3b-bf16 --format default --dangerously-skip-permissions <prompt>`.
## Transport

View file

@ -21,8 +21,8 @@ import (
const Name = "cli"
// known profiles — must match CLI tool names
var knownProfiles = []string{"claude", "gemini", "codex", "opencode"}
// known agents — must match CLI tool names
var knownAgents = []string{"claude", "gemini", "codex", "opencode"}
type cliOutput struct {
line string
@ -30,7 +30,7 @@ type cliOutput struct {
// sessionKey uniquely identifies a logical worker session.
type sessionKey struct {
model string
agent string
sessionID string
}
@ -76,9 +76,9 @@ func (c *CLI) Capabilities(_ context.Context) (runtime.Capabilities, error) {
for name := range c.profiles {
models = append(models, name)
}
for _, p := range knownProfiles {
if _, ok := c.profiles[p]; !ok {
models = append(models, p)
for _, agent := range knownAgents {
if _, ok := c.profiles[agent]; !ok {
models = append(models, agent)
}
}
return runtime.Capabilities{
@ -101,19 +101,19 @@ func (c *CLI) Start(ctx context.Context) error {
for _, name := range names {
profile := c.profiles[name]
key := sessionKey{model: name, sessionID: runtime.DefaultSessionID}
key := sessionKey{agent: name, sessionID: runtime.DefaultSessionID}
sess, err := startProfileSession(ctx, key, profile, c.logger)
if err != nil {
c.mu.Lock()
_ = c.stopAllSessions(context.Background())
c.sessions = make(map[sessionKey]*profileSession)
c.mu.Unlock()
return fmt.Errorf("cli adapter: start profile %q: %w", name, err)
return fmt.Errorf("cli adapter: start agent %q: %w", name, err)
}
c.mu.Lock()
c.sessions[key] = sess
c.mu.Unlock()
c.logger.Info("cli adapter: persistent session started", zap.String("profile", name))
c.logger.Info("cli adapter: persistent session started", zap.String("agent", name))
}
return nil
}
@ -124,7 +124,7 @@ func (c *CLI) stopAllSessions(_ context.Context) error {
var firstErr error
for key, sess := range c.sessions {
if err := closeProfileSession(context.Background(), sess); err != nil && firstErr == nil {
firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.model, key.sessionID, err)
firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.agent, key.sessionID, err)
}
}
c.sessions = make(map[sessionKey]*profileSession)
@ -145,16 +145,17 @@ func (c *CLI) Stop(_ context.Context) error {
var firstErr error
for key, sess := range sessionsCopy {
if err := closeProfileSession(context.Background(), sess); err != nil && firstErr == nil {
firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.model, key.sessionID, err)
firstErr = fmt.Errorf("cli adapter: close session %q/%q: %w", key.agent, key.sessionID, err)
}
}
return firstErr
}
func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
profile, ok := c.profiles[spec.Model]
agent := cliAgentName(spec)
profile, ok := c.profiles[agent]
if !ok {
return fmt.Errorf("cli adapter: unknown profile %q", spec.Model)
return fmt.Errorf("cli adapter: unknown agent %q", agent)
}
if profile.Persistent {
if isCodexExecProfile(profile) {
@ -166,9 +167,9 @@ func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runt
}
// TerminateSession implements runtime.SessionTerminator.
func (c *CLI) TerminateSession(_ context.Context, model, sessionID string) error {
key := sessionKey{model: model, sessionID: normalizeSessionID(sessionID)}
if profile, ok := c.profiles[model]; ok && isCodexExecProfile(profile) {
func (c *CLI) TerminateSession(_ context.Context, agent, sessionID string) error {
key := sessionKey{agent: agent, sessionID: normalizeSessionID(sessionID)}
if profile, ok := c.profiles[agent]; ok && isCodexExecProfile(profile) {
c.mu.Lock()
_, ok := c.codexSessions[key]
if ok {
@ -176,7 +177,7 @@ func (c *CLI) TerminateSession(_ context.Context, model, sessionID string) error
}
c.mu.Unlock()
if !ok {
return fmt.Errorf("cli adapter: no session %q for profile %q", key.sessionID, model)
return fmt.Errorf("cli adapter: no session %q for agent %q", key.sessionID, agent)
}
return nil
}
@ -188,11 +189,15 @@ func (c *CLI) TerminateSession(_ context.Context, model, sessionID string) error
}
c.mu.Unlock()
if !ok {
return fmt.Errorf("cli adapter: no session %q for profile %q", key.sessionID, model)
return fmt.Errorf("cli adapter: no session %q for agent %q", key.sessionID, agent)
}
return closeProfileSession(context.Background(), sess)
}
func cliAgentName(spec runtime.ExecutionSpec) string {
return spec.Model
}
func normalizeSessionID(id string) string {
if id == "" {
return runtime.DefaultSessionID

View file

@ -47,7 +47,8 @@ func (c *CLI) executeCodexExec(ctx context.Context, spec runtime.ExecutionSpec,
}
func (c *CLI) resolveCodexExecSession(spec runtime.ExecutionSpec) (*codexExecSession, error) {
key := sessionKey{model: spec.Model, sessionID: normalizeSessionID(spec.SessionID)}
agent := cliAgentName(spec)
key := sessionKey{agent: agent, sessionID: normalizeSessionID(spec.SessionID)}
c.mu.Lock()
defer c.mu.Unlock()
@ -56,7 +57,7 @@ func (c *CLI) resolveCodexExecSession(spec runtime.ExecutionSpec) (*codexExecSes
return sess, nil
}
if spec.SessionMode == runtime.SessionModeRequireExisting {
return nil, fmt.Errorf("cli adapter: no persistent session for profile %q session %q", spec.Model, key.sessionID)
return nil, fmt.Errorf("cli adapter: no persistent session for agent %q session %q", agent, key.sessionID)
}
sess := &codexExecSession{key: key}
c.codexSessions[key] = sess

View file

@ -71,6 +71,8 @@ func (c *CLI) executeCommand(ctx context.Context, spec runtime.ExecutionSpec, pr
outputTokens, readErr = emitCodexJSONLines(ctx, stdout, sink, spec.RunID, &outBuf)
case "claude-json":
outputTokens, readErr = emitClaudeJSONLines(ctx, stdout, sink, spec.RunID, &outBuf)
case "opencode-json":
outputTokens, readErr = emitOpencodeJSON(ctx, stdout, sink, spec.RunID, &outBuf)
default:
outputTokens, readErr = emitStdoutChunks(ctx, stdout, sink, spec.RunID, &outBuf)
}
@ -338,6 +340,41 @@ func emitCodexJSONLines(ctx context.Context, stdout io.Reader, sink runtime.Even
return outputTokens, nil
}
// emitOpencodeJSON parses `opencode -p ... -f json` output. The current
// opencode CLI wraps the final assistant text in a single JSON object:
// {"response":"..."}.
func emitOpencodeJSON(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) {
body, err := io.ReadAll(stdout)
if err != nil {
return 0, err
}
outBuf.Write(body)
trimmed := strings.TrimSpace(string(body))
if trimmed == "" || trimmed[0] != '{' {
return 0, nil
}
var resp struct {
Response string `json:"response"`
}
if err := json.Unmarshal([]byte(trimmed), &resp); err != nil {
return 0, nil
}
if resp.Response == "" {
return 0, nil
}
outputTokens := len(strings.Fields(resp.Response))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: runID,
Type: runtime.EventTypeDelta,
Delta: resp.Response,
Timestamp: time.Now(),
})
return outputTokens, nil
}
func extractPrompt(input map[string]any) string {
if input == nil {
return ""

View file

@ -168,6 +168,40 @@ EOF`
}
}
func TestCLIExecuteOneShotOpencodeJSONParsesResponse(t *testing.T) {
testutil.RequireUnixShell(t)
script := `cat <<'EOF'
{
"response": "OpenCode says hello."
}
EOF`
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"opencode": {
Command: "sh",
Args: []string{"-c", script, "sh"},
OutputFormat: "opencode-json",
},
},
}
c := clipkg.New(cfg, zap.NewNop())
sink := &testutil.FakeSink{}
if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{
RunID: "run-oc",
Model: "opencode",
Input: map[string]any{"prompt": "hi"},
}, sink); err != nil {
t.Fatalf("execute: %v", err)
}
combined := testutil.CollectDeltas(sink.Events())
if combined != "OpenCode says hello." {
t.Fatalf("expected opencode-json deltas to be %q, got %q", "OpenCode says hello.", combined)
}
}
func TestCLIExecuteOneShotDrainsStderrConcurrently(t *testing.T) {
testutil.RequireUnixShell(t)

View file

@ -130,7 +130,8 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
// resolveSession returns an existing session for the given key or creates one
// when SessionMode allows it.
func (c *CLI) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf) (*profileSession, error) {
key := sessionKey{model: spec.Model, sessionID: normalizeSessionID(spec.SessionID)}
agent := cliAgentName(spec)
key := sessionKey{agent: agent, sessionID: normalizeSessionID(spec.SessionID)}
c.mu.Lock()
defer c.mu.Unlock()
@ -138,7 +139,7 @@ func (c *CLI) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, pr
return sess, nil
}
if spec.SessionMode == runtime.SessionModeRequireExisting {
return nil, fmt.Errorf("cli adapter: no persistent session for profile %q session %q", spec.Model, key.sessionID)
return nil, fmt.Errorf("cli adapter: no persistent session for agent %q session %q", agent, key.sessionID)
}
sess, err := startProfileSession(ctx, key, profile, c.logger)
if err != nil {
@ -150,7 +151,7 @@ func (c *CLI) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, pr
func startProfileSession(_ context.Context, key sessionKey, profile config.CLIProfileConf, logger *zap.Logger) (*profileSession, error) {
if profile.Command == "" {
return nil, fmt.Errorf("profile %q has no command", key.model)
return nil, fmt.Errorf("agent %q has no command", key.agent)
}
outputCh := make(chan cliOutput, 1024)
@ -211,7 +212,7 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
}
if profile.StartupIdleTimeoutMS > 0 {
drainUntilIdle(outputCh, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key.model)
drainUntilIdle(outputCh, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key.agent)
}
select {
@ -229,7 +230,7 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
return &profileSession{
key: key,
name: key.model,
name: key.agent,
profile: profile,
cmd: cmd,
input: input,
@ -278,7 +279,7 @@ func drainSessionUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, log
}
}
timer.Reset(timeout)
logger.Debug("cli adapter: cancel drain", zap.String("profile", key.model), zap.String("session", key.sessionID))
logger.Debug("cli adapter: cancel drain", zap.String("agent", key.agent), zap.String("session", key.sessionID))
case <-timer.C:
return
}

View file

@ -108,6 +108,9 @@ func cliConfFromStruct(s *structpb.Struct) config.CLIConf {
if v, ok := intFromAny(p["startup_idle_timeout_ms"]); ok {
prof.StartupIdleTimeoutMS = v
}
if v, ok := p["output_format"].(string); ok {
prof.OutputFormat = v
}
cfg.Profiles[name] = prof
}
}

View file

@ -10,11 +10,12 @@ func TestCLIConfFromStruct_ProfileRuntimeOptions(t *testing.T) {
st, err := structpb.NewStruct(map[string]any{
"profiles": map[string]any{
"claude": map[string]any{
"command": "claude",
"args": []any{"-p"},
"env": []any{},
"persistent": false,
"terminal": false,
"command": "claude",
"args": []any{"-p"},
"env": []any{},
"persistent": false,
"terminal": false,
"output_format": "claude-json",
},
},
})
@ -34,6 +35,9 @@ func TestCLIConfFromStruct_ProfileRuntimeOptions(t *testing.T) {
if len(prof.Args) != 1 || prof.Args[0] != "-p" {
t.Fatalf("args: got %#v", prof.Args)
}
if prof.OutputFormat != "claude-json" {
t.Fatalf("output_format: got %q", prof.OutputFormat)
}
}
func TestCLIConfFromStruct_ClaudeHeadlessBypassProfile(t *testing.T) {

View file

@ -13,7 +13,7 @@ metrics:
console:
adapter: "cli"
model: "claude"
agent: "opencode"
session_id: "default"
background: false
timeout_sec: 120
@ -71,6 +71,19 @@ nodes:
persistent: true
terminal: false
output_format: "codex-json"
opencode:
command: "opencode"
args:
- "run"
- "--model"
- "ollama-dgx/qwen3.6:35b-a3b-bf16"
- "--format"
- "default"
- "--dangerously-skip-permissions"
env: []
persistent: false
terminal: false
output_format: ""
runtime:
concurrency: 4
workspace_root: "/tmp/iop/workspace"

View file

@ -38,12 +38,20 @@ type EdgeServerConf struct {
type EdgeConsoleConf struct {
Adapter string `mapstructure:"adapter" yaml:"adapter"`
Agent string `mapstructure:"agent" yaml:"agent"`
Model string `mapstructure:"model" yaml:"model"`
SessionID string `mapstructure:"session_id" yaml:"session_id"`
Background bool `mapstructure:"background" yaml:"background"`
TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"`
}
func (c EdgeConsoleConf) ResolveAgent() string {
if c.Agent != "" {
return c.Agent
}
return c.Model
}
type TransportConf struct {
EdgeAddr string `mapstructure:"edge_addr" yaml:"edge_addr"`
Token string `mapstructure:"token" yaml:"token"`
@ -135,6 +143,9 @@ func LoadEdge(cfgFile string) (*EdgeConfig, error) {
if err := v.Unmarshal(&cfg); err != nil {
return nil, err
}
if !v.InConfig("console.agent") && v.InConfig("console.model") {
cfg.Console.Agent = cfg.Console.Model
}
return &cfg, nil
}
@ -150,7 +161,7 @@ func setEdgeDefaults(v *viper.Viper) {
v.SetDefault("metrics.port", 9092)
v.SetDefault("tls.enabled", false)
v.SetDefault("console.adapter", "cli")
v.SetDefault("console.model", "claude")
v.SetDefault("console.agent", "claude")
v.SetDefault("console.session_id", "default")
v.SetDefault("console.background", false)
v.SetDefault("console.timeout_sec", 120)

View file

@ -25,8 +25,8 @@ func TestLoadEdge_ConsoleTimeoutDefault(t *testing.T) {
if cfg.Console.Adapter != "cli" {
t.Fatalf("expected default console.adapter=%q, got %q", "cli", cfg.Console.Adapter)
}
if cfg.Console.Model != "claude" {
t.Fatalf("expected default console.model=%q, got %q", "claude", cfg.Console.Model)
if cfg.Console.ResolveAgent() != "claude" {
t.Fatalf("expected default console.agent=%q, got %q", "claude", cfg.Console.ResolveAgent())
}
}
@ -83,3 +83,20 @@ func TestLoadEdge_ConsoleTimeoutOverride(t *testing.T) {
t.Fatalf("expected timeout_sec=45, got %d", cfg.Console.TimeoutSec)
}
}
func TestLoadEdge_ConsoleAgentFallbackFromLegacyModel(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
yaml := "server:\n listen: \"0.0.0.0:9090\"\nconsole:\n model: \"codex\"\n"
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.LoadEdge(f)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Console.ResolveAgent() != "codex" {
t.Fatalf("expected legacy console.model fallback to resolve to %q, got %q", "codex", cfg.Console.ResolveAgent())
}
}