iop/apps/node/internal/adapters/cli/opencode_sse_internal_test.go
toki cd5425e89a feat(node): fix CLI adapter and session management, archive G07 task
- Fix CLI adapter files for proper session/workspace handling
- Fix session.go and session_test.go
- Archive 03+02_cli_agent_cwd task to archive
2026-06-13 20:36:48 +09:00

315 lines
8.9 KiB
Go

package cli
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"iop/packages/go/config"
)
func TestParseOpencodeRunArgs(t *testing.T) {
args := []string{
"--attach", "http://127.0.0.1:9876",
"--model", "ollama-dgx/qwen3.6:35b-a3b-bf16",
"--agent", "build",
"--variant", "fast",
"--title", "demo",
"--dir", "/work",
"--dangerously-skip-permissions",
}
opts := parseOpencodeRunArgs(args)
if opts.AttachURL != "http://127.0.0.1:9876" {
t.Errorf("attach: got %q", opts.AttachURL)
}
if opts.Model != "ollama-dgx/qwen3.6:35b-a3b-bf16" {
t.Errorf("model: got %q", opts.Model)
}
if opts.Agent != "build" {
t.Errorf("agent: got %q", opts.Agent)
}
if opts.Variant != "fast" {
t.Errorf("variant: got %q", opts.Variant)
}
if opts.Title != "demo" {
t.Errorf("title: got %q", opts.Title)
}
if opts.Dir != "/work" {
t.Errorf("dir: got %q", opts.Dir)
}
if !opts.DangerouslySkipPermissions {
t.Error("dangerously-skip-permissions should be true")
}
}
func TestParseOpencodeRunArgs_NoAttach(t *testing.T) {
args := []string{"--title", "untitled", "--model", "m"}
opts := parseOpencodeRunArgs(args)
if opts.AttachURL != "" {
t.Errorf("expected empty attach, got %q", opts.AttachURL)
}
if opts.Title != "untitled" || opts.Model != "m" {
t.Errorf("unexpected: %+v", opts)
}
}
func TestOpencodeModelPayload(t *testing.T) {
cases := []struct {
in string
wantProv string
wantMod string
wantErr bool
}{
{"ollama-dgx/qwen3.6:35b-a3b-bf16", "ollama-dgx", "qwen3.6:35b-a3b-bf16", false},
{"anthropic/claude-sonnet-4-6", "anthropic", "claude-sonnet-4-6", false},
{"openrouter/google/gemini-2.5-pro", "openrouter", "google/gemini-2.5-pro", false},
{"missing-slash", "", "", true},
{"/missing-provider", "", "", true},
{"missing-model/", "", "", true},
}
for _, tc := range cases {
got, err := opencodeModelPayload(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("opencodeModelPayload(%q): expected error, got nil", tc.in)
}
continue
}
if err != nil {
t.Errorf("opencodeModelPayload(%q): unexpected error %v", tc.in, err)
continue
}
if got["providerID"] != tc.wantProv {
t.Errorf("opencodeModelPayload(%q) providerID = %v, want %q", tc.in, got["providerID"], tc.wantProv)
}
if got["modelID"] != tc.wantMod {
t.Errorf("opencodeModelPayload(%q) modelID = %v, want %q", tc.in, got["modelID"], tc.wantMod)
}
}
}
func TestOpencodeStatusIdle(t *testing.T) {
cases := []struct {
name string
props map[string]any
want bool
}{
{"string idle", map[string]any{"status": "idle"}, true},
{"object idle", map[string]any{"status": map[string]any{"type": "idle"}}, true},
{"object busy", map[string]any{"status": map[string]any{"type": "busy"}}, false},
{"string busy", map[string]any{"status": "busy"}, false},
{"missing", map[string]any{}, false},
{"non-map number", map[string]any{"status": 1.0}, false},
{"nil props", nil, false},
}
for _, tc := range cases {
if got := opencodeStatusIdle(tc.props); got != tc.want {
t.Errorf("opencodeStatusIdle(%s) = %v, want %v", tc.name, got, tc.want)
}
}
}
func TestOpencodeEventEnvelope(t *testing.T) {
cases := []struct {
name string
in string
want string
ok bool
}{
{
name: "plain event",
in: `{"id":"evt_1","type":"session.idle","properties":{"sessionID":"ses_1"}}`,
want: "session.idle",
ok: true,
},
{
name: "global payload event",
in: `{"directory":"/work","payload":{"id":"evt_1","type":"message.part.delta","properties":{"sessionID":"ses_1"}}}`,
want: "message.part.delta",
ok: true,
},
{
name: "sync event ignored",
in: `{"directory":"/work","payload":{"type":"sync","syncEvent":{"type":"message.part.delta.1"}}}`,
ok: false,
},
}
for _, tc := range cases {
got, ok := opencodeEventEnvelope([]byte(tc.in))
if ok != tc.ok {
t.Fatalf("%s: ok=%v, want %v", tc.name, ok, tc.ok)
}
if ok && got.Type != tc.want {
t.Fatalf("%s: type=%q, want %q", tc.name, got.Type, tc.want)
}
}
}
func TestOpencodeMessagePartDeltaText(t *testing.T) {
partTypes := map[string]string{
"prt_text": "text",
"prt_reasoning": "reasoning",
}
cases := []struct {
name string
props map[string]any
want string
}{
{"text part", map[string]any{"partID": "prt_text", "field": "text", "delta": "OK"}, "OK"},
{"reasoning part ignored", map[string]any{"partID": "prt_reasoning", "field": "text", "delta": "thinking"}, ""},
// Unknown part: message.part.updated may have been missed on a new SSE
// connection; allow the delta rather than silently dropping it.
{"unknown part allowed when field=text", map[string]any{"partID": "prt_missing", "field": "text", "delta": "allowed"}, "allowed"},
{"non text field ignored", map[string]any{"partID": "prt_text", "field": "metadata", "delta": "lost"}, ""},
{"empty partID field=text allowed", map[string]any{"partID": "", "field": "text", "delta": "bare"}, "bare"},
}
for _, tc := range cases {
if got := opencodeMessagePartDeltaText(tc.props, partTypes); got != tc.want {
t.Fatalf("%s: got %q, want %q", tc.name, got, tc.want)
}
}
}
func TestOpencodeStepTokensFromPart(t *testing.T) {
var props map[string]any
if err := json.Unmarshal([]byte(`{"part":{"type":"step-finish","tokens":{"input":7,"output":1}}}`), &props); err != nil {
t.Fatal(err)
}
in, out, ok := opencodeStepTokens(props)
if !ok || in != 7 || out != 1 {
t.Fatalf("tokens: got %d/%d ok=%v, want 7/1 true", in, out, ok)
}
}
func TestDecodeSSEDataLine(t *testing.T) {
cases := []struct {
in string
want string
shouldGet bool
}{
{"data: {\"a\":1}", `{"a":1}`, true},
{"data:{\"a\":1}", `{"a":1}`, true},
{"event: ping", "", false},
{": comment", "", false},
{"", "", false},
{"data:", "", false},
{"data: ", "", false},
}
for _, tc := range cases {
got, ok := decodeSSEDataLine([]byte(tc.in))
if ok != tc.shouldGet {
t.Errorf("decodeSSEDataLine(%q) ok=%v, want %v", tc.in, ok, tc.shouldGet)
continue
}
if ok && string(got) != tc.want {
t.Errorf("decodeSSEDataLine(%q) = %q, want %q", tc.in, string(got), tc.want)
}
}
}
func TestOpencodeLocalServerCwd_Workspace(t *testing.T) {
tmpDir := t.TempDir()
workspace := t.TempDir()
scriptPath := filepath.Join(tmpDir, "fake-opencode-server.sh")
scriptContent := fmt.Sprintf(`#!/bin/sh
pwd > "%s/cwd.txt"
echo "opencode server listening on http://127.0.0.1:12345"
`, workspace)
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
t.Fatalf("write fake script: %v", err)
}
profile := config.CLIProfileConf{
Command: scriptPath,
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
url, cmd, err := startOpencodeLocalServer(ctx, profile, opencodeRunOpts{}, workspace, nil)
if err != nil {
t.Fatalf("startOpencodeLocalServer failed: %v", err)
}
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
if url != "http://127.0.0.1:12345" {
t.Errorf("url got %q, want http://127.0.0.1:12345", url)
}
time.Sleep(100 * time.Millisecond)
markerPath := filepath.Join(workspace, "cwd.txt")
cwdBytes, err := os.ReadFile(markerPath)
if err != nil {
t.Fatalf("failed to read marker file: %v", err)
}
cwd := strings.TrimSpace(string(cwdBytes))
resolvedWorkspace, err := os.Readlink(workspace)
if err != nil {
resolvedWorkspace = workspace
}
if cwd != workspace && cwd != resolvedWorkspace {
t.Errorf("expected process cwd to be %q or %q, got %q", workspace, resolvedWorkspace, cwd)
}
}
func TestOpencodeLocalServerCwd_DirOverride(t *testing.T) {
tmpDir := t.TempDir()
workspace := t.TempDir()
overrideDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "fake-opencode-server.sh")
scriptContent := fmt.Sprintf(`#!/bin/sh
pwd > "%s/cwd.txt"
echo "opencode server listening on http://127.0.0.1:12345"
`, overrideDir)
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
t.Fatalf("write fake script: %v", err)
}
profile := config.CLIProfileConf{
Command: scriptPath,
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
opts := opencodeRunOpts{Dir: overrideDir}
url, cmd, err := startOpencodeLocalServer(ctx, profile, opts, workspace, nil)
if err != nil {
t.Fatalf("startOpencodeLocalServer failed: %v", err)
}
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
if url != "http://127.0.0.1:12345" {
t.Errorf("url got %q, want http://127.0.0.1:12345", url)
}
time.Sleep(100 * time.Millisecond)
markerPath := filepath.Join(overrideDir, "cwd.txt")
cwdBytes, err := os.ReadFile(markerPath)
if err != nil {
t.Fatalf("failed to read marker file: %v", err)
}
cwd := strings.TrimSpace(string(cwdBytes))
resolvedOverrideDir, err := os.Readlink(overrideDir)
if err != nil {
resolvedOverrideDir = overrideDir
}
if cwd != overrideDir && cwd != resolvedOverrideDir {
t.Errorf("expected process cwd to be %q or %q, got %q", overrideDir, resolvedOverrideDir, cwd)
}
}