diff --git a/internal/agent/agent.go b/internal/agent/agent.go index f24fd848..f7532cb1 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -29,7 +29,6 @@ import ( "github.com/enowdev/antares/internal/skills" "github.com/enowdev/antares/internal/store" "github.com/enowdev/antares/internal/tools" - "github.com/enowdev/antares/internal/worktree" ) // EventType enumerates what the agent streams to callers. @@ -403,7 +402,7 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error } } - history = a.maybeCompact(runCtx, history, systemPrompt, emit) + history = a.maybeCompact(runCtx, history, systemPrompt, modelName, toolSpecs, emit) llmReq := llm.Request{ Model: modelName, @@ -450,9 +449,9 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error if resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 { _ = emit(Event{ Type: EventUsage, InputTokens: total.InputTokens, OutputTokens: total.OutputTokens, - // This turn's input (plus cached tokens, which still occupy the - // window) is the live context size the fill gauge plots. - ContextTokens: resp.Usage.InputTokens + resp.Usage.CacheReadTokens, + // Context size is provider-normalised: cache counters are billing + // breakdowns whose inclusion in InputTokens differs by API. + ContextTokens: resp.Usage.ContextSize(), ContextWindow: a.contextWindowFor(modelName), }) if !req.Quiet { @@ -886,31 +885,7 @@ func (a *Agent) subAgentFor(parent Request) tools.SubAgent { return a.runSubprocess(ctx, sub) } - // Isolation gives the sub-agent its own worktree so parallel workers on - // the same repository do not conflict. When it cannot be set up, the - // sub-agent shares the workspace rather than failing. - workspace := sub.Workspace - // A sub-agent of a project session inherits the project: it works in the - // project folder and its writes stay confined there (plus the antares - // workspace), same as the parent. An isolated worktree opts out — it gets - // its own tree below. - projectDir := "" - if !sub.Isolate && strings.TrimSpace(parent.ProjectDir) != "" { - projectDir = parent.ProjectDir - if workspace == "" { - workspace = parent.ProjectDir - } - } - var wt *worktree.Worktree - if sub.Isolate { - base := firstNonEmpty(sub.Workspace, a.cfg.Agent.Workspace) - if w, err := worktree.Create(ctx, config.Expand(base), firstNonEmpty(sub.Role, "sub")); err == nil { - wt = w - workspace = w.Path - } else { - slog.Debug("worktree isolation unavailable", "error", err) - } - } + workspace, projectDir, wt := a.prepareSubAgentWorkspace(ctx, parent, sub) subID, untrack := trackSubAgent(sub.Role, sub.Prompt, parent.SessionID) defer untrack() diff --git a/internal/agent/background.go b/internal/agent/background.go index 0e9cdfe9..039c2b0c 100644 --- a/internal/agent/background.go +++ b/internal/agent/background.go @@ -97,7 +97,7 @@ func (a *Agent) startBackground(parent Request, req tools.SubAgentRequest) strin if maxTurns <= 0 { maxTurns = 20 } - workspace := firstNonEmpty(req.Workspace, a.cfg.Agent.Workspace) + workspace, projectDir, wt := a.prepareSubAgentWorkspace(ctx, parent, req) // Not Quiet: the sub-agent keeps a real session, so the task can be // continued later with the task tool's send action. Every event is @@ -109,11 +109,15 @@ func (a *Agent) startBackground(parent Request, req tools.SubAgentRequest) strin Model: req.Model, Role: req.Role, Workspace: workspace, + ProjectDir: projectDir, MaxTurns: maxTurns, Platform: "background", UserID: parent.UserID, Depth: depth, }, subEmit(subID, nil)) + if wt != nil { + wt.Cleanup(ctx) + } a.bg.finish(id, res, err, ctx.Err()) // Signal the delegating session that this worker is done, so the main diff --git a/internal/agent/compact.go b/internal/agent/compact.go index 76094ba0..d9feaece 100644 --- a/internal/agent/compact.go +++ b/internal/agent/compact.go @@ -2,6 +2,7 @@ package agent import ( "context" + "encoding/json" "fmt" "log/slog" "strings" @@ -29,21 +30,18 @@ func (a *Agent) contextWindowFor(model string) int { // maybeCompact summarises older turns once the conversation approaches the // model's context window, keeping recent turns verbatim. -func (a *Agent) maybeCompact(ctx context.Context, history []llm.Message, system string, emit Emit) []llm.Message { +func (a *Agent) maybeCompact(ctx context.Context, history []llm.Message, system, model string, tools []llm.Tool, emit Emit) []llm.Message { cfg := a.cfg.Compression if !cfg.Enabled || len(history) < 8 { return history } - window := a.cfg.Model.ContextWindow - if window <= 0 { - window = 128000 - } + window := a.contextWindowFor(model) threshold := cfg.Threshold if threshold <= 0 || threshold >= 1 { threshold = 0.8 } - used := estimateTokens(history, system) + used := estimateRequestTokens(history, system, tools) if float64(used) < float64(window)*threshold { // Even below the threshold, prune oversized tool results that are no // longer near the tail — they dominate context growth. @@ -91,6 +89,21 @@ func (a *Agent) maybeCompact(ctx context.Context, history []llm.Message, system return compacted } +// estimateRequestTokens includes tool schemas as well as system/history. Large +// agent tool packs are sent on every call and can consume a material part of the +// context window; omitting them delays compaction until the provider rejects the +// request even though the history-only estimate still looks safe. +func estimateRequestTokens(history []llm.Message, system string, tools []llm.Tool) int { + total := estimateTokens(history, system) + for _, tool := range tools { + total += len(tool.Name)/4 + len(tool.Description)/4 + 8 + if schema, err := json.Marshal(tool.Parameters); err == nil { + total += len(schema) / 4 + } + } + return total +} + // rebalanceToolBoundary moves messages between middle and tail so that no tool // result is separated from the assistant turn that requested it. func rebalanceToolBoundary(middle, tail []llm.Message) ([]llm.Message, []llm.Message) { diff --git a/internal/agent/context_usage_test.go b/internal/agent/context_usage_test.go new file mode 100644 index 00000000..a8650b25 --- /dev/null +++ b/internal/agent/context_usage_test.go @@ -0,0 +1,72 @@ +package agent + +import ( + "encoding/json" + "testing" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" +) + +func TestContextWindowForPrefersActiveModelMetadata(t *testing.T) { + cfg := config.Default() + cfg.Model.ContextWindow = 200000 + p := cfg.Providers["custom"] + p.ModelMeta = map[string]config.ModelMeta{ + "cb-kimi-k3": {ContextWindow: 256000}, + } + cfg.Providers["custom"] = p + + a := &Agent{cfg: cfg} + if got := a.contextWindowFor("cb-kimi-k3"); got != 256000 { + t.Fatalf("contextWindowFor() = %d, want 256000", got) + } +} + +func TestEstimateRequestTokensIncludesToolSchemas(t *testing.T) { + history := []llm.Message{{Role: llm.RoleUser, Content: "hello"}} + tools := []llm.Tool{{ + Name: "large_tool", + Description: "tool description", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "payload": map[string]any{"type": "string", "description": string(make([]byte, 4000))}, + }, + }, + }} + + base := estimateTokens(history, "system") + got := estimateRequestTokens(history, "system", tools) + schema, err := json.Marshal(tools[0].Parameters) + if err != nil { + t.Fatal(err) + } + wantMinimum := base + len(tools[0].Name)/4 + len(tools[0].Description)/4 + 8 + len(schema)/4 + if got < wantMinimum { + t.Fatalf("estimateRequestTokens() = %d, want at least %d", got, wantMinimum) + } +} + +func TestUsageEventKeepsCumulativeAndLatestContextSeparate(t *testing.T) { + e := Event{ + Type: EventUsage, + InputTokens: 333000, + ContextTokens: llm.Usage{InputTokens: 145878, CacheReadTokens: 145408, ContextTokens: 145878}.ContextSize(), + ContextWindow: 256000, + } + b, err := json.Marshal(e) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if got["input_tokens"] != float64(333000) { + t.Fatalf("input_tokens = %v", got["input_tokens"]) + } + if got["context_tokens"] != float64(145878) { + t.Fatalf("context_tokens = %v; cached tokens were counted twice", got["context_tokens"]) + } +} diff --git a/internal/agent/subagent_workspace.go b/internal/agent/subagent_workspace.go new file mode 100644 index 00000000..06090872 --- /dev/null +++ b/internal/agent/subagent_workspace.go @@ -0,0 +1,42 @@ +package agent + +import ( + "context" + "log/slog" + "strings" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/tools" + "github.com/enowdev/antares/internal/worktree" +) + +// prepareSubAgentWorkspace gives synchronous and background delegation identical +// workspace semantics. A worker inherits its parent's project by default. When +// isolation is requested, the worktree is created from that project (not from +// the global Antares workspace); failure falls back to the inherited project so +// the worker can still read and edit the files it was delegated. +func (a *Agent) prepareSubAgentWorkspace(ctx context.Context, parent Request, sub tools.SubAgentRequest) (workspace, projectDir string, wt *worktree.Worktree) { + workspace = strings.TrimSpace(sub.Workspace) + parentProject := strings.TrimSpace(parent.ProjectDir) + + if parentProject != "" { + projectDir = parentProject + if workspace == "" { + workspace = parentProject + } + } else if workspace == "" { + workspace = firstNonEmpty(parent.Workspace, a.cfg.Agent.Workspace) + } + + if !sub.Isolate { + return workspace, projectDir, nil + } + + base := firstNonEmpty(sub.Workspace, parentProject, parent.Workspace, a.cfg.Agent.Workspace) + w, err := worktree.Create(ctx, config.Expand(base), firstNonEmpty(sub.Role, "sub")) + if err != nil { + slog.Warn("worktree isolation unavailable; using inherited workspace", "workspace", base, "error", err) + return workspace, projectDir, nil + } + return w.Path, "", w +} diff --git a/internal/agent/subagent_workspace_test.go b/internal/agent/subagent_workspace_test.go new file mode 100644 index 00000000..71d46c3f --- /dev/null +++ b/internal/agent/subagent_workspace_test.go @@ -0,0 +1,91 @@ +package agent + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/tools" +) + +func TestPrepareSubAgentWorkspaceInheritsParentProject(t *testing.T) { + project := t.TempDir() + a := &Agent{cfg: &config.Config{Agent: config.Agent{Workspace: "/global/antares-workspace"}}} + + workspace, projectDir, wt := a.prepareSubAgentWorkspace( + context.Background(), + Request{ProjectDir: project, Workspace: project}, + tools.SubAgentRequest{}, + ) + + if workspace != project { + t.Fatalf("workspace = %q, want inherited project %q", workspace, project) + } + if projectDir != project { + t.Fatalf("projectDir = %q, want %q", projectDir, project) + } + if wt != nil { + t.Fatal("non-isolated worker unexpectedly created a worktree") + } +} + +func TestPrepareSubAgentWorkspaceIsolationFailureKeepsParentProject(t *testing.T) { + project := t.TempDir() // deliberately not a git repository + a := &Agent{cfg: &config.Config{Agent: config.Agent{Workspace: "/global/antares-workspace"}}} + + workspace, projectDir, wt := a.prepareSubAgentWorkspace( + context.Background(), + Request{ProjectDir: project, Workspace: project}, + tools.SubAgentRequest{Isolate: true, Role: "coder"}, + ) + + if workspace != project || projectDir != project || wt != nil { + t.Fatalf("fallback = (%q, %q, %v), want inherited project without worktree", workspace, projectDir, wt) + } +} + +func TestPrepareSubAgentWorkspaceIsolatesParentProject(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } + project := t.TempDir() + runGit := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", project}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.invalid", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.invalid") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + runGit("init", "-q") + if err := os.WriteFile(filepath.Join(project, "README.md"), []byte("test\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit("add", "README.md") + runGit("commit", "-q", "-m", "initial") + + a := &Agent{cfg: &config.Config{Agent: config.Agent{Workspace: "/global/antares-workspace"}}} + workspace, projectDir, wt := a.prepareSubAgentWorkspace( + context.Background(), + Request{ProjectDir: project, Workspace: project}, + tools.SubAgentRequest{Isolate: true, Role: "coder"}, + ) + if wt == nil { + t.Fatal("isolation did not create a worktree from the parent project") + } + t.Cleanup(func() { wt.Cleanup(context.Background()) }) + if workspace != wt.Path { + t.Fatalf("workspace = %q, want worktree %q", workspace, wt.Path) + } + if projectDir != "" { + t.Fatalf("isolated projectDir = %q, want empty", projectDir) + } + if _, err := os.Stat(filepath.Join(workspace, "README.md")); err != nil { + t.Fatalf("worktree does not contain parent project: %v", err) + } +} diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go index d0d50037..50e5799f 100644 --- a/internal/llm/anthropic.go +++ b/internal/llm/anthropic.go @@ -267,6 +267,8 @@ func (c *anthropicClient) fromResponse(raw *antResponse) (*Response, error) { resp.Usage = Usage{ InputTokens: raw.Usage.InputTokens, OutputTokens: raw.Usage.OutputTokens, CacheReadTokens: raw.Usage.CacheReadInputTokens, CacheWriteTokens: raw.Usage.CacheCreationInputTokens, + // Anthropic reports cache reads/creation alongside uncached input. + ContextTokens: raw.Usage.InputTokens + raw.Usage.CacheReadInputTokens + raw.Usage.CacheCreationInputTokens, } } return resp, nil @@ -340,6 +342,7 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request, emit func(Eve usage.InputTokens = u.InputTokens usage.CacheReadTokens = u.CacheReadInputTokens usage.CacheWriteTokens = u.CacheCreationInputTokens + usage.ContextTokens = usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens } } case "content_block_start": @@ -381,6 +384,7 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request, emit func(Eve if ev.Usage.CacheReadInputTokens > 0 { usage.CacheReadTokens = ev.Usage.CacheReadInputTokens } + usage.ContextTokens = usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens u := usage return emit(Event{Type: EventUsage, Usage: &u}) } diff --git a/internal/llm/anthropic_usage_test.go b/internal/llm/anthropic_usage_test.go index 454d10a0..5487bc9e 100644 --- a/internal/llm/anthropic_usage_test.go +++ b/internal/llm/anthropic_usage_test.go @@ -43,6 +43,9 @@ func TestAnthropicStreamUsageFromMessageDelta(t *testing.T) { if resp.Usage.CacheReadTokens != 2 { t.Errorf("cache read tokens = %d, want 2", resp.Usage.CacheReadTokens) } + if got, want := resp.Usage.ContextSize(), 15; got != want { + t.Errorf("context size = %d, want %d", got, want) + } } // TestAnthropicStreamUsageFromMessageStart guards the real-Anthropic path: @@ -76,4 +79,7 @@ func TestAnthropicStreamUsageFromMessageStart(t *testing.T) { if resp.Usage.CacheReadTokens != 5 { t.Errorf("cache read tokens = %d, want 5", resp.Usage.CacheReadTokens) } + if got, want := resp.Usage.ContextSize(), 105; got != want { + t.Errorf("context size = %d, want %d", got, want) + } } diff --git a/internal/llm/codex.go b/internal/llm/codex.go index c94b5401..ba077279 100644 --- a/internal/llm/codex.go +++ b/internal/llm/codex.go @@ -178,7 +178,10 @@ func (r *responsesReply) toResponse() (*Response, error) { } resp.Content, resp.Reasoning = text.String(), reasoning.String() if r.Usage != nil { - resp.Usage = Usage{InputTokens: r.Usage.InputTokens, OutputTokens: r.Usage.OutputTokens} + resp.Usage = Usage{ + InputTokens: r.Usage.InputTokens, OutputTokens: r.Usage.OutputTokens, + ContextTokens: r.Usage.InputTokens, + } } return resp, nil } diff --git a/internal/llm/context_usage_test.go b/internal/llm/context_usage_test.go new file mode 100644 index 00000000..78df505d --- /dev/null +++ b/internal/llm/context_usage_test.go @@ -0,0 +1,33 @@ +package llm + +import "testing" + +func TestOpenAIUsageCachedTokensArePromptSubset(t *testing.T) { + u := (&oaUsage{ + PromptTokens: 145878, + PromptDetails: &struct { + CachedTokens int `json:"cached_tokens"` + }{CachedTokens: 145408}, + }).normalise() + + if got, want := u.ContextSize(), 145878; got != want { + t.Fatalf("ContextSize() = %d, want %d; cached prompt tokens must not be counted twice", got, want) + } + if u.CacheReadTokens != 145408 { + t.Fatalf("CacheReadTokens = %d, want billing breakdown preserved", u.CacheReadTokens) + } +} + +func TestUsageContextSizeFallbackDoesNotAssumeCacheSemantics(t *testing.T) { + u := Usage{InputTokens: 100, CacheReadTokens: 20, CacheWriteTokens: 5} + if got, want := u.ContextSize(), 100; got != want { + t.Fatalf("ContextSize() fallback = %d, want %d without double-counting unknown cache semantics", got, want) + } +} + +func TestUsageContextSizeHonorsProviderNormalisation(t *testing.T) { + u := Usage{InputTokens: 100, CacheReadTokens: 20, CacheWriteTokens: 5, ContextTokens: 125} + if got, want := u.ContextSize(), 125; got != want { + t.Fatalf("ContextSize() = %d, want provider-normalised %d", got, want) + } +} diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 9ee39d1f..e5d27d27 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -297,6 +297,8 @@ func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error) resp.Usage = Usage{ InputTokens: u.PromptTokenCount, OutputTokens: u.CandidatesTokenCount, CacheReadTokens: u.CachedContentTokenCount, ReasoningTokens: u.ThoughtsTokenCount, + // Gemini promptTokenCount includes cachedContentTokenCount. + ContextTokens: u.PromptTokenCount, } } return resp, nil @@ -331,6 +333,8 @@ func (c *geminiClient) Stream(ctx context.Context, req Request, emit func(Event) usage = Usage{ InputTokens: u.PromptTokenCount, OutputTokens: u.CandidatesTokenCount, CacheReadTokens: u.CachedContentTokenCount, ReasoningTokens: u.ThoughtsTokenCount, + // Gemini promptTokenCount includes cachedContentTokenCount. + ContextTokens: u.PromptTokenCount, } cp := usage if err := emit(Event{Type: EventUsage, Usage: &cp}); err != nil { diff --git a/internal/llm/openai.go b/internal/llm/openai.go index b1c2874f..5ee04e62 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -238,7 +238,12 @@ func (u *oaUsage) normalise() Usage { if u == nil { return Usage{} } - out := Usage{InputTokens: u.PromptTokens, OutputTokens: u.CompletionTokens} + out := Usage{ + InputTokens: u.PromptTokens, OutputTokens: u.CompletionTokens, + // OpenAI prompt_tokens already includes cached tokens; the details field + // is a subset used for billing, not additional context. + ContextTokens: u.PromptTokens, + } if u.PromptDetails != nil { out.CacheReadTokens = u.PromptDetails.CachedTokens } diff --git a/internal/llm/types.go b/internal/llm/types.go index 4771ed66..5a20a32c 100644 --- a/internal/llm/types.go +++ b/internal/llm/types.go @@ -74,15 +74,33 @@ type Request struct { } // Usage reports token accounting for one call. +// +// ContextTokens is the provider-normalised size of the prompt that occupied the +// model context for this call. It is deliberately separate from cache billing +// fields: OpenAI-compatible and Gemini APIs report cached tokens as a subset of +// InputTokens, while Anthropic reports them alongside InputTokens. Consumers +// must not infer context size by blindly adding cache fields. type Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CacheReadTokens int `json:"cache_read_tokens"` CacheWriteTokens int `json:"cache_write_tokens"` ReasoningTokens int `json:"reasoning_tokens"` + ContextTokens int `json:"context_tokens"` Cost float64 `json:"cost"` } +// ContextSize returns the provider-normalised prompt size for context-window +// accounting. Adapters with cache counters whose semantics differ set +// ContextTokens explicitly. The conservative fallback treats InputTokens as the +// complete prompt so an unknown adapter cannot double-count a cache subset. +func (u Usage) ContextSize() int { + if u.ContextTokens > 0 { + return u.ContextTokens + } + return u.InputTokens +} + // Response is the final result of a completion. type Response struct { Content string `json:"content"` diff --git a/internal/tools/shell.go b/internal/tools/shell.go index 03d7a36f..2d457858 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -119,7 +119,11 @@ func defaultShell(configured string) (string, []string) { if runtime.GOOS == "windows" { return "powershell.exe", []string{"-NoLogo", "-NoProfile", "-Command", "-"} } - for _, sh := range []string{os.Getenv("SHELL"), "/bin/bash", "/bin/sh"} { + // The persistent-shell protocol below emits POSIX syntax (`$?`, `printf`). + // Do not inherit an arbitrary interactive shell such as fish: fish parses + // `$?` as an error, never prints the completion sentinel, and leaves every + // terminal call waiting until its timeout even though the command finished. + for _, sh := range []string{"/bin/bash", "/bin/sh"} { if sh == "" { continue } diff --git a/internal/tools/shell_test.go b/internal/tools/shell_test.go new file mode 100644 index 00000000..8a8d5173 --- /dev/null +++ b/internal/tools/shell_test.go @@ -0,0 +1,62 @@ +package tools + +import ( + "context" + "os" + "runtime" + "testing" + "time" + + "github.com/enowdev/antares/internal/config" +) + +func TestDefaultShellDoesNotInheritNonPOSIXShell(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell selection does not apply on Windows") + } + + old := os.Getenv("SHELL") + t.Cleanup(func() { _ = os.Setenv("SHELL", old) }) + if err := os.Setenv("SHELL", "/bin/fish"); err != nil { + t.Fatal(err) + } + + shell, _ := defaultShell("") + if shell == "/bin/fish" { + t.Fatalf("defaultShell inherited %q, but the sentinel protocol requires a POSIX shell", shell) + } + if shell != "/bin/bash" && shell != "/bin/sh" { + t.Fatalf("defaultShell = %q, want /bin/bash or /bin/sh", shell) + } +} + +func TestDefaultShellHonorsExplicitConfiguration(t *testing.T) { + shell, args := defaultShell("/custom/shell") + if shell != "/custom/shell" { + t.Fatalf("defaultShell(configured) = %q", shell) + } + if len(args) != 1 || args[0] != "-i" { + t.Fatalf("configured shell args = %#v, want [-i]", args) + } +} + +func TestDefaultShellCommandEmitsCompletionSentinel(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX persistent shell protocol does not apply on Windows") + } + + m := NewShellManager(config.Terminal{}) + t.Cleanup(m.CloseAll) + sess, err := m.session("test-session", t.TempDir()) + if err != nil { + t.Fatal(err) + } + + out, code, err := sess.run(context.Background(), "printf ANTARES_SHELL_OK", 2*time.Second, nil) + if err != nil { + t.Fatalf("command did not complete via sentinel: %v", err) + } + if code != 0 || out != "ANTARES_SHELL_OK" { + t.Fatalf("command result = (%q, %d), want (%q, 0)", out, code, "ANTARES_SHELL_OK") + } +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 3f33f128..7059f7be 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -447,7 +447,13 @@ export default function ChatPage() { abortRef.current?.() abortRef.current = null setStreaming(false) - }, []) + if (sessionId) { + void post<{ interrupted: boolean }>('/chat/interrupt', { session_id: sessionId }).catch(() => { + // The stream is already closed locally. A failed interrupt will surface + // when the user reattaches instead of leaving the stop button stuck. + }) + } + }, [sessionId]) // Apply one stream event to the named assistant message. Shared by a fresh // send and a reattach, so both render a turn identically. Session handling @@ -1652,10 +1658,11 @@ export function StreamingIndicator({ const { t } = useI18n() const [secs, setSecs] = useState(0) useEffect(() => { + setSecs(0) const start = Date.now() const id = setInterval(() => setSecs(Math.round((Date.now() - start) / 1000)), 1000) return () => clearInterval(id) - }, []) + }, [turn, tool, waiting]) // Paused on a question: no timer, no pulsing "working" — the run is idle by // design, waiting on the person. Otherwise show the running tool / step. if (waiting) {