feat: enhance CLI adapters with oneshot mode, update edge/node configs and add tests
This commit is contained in:
parent
157a8a7076
commit
03c02d738a
16 changed files with 186 additions and 53 deletions
|
|
@ -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` | 콘솔 종료 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue