summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/hexai-mcp-server/main.go33
-rw-r--r--cmd/hexai-mcp-server/main_test.go54
-rw-r--r--internal/askcli/command_add.go2
-rw-r--r--internal/askcli/command_complete_uuids.go2
-rw-r--r--internal/askcli/command_complete_uuids_test.go31
-rw-r--r--internal/askcli/command_delete_test.go31
-rw-r--r--internal/askcli/command_dep.go2
-rw-r--r--internal/askcli/command_dep_test.go65
-rw-r--r--internal/askcli/command_info.go2
-rw-r--r--internal/askcli/command_info_add_test.go94
-rw-r--r--internal/askcli/command_list.go2
-rw-r--r--internal/askcli/command_list_test.go80
-rw-r--r--internal/askcli/command_projects.go23
-rw-r--r--internal/askcli/command_projects_test.go52
-rw-r--r--internal/askcli/command_start_deps.go2
-rw-r--r--internal/askcli/command_start_deps_test.go37
-rw-r--r--internal/askcli/command_urgency.go2
-rw-r--r--internal/askcli/command_urgency_test.go18
-rw-r--r--internal/askcli/command_watch_test.go19
-rw-r--r--internal/askcli/command_write_test.go10
-rw-r--r--internal/askcli/dispatch.go20
-rw-r--r--internal/askcli/dispatch_test.go38
-rw-r--r--internal/askcli/render_task_list.go8
-rw-r--r--internal/askcli/render_task_list_test.go21
-rw-r--r--internal/askcli/task_alias_cache.go54
-rw-r--r--internal/askcli/task_alias_cache_test.go84
-rw-r--r--internal/askcli/task_selector.go15
-rw-r--r--internal/askcli/task_selector_test.go87
-rw-r--r--internal/hexaiaction/tui_custom.go2
-rw-r--r--internal/hexaiaction/tui_custom_test.go17
-rw-r--r--internal/hexaicli/cache_test.go30
-rw-r--r--internal/hexaicli/run.go27
-rw-r--r--internal/hexaicli/run_model_override_test.go7
-rw-r--r--internal/hexaicli/run_output_test.go6
-rw-r--r--internal/hexaicli/run_test.go13
-rw-r--r--internal/hexaicli/run_timeout_test.go8
-rw-r--r--internal/hexaicli/runner.go3
-rw-r--r--internal/hexaicli/simulation_test.go12
-rw-r--r--internal/tmuxedit/agentutil.go12
-rw-r--r--internal/tmuxedit/agentutil_test.go7
-rw-r--r--internal/tmuxedit/pane.go15
-rw-r--r--internal/tmuxedit/send_test.go2
42 files changed, 452 insertions, 597 deletions
diff --git a/cmd/hexai-mcp-server/main.go b/cmd/hexai-mcp-server/main.go
index 03150bb..741e310 100644
--- a/cmd/hexai-mcp-server/main.go
+++ b/cmd/hexai-mcp-server/main.go
@@ -25,13 +25,6 @@ func buildOverrides(opts mcpOptions) hexaimcp.MCPOverrides {
}
}
-// Seams for testing: override in tests to avoid launching real MCP server.
-// Signatures match hexaimcp.Run and hexaimcp.RunBackfill respectively.
-var (
- runMCP = hexaimcp.Run
- runBackfill = hexaimcp.RunBackfill
-)
-
// deprecationWarning is the notice runMain emits on every startup so users
// see this binary is experimental. Kept as a constant (not printf'd) so
// tests can assert on its contents directly.
@@ -65,6 +58,18 @@ type mcpOptions struct {
showVersion bool
}
+type mcpDeps struct {
+ runMCP func(context.Context, string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error
+ runBackfill func(context.Context, string, string, hexaimcp.MCPOverrides) error
+}
+
+func defaultMCPDeps() mcpDeps {
+ return mcpDeps{
+ runMCP: hexaimcp.Run,
+ runBackfill: hexaimcp.RunBackfill,
+ }
+}
+
func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) }
// runMain prints the deprecation warning, parses flags, and delegates to
@@ -73,6 +78,10 @@ func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) }
// Pulling this out of main keeps it testable without touching package-level
// flag state.
func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+ return runMainWithDeps(args, stdin, stdout, stderr, defaultMCPDeps())
+}
+
+func runMainWithDeps(args []string, stdin io.Reader, stdout, stderr io.Writer, deps mcpDeps) int {
fmt.Fprint(stderr, deprecationWarning)
defaultLog, err := defaultLogPath()
@@ -107,7 +116,7 @@ func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
- if err := run(ctx, opts, stdin, stdout, stderr); err != nil {
+ if err := runWithDeps(ctx, opts, stdin, stdout, stderr, deps); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
@@ -118,6 +127,10 @@ func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
// CLI flag values are passed via MCPOverrides instead of environment variables.
// ctx is threaded into the server/backfill so they stop on shutdown signals.
func run(ctx context.Context, opts mcpOptions, stdin io.Reader, stdout, stderr io.Writer) error {
+ return runWithDeps(ctx, opts, stdin, stdout, stderr, defaultMCPDeps())
+}
+
+func runWithDeps(ctx context.Context, opts mcpOptions, stdin io.Reader, stdout, stderr io.Writer, deps mcpDeps) error {
if opts.showVersion {
fmt.Fprintln(stdout, internal.Version)
return nil
@@ -127,10 +140,10 @@ func run(ctx context.Context, opts mcpOptions, stdin io.Reader, stdout, stderr i
// Handle backfill operation
if opts.syncAll {
- return runBackfill(ctx, opts.logPath, opts.configPath, overrides)
+ return deps.runBackfill(ctx, opts.logPath, opts.configPath, overrides)
}
- return runMCP(ctx, opts.logPath, opts.configPath, overrides, stdin, stdout, stderr)
+ return deps.runMCP(ctx, opts.logPath, opts.configPath, overrides, stdin, stdout, stderr)
}
// defaultLogPath returns the default MCP log file path in the state directory.
diff --git a/cmd/hexai-mcp-server/main_test.go b/cmd/hexai-mcp-server/main_test.go
index b2a3895..ded0be7 100644
--- a/cmd/hexai-mcp-server/main_test.go
+++ b/cmd/hexai-mcp-server/main_test.go
@@ -66,12 +66,10 @@ func TestBuildOverrides(t *testing.T) {
}
func TestRun_SyncAll(t *testing.T) {
- old := runBackfill
- t.Cleanup(func() { runBackfill = old })
-
var gotLog, gotConfig string
var gotOverrides hexaimcp.MCPOverrides
- runBackfill = func(_ context.Context, logPath, configPath string, overrides hexaimcp.MCPOverrides) error {
+ deps := defaultMCPDeps()
+ deps.runBackfill = func(_ context.Context, logPath, configPath string, overrides hexaimcp.MCPOverrides) error {
gotLog = logPath
gotConfig = configPath
gotOverrides = overrides
@@ -86,7 +84,7 @@ func TestRun_SyncAll(t *testing.T) {
slashCommandSync: true,
slashCommandDir: "/tmp/cmds",
}
- if err := run(context.Background(), opts, nil, nil, nil); err != nil {
+ if err := runWithDeps(context.Background(), opts, nil, nil, nil, deps); err != nil {
t.Fatalf("run syncAll: %v", err)
}
if gotLog != "/tmp/test.log" {
@@ -107,30 +105,26 @@ func TestRun_SyncAll(t *testing.T) {
}
func TestRun_SyncAllError(t *testing.T) {
- old := runBackfill
- t.Cleanup(func() { runBackfill = old })
-
wantErr := errors.New("backfill failed")
- runBackfill = func(_ context.Context, _, _ string, _ hexaimcp.MCPOverrides) error { return wantErr }
+ deps := defaultMCPDeps()
+ deps.runBackfill = func(_ context.Context, _, _ string, _ hexaimcp.MCPOverrides) error { return wantErr }
opts := mcpOptions{syncAll: true}
- if err := run(context.Background(), opts, nil, nil, nil); !errors.Is(err, wantErr) {
+ if err := runWithDeps(context.Background(), opts, nil, nil, nil, deps); !errors.Is(err, wantErr) {
t.Fatalf("expected backfill error, got: %v", err)
}
}
func TestRun_MCPServer(t *testing.T) {
- old := runMCP
- t.Cleanup(func() { runMCP = old })
-
called := false
- runMCP = func(_ context.Context, logPath, configPath string, overrides hexaimcp.MCPOverrides, stdin io.Reader, stdout, stderr io.Writer) error {
+ deps := defaultMCPDeps()
+ deps.runMCP = func(_ context.Context, logPath, configPath string, overrides hexaimcp.MCPOverrides, stdin io.Reader, stdout, stderr io.Writer) error {
called = true
return nil
}
opts := mcpOptions{logPath: "/tmp/mcp.log"}
- if err := run(context.Background(), opts, nil, nil, nil); err != nil {
+ if err := runWithDeps(context.Background(), opts, nil, nil, nil, deps); err != nil {
t.Fatalf("run MCP: %v", err)
}
if !called {
@@ -139,15 +133,13 @@ func TestRun_MCPServer(t *testing.T) {
}
func TestRun_MCPServerError(t *testing.T) {
- old := runMCP
- t.Cleanup(func() { runMCP = old })
-
wantErr := errors.New("server failed")
- runMCP = func(_ context.Context, _, _ string, _ hexaimcp.MCPOverrides, _ io.Reader, _, _ io.Writer) error {
+ deps := defaultMCPDeps()
+ deps.runMCP = func(_ context.Context, _, _ string, _ hexaimcp.MCPOverrides, _ io.Reader, _, _ io.Writer) error {
return wantErr
}
- if err := run(context.Background(), mcpOptions{}, nil, nil, nil); !errors.Is(err, wantErr) {
+ if err := runWithDeps(context.Background(), mcpOptions{}, nil, nil, nil, deps); !errors.Is(err, wantErr) {
t.Fatalf("expected server error, got: %v", err)
}
}
@@ -172,17 +164,15 @@ func TestRunMain_VersionFlag(t *testing.T) {
// runMain --sync-all path: forwards parsed options to runBackfill and
// returns 0 on success.
func TestRunMain_SyncAllSuccess(t *testing.T) {
- old := runBackfill
- t.Cleanup(func() { runBackfill = old })
-
var gotLog string
- runBackfill = func(_ context.Context, logPath string, _ string, _ hexaimcp.MCPOverrides) error {
+ deps := defaultMCPDeps()
+ deps.runBackfill = func(_ context.Context, logPath string, _ string, _ hexaimcp.MCPOverrides) error {
gotLog = logPath
return nil
}
var stdout, stderr bytes.Buffer
- code := runMain([]string{"-sync-all", "-log", "/tmp/sync.log"}, nil, &stdout, &stderr)
+ code := runMainWithDeps([]string{"-sync-all", "-log", "/tmp/sync.log"}, nil, &stdout, &stderr, deps)
if code != 0 {
t.Fatalf("runMain code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -194,14 +184,13 @@ func TestRunMain_SyncAllSuccess(t *testing.T) {
// runMain run-error path: when the underlying server fails, runMain must
// return 1 (the production exit code) and write the error to stderr.
func TestRunMain_ServerErrorReturnsOne(t *testing.T) {
- old := runMCP
- t.Cleanup(func() { runMCP = old })
- runMCP = func(context.Context, string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error {
+ deps := defaultMCPDeps()
+ deps.runMCP = func(context.Context, string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error {
return errors.New("mcp boom")
}
var stdout, stderr bytes.Buffer
- code := runMain(nil, nil, &stdout, &stderr)
+ code := runMainWithDeps(nil, nil, &stdout, &stderr, deps)
if code != 1 {
t.Fatalf("runMain code = %d, want 1", code)
}
@@ -212,15 +201,14 @@ func TestRunMain_ServerErrorReturnsOne(t *testing.T) {
// Bad flag must yield exit 2 without ever invoking the server stub.
func TestRunMain_BadFlagReturnsTwo(t *testing.T) {
- old := runMCP
- t.Cleanup(func() { runMCP = old })
called := false
- runMCP = func(context.Context, string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error {
+ deps := defaultMCPDeps()
+ deps.runMCP = func(context.Context, string, string, hexaimcp.MCPOverrides, io.Reader, io.Writer, io.Writer) error {
called = true
return nil
}
var stdout, stderr bytes.Buffer
- code := runMain([]string{"--bogus"}, nil, &stdout, &stderr)
+ code := runMainWithDeps([]string{"--bogus"}, nil, &stdout, &stderr, deps)
if code != 2 {
t.Fatalf("runMain code = %d, want 2", code)
}
diff --git a/internal/askcli/command_add.go b/internal/askcli/command_add.go
index ccfb034..0793693 100644
--- a/internal/askcli/command_add.go
+++ b/internal/askcli/command_add.go
@@ -56,7 +56,7 @@ func (d *Dispatcher) createTask(ctx context.Context, modifiers []string, descrip
// problem as a warning on stderr but still report success on stdout with
// exit 0 so the user does not retry and create a duplicate task. The
// displayed identifier falls back to the UUID when no alias is available.
- aliases, aliasErr := ensureTaskAliasesForUUIDs([]string{uuid})
+ aliases, aliasErr := d.aliasCache.withDefaults().ensureTaskAliasesForUUIDs([]string{uuid})
if aliasErr != nil {
fmt.Fprintf(stderr, "warning: failed to assign task alias: %v\n", aliasErr)
aliases = nil
diff --git a/internal/askcli/command_complete_uuids.go b/internal/askcli/command_complete_uuids.go
index 5c26649..567602d 100644
--- a/internal/askcli/command_complete_uuids.go
+++ b/internal/askcli/command_complete_uuids.go
@@ -36,7 +36,7 @@ func (d *Dispatcher) completeTaskSelectors(ctx context.Context, args []string, s
fmt.Fprintf(stderr, "error: failed to parse task data: %v\n", err)
return 1, nil
}
- aliases, err := ensureTaskAliases(tasks)
+ aliases, err := d.aliasCache.withDefaults().ensureTaskAliases(tasks)
if err != nil {
fmt.Fprintf(stderr, "warning: failed to update task alias cache: %v\n", err)
aliases = nil
diff --git a/internal/askcli/command_complete_uuids_test.go b/internal/askcli/command_complete_uuids_test.go
index 2c1b5fd..b7a2e80 100644
--- a/internal/askcli/command_complete_uuids_test.go
+++ b/internal/askcli/command_complete_uuids_test.go
@@ -13,14 +13,8 @@ import (
func TestHandleCompleteUUIDs_PrintsPendingUUIDs(t *testing.T) {
dir := t.TempDir()
- oldNow := nowTaskAliasCache
- oldRoot := taskAliasCacheRoot
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- defer func() {
- nowTaskAliasCache = oldNow
- taskAliasCacheRoot = oldRoot
- }()
+ now := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC)
+ deps := testTaskAliasCacheDeps(dir, &now)
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
want := []string{"status:pending", "export"}
@@ -30,6 +24,7 @@ func TestHandleCompleteUUIDs_PrintsPendingUUIDs(t *testing.T) {
_, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"First task"},{"uuid":"uuid-2","description":"Second task"},{"uuid":""}]`)
return 0, nil
}})
+ d.aliasCache = deps
var stdout, stderr bytes.Buffer
code, err := d.handleCompleteUUIDs(context.Background(), nil, &stdout, &stderr)
@@ -48,7 +43,7 @@ func TestHandleCompleteUUIDs_PrintsPendingUUIDs(t *testing.T) {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
- path, err := taskAliasCachePath()
+ path, err := deps.taskAliasCachePath()
if err != nil {
t.Fatalf("taskAliasCachePath: %v", err)
}
@@ -82,11 +77,9 @@ func TestHandleCompleteUUIDs_ParseError(t *testing.T) {
func TestHandleCompleteUUIDs_RecoverFromCorruptAliasCache(t *testing.T) {
dir := t.TempDir()
- oldRoot := taskAliasCacheRoot
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- defer func() { taskAliasCacheRoot = oldRoot }()
+ deps := testTaskAliasCacheDeps(dir, nil)
- path, err := taskAliasCachePath()
+ path, err := deps.taskAliasCachePath()
if err != nil {
t.Fatalf("taskAliasCachePath: %v", err)
}
@@ -104,6 +97,7 @@ func TestHandleCompleteUUIDs_RecoverFromCorruptAliasCache(t *testing.T) {
_, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"Fallback task"}]`)
return 0, nil
}})
+ d.aliasCache = deps
var stdout, stderr bytes.Buffer
code, err := d.handleCompleteUUIDs(context.Background(), nil, &stdout, &stderr)
@@ -168,14 +162,8 @@ func TestTaskCompletionAliasItems_OnlyShortAliases(t *testing.T) {
func TestHandleCompleteAliases_PrintsAliasesOnly(t *testing.T) {
dir := t.TempDir()
- oldNow := nowTaskAliasCache
- oldRoot := taskAliasCacheRoot
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- defer func() {
- nowTaskAliasCache = oldNow
- taskAliasCacheRoot = oldRoot
- }()
+ now := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC)
+ deps := testTaskAliasCacheDeps(dir, &now)
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
want := []string{"status:pending", "export"}
@@ -185,6 +173,7 @@ func TestHandleCompleteAliases_PrintsAliasesOnly(t *testing.T) {
_, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"First task"},{"uuid":"uuid-2","description":"Second task"},{"uuid":""}]`)
return 0, nil
}})
+ d.aliasCache = deps
var stdout, stderr bytes.Buffer
code, err := d.handleCompleteAliases(context.Background(), nil, &stdout, &stderr)
diff --git a/internal/askcli/command_delete_test.go b/internal/askcli/command_delete_test.go
index 62d3e48..d86ee0b 100644
--- a/internal/askcli/command_delete_test.go
+++ b/internal/askcli/command_delete_test.go
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"io"
- "path/filepath"
"strings"
"testing"
"time"
@@ -12,21 +11,15 @@ import (
func TestHandleDelete_Success(t *testing.T) {
dir := t.TempDir()
- oldRoot := taskAliasCacheRoot
- oldNow := nowTaskAliasCache
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- defer func() {
- taskAliasCacheRoot = oldRoot
- nowTaskAliasCache = oldNow
- }()
+ now := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC)
+ deps := testTaskAliasCacheDeps(dir, &now)
writeTaskAliasCacheForTest(t, taskAliasCache{
NextID: 1,
Entries: []taskAliasCacheEntry{
- {UUID: "test-uuid-123", Alias: "0", CreatedAt: nowTaskAliasCache()},
+ {UUID: "test-uuid-123", Alias: "0", CreatedAt: now},
},
- })
+ }, deps)
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
if len(args) == 2 && args[0] == "uuid:test-uuid-123" && args[1] == "export" {
@@ -35,6 +28,7 @@ func TestHandleDelete_Success(t *testing.T) {
}
return 0, nil
}})
+ d.aliasCache = deps
var stdout, stderr bytes.Buffer
code, err := d.Dispatch(context.Background(), []string{"delete", "test-uuid-123"}, &bytes.Buffer{}, &stdout, &stderr)
if code != 0 {
@@ -120,21 +114,15 @@ func TestHandleDelete_PassesCorrectArgs(t *testing.T) {
func TestHandleDelete_AliasSelector(t *testing.T) {
dir := t.TempDir()
- oldRoot := taskAliasCacheRoot
- oldNow := nowTaskAliasCache
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- defer func() {
- taskAliasCacheRoot = oldRoot
- nowTaskAliasCache = oldNow
- }()
+ now := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC)
+ deps := testTaskAliasCacheDeps(dir, &now)
writeTaskAliasCacheForTest(t, taskAliasCache{
NextID: 1,
Entries: []taskAliasCacheEntry{
- {UUID: "test-uuid-123", Alias: "0", CreatedAt: nowTaskAliasCache()},
+ {UUID: "test-uuid-123", Alias: "0", CreatedAt: now},
},
- })
+ }, deps)
var capturedArgs []string
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
@@ -145,6 +133,7 @@ func TestHandleDelete_AliasSelector(t *testing.T) {
capturedArgs = args
return 0, nil
}})
+ d.aliasCache = deps
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"delete", "0"}, &bytes.Buffer{}, &stdout, &stderr)
diff --git a/internal/askcli/command_dep.go b/internal/askcli/command_dep.go
index aa28df8..ce65aa4 100644
--- a/internal/askcli/command_dep.go
+++ b/internal/askcli/command_dep.go
@@ -75,7 +75,7 @@ func (d *Dispatcher) handleDepList(ctx context.Context, args []string, stdout, s
if len(task.Depends) == 0 {
_, _ = io.WriteString(stdout, "no dependencies\n")
} else {
- aliases, err := ensureTaskAliasesForUUIDs(task.Depends)
+ aliases, err := d.aliasCache.withDefaults().ensureTaskAliasesForUUIDs(task.Depends)
if err != nil {
fmt.Fprintf(stderr, "error: failed to load task aliases: %v\n", err)
return 1, nil
diff --git a/internal/askcli/command_dep_test.go b/internal/askcli/command_dep_test.go
index 4cbcd26..fe3ab70 100644
--- a/internal/askcli/command_dep_test.go
+++ b/internal/askcli/command_dep_test.go
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"io"
- "path/filepath"
"strings"
"testing"
"time"
@@ -12,22 +11,16 @@ import (
func TestHandleDep_AddSuccess(t *testing.T) {
dir := t.TempDir()
- oldRoot := taskAliasCacheRoot
- oldNow := nowTaskAliasCache
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- defer func() {
- taskAliasCacheRoot = oldRoot
- nowTaskAliasCache = oldNow
- }()
+ now := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC)
+ deps := testTaskAliasCacheDeps(dir, &now)
writeTaskAliasCacheForTest(t, taskAliasCache{
NextID: 2,
Entries: []taskAliasCacheEntry{
- {UUID: "uuid-1", Alias: "0", CreatedAt: nowTaskAliasCache()},
- {UUID: "uuid-2", Alias: "1", CreatedAt: nowTaskAliasCache()},
+ {UUID: "uuid-1", Alias: "0", CreatedAt: now},
+ {UUID: "uuid-2", Alias: "1", CreatedAt: now},
},
- })
+ }, deps)
var capturedArgs []string
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
@@ -43,6 +36,7 @@ func TestHandleDep_AddSuccess(t *testing.T) {
capturedArgs = args
return 0, nil
}})
+ d.aliasCache = deps
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"dep", "add", "uuid-1", "uuid-2"}, nil, &stdout, &stderr)
if code != 0 {
@@ -59,14 +53,8 @@ func TestHandleDep_AddSuccess(t *testing.T) {
func TestHandleDep_RmSuccess(t *testing.T) {
dir := t.TempDir()
- oldRoot := taskAliasCacheRoot
- oldNow := nowTaskAliasCache
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- defer func() {
- taskAliasCacheRoot = oldRoot
- nowTaskAliasCache = oldNow
- }()
+ now := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC)
+ deps := testTaskAliasCacheDeps(dir, &now)
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
if len(args) == 2 && args[1] == "export" {
@@ -80,6 +68,7 @@ func TestHandleDep_RmSuccess(t *testing.T) {
}
return 0, nil
}})
+ d.aliasCache = deps
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"dep", "rm", "uuid-1", "uuid-2"}, nil, &stdout, &stderr)
if code != 0 {
@@ -89,29 +78,24 @@ func TestHandleDep_RmSuccess(t *testing.T) {
func TestHandleDep_ListSuccess(t *testing.T) {
dir := t.TempDir()
- oldRoot := taskAliasCacheRoot
- oldNow := nowTaskAliasCache
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- defer func() {
- taskAliasCacheRoot = oldRoot
- nowTaskAliasCache = oldNow
- }()
+ now := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC)
+ deps := testTaskAliasCacheDeps(dir, &now)
writeTaskAliasCacheForTest(t, taskAliasCache{
NextID: 3,
Entries: []taskAliasCacheEntry{
- {UUID: "dep-1", Alias: "1", CreatedAt: nowTaskAliasCache()},
- {UUID: "dep-2", Alias: "2", CreatedAt: nowTaskAliasCache()},
- {UUID: "uuid-1", Alias: "0", CreatedAt: nowTaskAliasCache()},
+ {UUID: "dep-1", Alias: "1", CreatedAt: now},
+ {UUID: "dep-2", Alias: "2", CreatedAt: now},
+ {UUID: "uuid-1", Alias: "0", CreatedAt: now},
},
- })
+ }, deps)
jsonData := `[{"uuid":"uuid-1","description":"Task","status":"pending","priority":"M","tags":[],"urgency":10,"depends":["dep-1","dep-2"]}]`
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
_, _ = io.WriteString(stdout, jsonData)
return 0, nil
}})
+ d.aliasCache = deps
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"dep", "list", "uuid-1"}, nil, &stdout, &stderr)
if code != 0 {
@@ -198,22 +182,16 @@ func TestHandleDep_AcceptUUIDPrefix(t *testing.T) {
func TestHandleDep_AliasSelectors(t *testing.T) {
dir := t.TempDir()
- oldRoot := taskAliasCacheRoot
- oldNow := nowTaskAliasCache
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- defer func() {
- taskAliasCacheRoot = oldRoot
- nowTaskAliasCache = oldNow
- }()
+ now := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC)
+ deps := testTaskAliasCacheDeps(dir, &now)
writeTaskAliasCacheForTest(t, taskAliasCache{
NextID: 2,
Entries: []taskAliasCacheEntry{
- {UUID: "uuid-1", Alias: "0", CreatedAt: nowTaskAliasCache()},
- {UUID: "uuid-2", Alias: "1", CreatedAt: nowTaskAliasCache()},
+ {UUID: "uuid-1", Alias: "0", CreatedAt: now},
+ {UUID: "uuid-2", Alias: "1", CreatedAt: now},
},
- })
+ }, deps)
var capturedArgs []string
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
@@ -229,6 +207,7 @@ func TestHandleDep_AliasSelectors(t *testing.T) {
capturedArgs = args
return 0, nil
}})
+ d.aliasCache = deps
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"dep", "add", "0", "1"}, nil, &stdout, &stderr)
diff --git a/internal/askcli/command_info.go b/internal/askcli/command_info.go
index ba2e37d..6c02583 100644
--- a/internal/askcli/command_info.go
+++ b/internal/askcli/command_info.go
@@ -16,7 +16,7 @@ func (d *Dispatcher) handleInfo(ctx context.Context, args []string, stdout, stde
return code, nil
}
allUUIDs := append([]string{tasks[0].UUID}, tasks[0].Depends...)
- aliases, err := ensureTaskAliasesForUUIDs(allUUIDs)
+ aliases, err := d.aliasCache.withDefaults().ensureTaskAliasesForUUIDs(allUUIDs)
if err != nil {
fmt.Fprintf(stderr, "error: failed to load task aliases: %v\n", err)
return 1, nil
diff --git a/internal/askcli/command_info_add_test.go b/internal/askcli/command_info_add_test.go
index b918518..ad82606 100644
--- a/internal/askcli/command_info_add_test.go
+++ b/internal/askcli/command_info_add_test.go
@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"io"
- "path/filepath"
"strings"
"testing"
"time"
@@ -14,22 +13,16 @@ import (
func TestHandleInfo_Success(t *testing.T) {
unsetTestEnv(t, "HEXAI_DEBUG")
dir := t.TempDir()
- oldRoot := taskAliasCacheRoot
- oldNow := nowTaskAliasCache
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- defer func() {
- taskAliasCacheRoot = oldRoot
- nowTaskAliasCache = oldNow
- }()
+ now := time.Date(2026, 3, 26, 12, 0,