iop/apps/edge/cmd/edge/console_test.go
toki ffc30cb927 feat(edge): add console foreground dedupe task and update console events
- Add edge_console_foreground_dedupe agent-task with plan and code review logs
- Update console_events.go with foreground dedupe support
- Update console_test.go with related test cases
- Update e2e-smoke SKILL.md
2026-05-16 22:59:38 +09:00

430 lines
14 KiB
Go

package main
import (
"bytes"
"context"
"strings"
"testing"
"time"
edgenode "iop/apps/edge/internal/node"
edgeservice "iop/apps/edge/internal/service"
eventpkg "iop/packages/events"
iop "iop/proto/gen/iop"
)
func TestBuildRunRequest_SessionAndBackground(t *testing.T) {
req, runID, err := edgeservice.BuildRunRequest(edgeservice.SubmitRunRequest{
Adapter: "cli",
Target: "codex",
SessionID: "session-a",
Background: true,
TimeoutSec: 30,
Prompt: "hello",
})
if err != nil {
t.Fatalf("buildRunRequest: %v", err)
}
if runID == "" {
t.Fatal("expected non-empty runID")
}
if req.GetAdapter() != "cli" {
t.Errorf("Adapter: got %q want %q", req.GetAdapter(), "cli")
}
if req.GetTarget() != "codex" {
t.Errorf("wire target: got %q want %q", req.GetTarget(), "codex")
}
if req.GetSessionId() != "session-a" {
t.Errorf("SessionId: got %q want %q", req.GetSessionId(), "session-a")
}
if !req.GetBackground() {
t.Error("Background: expected true")
}
if req.GetSessionMode() != iop.RunSessionMode_RUN_SESSION_MODE_CREATE_IF_MISSING {
t.Errorf("SessionMode: got %v", req.GetSessionMode())
}
}
func TestBuildRunRequest_EmptySessionNormalized(t *testing.T) {
req, _, err := edgeservice.BuildRunRequest(edgeservice.SubmitRunRequest{
Adapter: "cli",
Target: "codex",
TimeoutSec: 30,
Prompt: "hello",
})
if err != nil {
t.Fatalf("buildRunRequest: %v", err)
}
if req.GetSessionId() != "default" {
t.Errorf("SessionId: got %q want %q", req.GetSessionId(), "default")
}
}
func TestBuildRunRequest_DefaultTimeoutFallback(t *testing.T) {
req, _, err := edgeservice.BuildRunRequest(edgeservice.SubmitRunRequest{
Adapter: "cli",
Target: "codex",
SessionID: "s1",
Prompt: "hello",
})
if err != nil {
t.Fatalf("buildRunRequest: %v", err)
}
if req.GetTimeoutSec() != 30 {
t.Errorf("TimeoutSec: got %d want 30", req.GetTimeoutSec())
}
}
func TestNormalizeConsoleSessionID(t *testing.T) {
if got := normalizeConsoleSessionID(""); got != "default" {
t.Errorf("empty: got %q want %q", got, "default")
}
if got := normalizeConsoleSessionID("my-session"); got != "my-session" {
t.Errorf("non-empty: got %q want %q", got, "my-session")
}
}
func TestConsoleEventRouterSuppressesTrackedForegroundRun(t *testing.T) {
var out bytes.Buffer
router := newConsoleEventRouter(&out, nil, nil)
unregister := router.TrackForeground("run-1")
defer unregister()
router.Handle(&iop.RunEvent{RunId: "run-1", Type: "start"})
if out.Len() != 0 {
t.Fatalf("registered foreground event should not be printed asynchronously: %q", out.String())
}
}
func TestConsoleEventRouterIgnoresLateForegroundAfterUntrack(t *testing.T) {
var out bytes.Buffer
router := newConsoleEventRouter(&out, nil, nil)
unregister := router.TrackForeground("run-fg")
unregister()
router.Handle(&iop.RunEvent{RunId: "run-fg", Type: "start", SessionId: "s1"})
router.Handle(&iop.RunEvent{RunId: "run-fg", Type: "delta", Delta: "hi"})
router.Handle(&iop.RunEvent{RunId: "run-fg", Type: "complete", Message: "done"})
if got := out.String(); got != "" {
t.Fatalf("expected no async output for late foreground events, got:\n%s", got)
}
}
func TestConsoleEventRouterPrintsUnregisteredRun(t *testing.T) {
var out bytes.Buffer
router := newConsoleEventRouter(&out, nil, nil)
router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "start", SessionId: "s1", Background: true})
router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "delta", Delta: "hello", Background: true})
if got := out.String(); !strings.Contains(got, "[node-unknown-message] hello") {
t.Fatalf("expected delta to be printed before completion, got:\n%s", got)
}
router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "delta", Delta: " world\n", Background: true})
router.Handle(&iop.RunEvent{RunId: "run-bg", Type: "complete", Message: "done", Background: true})
got := out.String()
for _, want := range []string{
"[node-unknown-event] start run_id=run-bg session=s1 background=true",
"[node-unknown-event] complete run_id=run-bg detail=\"done\"",
"[node-unknown-message] hello world",
} {
if !strings.Contains(got, want) {
t.Fatalf("expected output to contain %q, got:\n%s", want, got)
}
}
}
func TestConsoleResponseStreamWritesBeforeFinish(t *testing.T) {
var out bytes.Buffer
stream := newConsoleResponseStream(&out, "[node-test-message] ")
stream.Write("hello")
if got := out.String(); got != "[node-test-message] hello" {
t.Fatalf("Write should print immediately, got %q", got)
}
stream.Write(" world")
stream.Finish()
if got := out.String(); got != "[node-test-message] hello world\n" {
t.Fatalf("Finish should close the line, got %q", got)
}
}
func TestBuildNodeCommandRequest_StatusUsesCurrentTarget(t *testing.T) {
req, reqID := buildNodeCommandRequest("cli", "codex", "default", 30)
if reqID == "" {
t.Fatal("expected non-empty reqID")
}
if req.GetType() != iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS {
t.Errorf("Type: got %v want %v", req.GetType(), iop.NodeCommandType_NODE_COMMAND_TYPE_USAGE_STATUS)
}
if req.GetAdapter() != "cli" {
t.Errorf("Adapter: got %q want %q", req.GetAdapter(), "cli")
}
if req.GetTarget() != "codex" {
t.Errorf("Target: got %q want %q", req.GetTarget(), "codex")
}
if req.GetSessionId() != "default" {
t.Errorf("SessionId: got %q want %q", req.GetSessionId(), "default")
}
if req.GetTimeoutSec() != 30 {
t.Errorf("TimeoutSec: got %d want 30", req.GetTimeoutSec())
}
}
func TestStatusWaitTimeout_UsesNormalizedRequestTimeout(t *testing.T) {
// 0 should be normalized to 30, and then +5 added
req, _ := buildNodeCommandRequest("cli", "codex", "default", 0)
got := statusWaitTimeout(req)
want := 35 * time.Second
if got != want {
t.Errorf("expected %v, got %v", want, got)
}
// Explicit 60 should result in 65
req2, _ := buildNodeCommandRequest("cli", "codex", "default", 60)
got2 := statusWaitTimeout(req2)
want2 := 65 * time.Second
if got2 != want2 {
t.Errorf("expected %v, got %v", want2, got2)
}
}
func TestFormatUsageStatus_PrintsParsedLimits(t *testing.T) {
var out bytes.Buffer
formatUsageStatus(&out, "local-node", "codex", "default", &iop.AgentUsageStatus{
DailyLimit: "98%",
DailyResetTime: "18:38",
WeeklyLimit: "22%",
WeeklyResetTime: "10:20 on 8 May",
})
got := out.String()
if !strings.Contains(got, "Daily limit: 98% remaining (resets 18:38)") {
t.Errorf("expected Daily limit: 98%% remaining (resets 18:38), got:\n%s", got)
}
if !strings.Contains(got, "Weekly limit: 22% remaining (resets 10:20 on 8 May") {
t.Errorf("expected Weekly limit: 22%% remaining (resets 10:20 on 8 May), got:\n%s", got)
}
}
func TestFormatUsageStatus_PrintsClaudeLabels(t *testing.T) {
var out bytes.Buffer
formatUsageStatus(&out, "local-node", "claude", "default", &iop.AgentUsageStatus{
DailyLimit: "98%",
DailyResetTime: "10am (Asia/Seoul)",
WeeklyLimit: "50%",
WeeklyResetTime: "May 16, 6pm (Asia/Seoul)",
Metadata: map[string]string{
"daily_label": "Current session",
"weekly_label": "Current week",
},
})
got := out.String()
if !strings.Contains(got, "Current session: 98% remaining (resets 10am (Asia/Seoul))") {
t.Errorf("expected Current session label, got:\n%s", got)
}
if !strings.Contains(got, "Current week: 50% remaining (resets May 16, 6pm (Asia/Seoul))") {
t.Errorf("expected Current week label, got:\n%s", got)
}
}
func TestFormatUsageStatus_PrintsGeminiQuotaMetadata(t *testing.T) {
var out bytes.Buffer
formatUsageStatus(&out, "local-node", "gemini", "default", &iop.AgentUsageStatus{
DailyLimit: "100%",
DailyResetTime: "24h",
Metadata: map[string]string{
"daily_label": "Daily quota",
"used_percent": "0%",
"usage_limit": "200",
},
})
got := out.String()
if !strings.Contains(got, "Daily quota: 100% remaining (resets 24h)") {
t.Errorf("expected Daily quota line, got:\n%s", got)
}
if !strings.Contains(got, "Used: 0%") {
t.Errorf("expected Used: 0%%, got:\n%s", got)
}
if !strings.Contains(got, "Usage limit: 200") {
t.Errorf("expected Usage limit: 200, got:\n%s", got)
}
}
func TestFormatUsageStatus_PrintsGeminiModelUsage(t *testing.T) {
var out bytes.Buffer
formatUsageStatus(&out, "local-node", "gemini", "default", &iop.AgentUsageStatus{
DailyLimit: "100%",
Metadata: map[string]string{
"daily_label": "Daily quota",
"used_percent": "0%",
"model_usage_count": "2",
"model_usage_0_name": "Flash",
"model_usage_0_used_percent": "0%",
"model_usage_0_reset": "2:15 PM (24h)",
"model_usage_1_name": "gemini-3.1-flash-lite",
"model_usage_1_used_percent": "3%",
"model_usage_1_reset": "12:48 PM (22h 34m)",
},
})
got := out.String()
for _, want := range []string{
"Model usage:",
" Flash: 0% used (resets 2:15 PM (24h))",
" gemini-3.1-flash-lite: 3% used (resets 12:48 PM (22h 34m))",
} {
if !strings.Contains(got, want) {
t.Errorf("expected %q, got:\n%s", want, got)
}
}
}
func TestFormatUsageStatus_Error(t *testing.T) {
// The node reports an error string directly inside the command response, which is checked before formatUsageStatus is called.
// But we should test if raw status block is printed properly if no limits are parsed.
var out bytes.Buffer
formatUsageStatus(&out, "local-node", "codex", "default", &iop.AgentUsageStatus{
RawOutput: "unparsed error text",
})
got := out.String()
if !strings.Contains(got, "raw output did not include parsed limits:") {
t.Errorf("expected raw output warning, got:\n%s", got)
}
if !strings.Contains(got, "unparsed error text") {
t.Errorf("expected unparsed text, got:\n%s", got)
}
}
func TestResolveConsoleNode_RequiresExplicitSelectionForMultipleNodes(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-1"})
reg.Register(&edgenode.NodeEntry{NodeID: "node-2"})
svc := edgeservice.New(reg, nil)
// Empty target should fail for multiple nodes
if _, err := svc.ResolveNode(""); err == nil {
t.Error("expected error for implicit resolve with multiple nodes")
}
// Explicit target should succeed
if e, err := svc.ResolveNode("node-1"); err != nil || e.NodeID != "node-1" {
t.Errorf("failed explicit resolve: %v", err)
}
}
func TestResolveConsoleNode_AllowsSingleNodeFallback(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-1"})
svc := edgeservice.New(reg, nil)
if e, err := svc.ResolveNode(""); err != nil || e.NodeID != "node-1" {
t.Errorf("expected single node fallback, got error: %v", err)
}
}
func TestPrintNodes_ShowsSelectedNode(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "alias-1"})
reg.Register(&edgenode.NodeEntry{NodeID: "node-2", Alias: "alias-2"})
var out bytes.Buffer
printNodes(&out, reg.All(), "alias-1")
got := out.String()
if !strings.Contains(got, "* node-1 (alias-1)") {
t.Errorf("expected selected node-1 to have marker, got:\n%s", got)
}
if !strings.Contains(got, " node-2 (alias-2)") {
t.Errorf("expected non-selected node-2 to have no marker, got:\n%s", got)
}
}
func TestHandleTerminateSession_OutputFormat(t *testing.T) {
// Mock sendTerminateSessionFunc to avoid network/toki dependency
oldFunc := sendTerminateSessionFunc
defer func() { sendTerminateSessionFunc = oldFunc }()
sendTerminateSessionFunc = func(ctx context.Context, edgeSvc *edgeservice.Service, target *consoleTarget) (string, error) {
return "mock-alias", nil
}
target := &consoleTarget{
SessionID: "s-test",
}
var out bytes.Buffer
handleTerminateSession(context.Background(), nil, &out, target)
got := out.String()
want := "terminated session s-test node=mock-alias\n"
if got != want {
t.Errorf("OutputFormat (alias): got %q, want %q", got, want)
}
// Test fallback to NodeID in output
sendTerminateSessionFunc = func(ctx context.Context, edgeSvc *edgeservice.Service, target *consoleTarget) (string, error) {
return "node-raw-id", nil
}
out.Reset()
handleTerminateSession(context.Background(), nil, &out, target)
got2 := out.String()
want2 := "terminated session s-test node=node-raw-id\n"
if got2 != want2 {
t.Errorf("OutputFormat (node_id): got %q, want %q", got2, want2)
}
}
func TestConsoleEventRouterPrintsNodeScopedAsyncRun(t *testing.T) {
var out bytes.Buffer
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "alias-1"})
router := newConsoleEventRouter(&out, reg, nil)
router.Handle(&iop.RunEvent{
RunId: "run-1",
Type: "start",
NodeId: "node-1",
Background: true,
})
got := out.String()
if !strings.Contains(got, "[node-alias-1-event] start run_id=run-1") {
t.Errorf("expected node-alias-1-event label, got:\n%s", got)
}
}
func TestConsoleEventRouterFallsBackToNodeID(t *testing.T) {
var out bytes.Buffer
router := newConsoleEventRouter(&out, nil, nil)
router.Handle(&iop.RunEvent{
RunId: "run-1",
Type: "start",
NodeId: "some-raw-id",
Background: true,
})
got := out.String()
if !strings.Contains(got, "[node-some-raw-id-event] start run_id=run-1") {
t.Errorf("expected node-some-raw-id-event label, got:\n%s", got)
}
}
func TestConsoleEventRouterPrintsNodeLifecycleEvents(t *testing.T) {
var out bytes.Buffer
router := newConsoleEventRouter(&out, nil, nil)
router.HandleNodeEvent(&iop.EdgeNodeEvent{
Type: eventpkg.TypeNodeDisconnected,
NodeId: "node-1",
Alias: "alias-1",
Reason: eventpkg.ReasonTransportClosed,
})
got := out.String()
if !strings.Contains(got, `[node-alias-1-event] disconnected reason="transport_closed"`) {
t.Errorf("expected disconnected lifecycle output, got:\n%s", got)
}
}