From b4f06c656d8d8733a9bf2e2e2a5eb2a7a48389bb Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 11 Jun 2026 00:28:27 +0300 Subject: Extract shared chatrun package to eliminate chat-runner DRY violations The CLI, LSP server and tmux code-action tool each carried near-identical chat-running logic: invoking the LLM (streaming-aware), collecting the response, and accounting sent/received bytes into the stats package. Introduce internal/chatrun with: - Invoke: streaming-aware LLM call that collects the full response and optionally mirrors chunks to a writer (nil writer = collect only). - SentBytes / Account: shared byte counting and stats.Update. Wire all three surfaces to it: - hexaicli: runChatRequest delegates to chatrun.Invoke; summarizeChatRun uses chatrun.Account. Removed the duplicated streaming/simple helpers. - hexaiaction: runOnce uses chatrun.Invoke + chatrun.Account, keeping the tmux status update local. - lsp: chatWithStats and the completion path use chatrun.SentBytes/Invoke; extracted unavailableClientError to keep chatWithStats small. chatrun has 100% test coverage; full suite passes with -race. Co-Authored-By: Claude Opus 4.8 --- internal/hexaiaction/prompts.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) (limited to 'internal/hexaiaction/prompts.go') diff --git a/internal/hexaiaction/prompts.go b/internal/hexaiaction/prompts.go index 4b2d8be..c5e496b 100644 --- a/internal/hexaiaction/prompts.go +++ b/internal/hexaiaction/prompts.go @@ -6,6 +6,7 @@ import ( "time" "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/chatrun" "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/llmutils" "codeberg.org/snonux/hexai/internal/stats" @@ -115,25 +116,27 @@ func runCustom(ctx context.Context, cfg actionConfig, client chatDoer, ca appcon // runOnce sends a single system+user prompt pair to the LLM, strips code // fences from the response, records stats, and updates the tmux status line. // Pass a zero-value requestArgs{} when no extra options are needed. +// +// The LLM invocation and byte/stats accounting are delegated to the shared +// chatrun package so this surface stays in lock-step with the CLI and LSP. The +// tmux status update is kept here because it is specific to the action tool's +// reporting style. func runOnce(ctx context.Context, client chatDoer, sys, user string, req requestArgs) (string, error) { msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - txt, err := client.Chat(ctx, msgs, req.options...) + // runOnce never streams to a writer, so pass nil out. + txt, err := chatrun.Invoke(ctx, client, msgs, req.options, nil) if err != nil { return "", err } out := strings.TrimSpace(StripFences(txt)) - // Contribute to global stats and update tmux status - sent := 0 - for _, m := range msgs { - sent += len(m.Content) - } - recv := len(out) model := strings.TrimSpace(req.model) if model == "" { model = client.DefaultModel() } provider := providerOf(client) - _ = stats.Update(ctx, provider, model, sent, recv) + // Account for the exchange against the post-fence-strip response so the + // recorded recv bytes match what the user actually receives. + chatrun.Account(ctx, provider, model, msgs, out) if snap, err := stats.TakeSnapshot(); err == nil { scopeReqs := snap.ScopeReqs(provider, model) scopeRPM := snap.ScopeRPM(provider, model) -- cgit v1.2.3