mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c23a661df2 | ||
|
|
87158a1c90 | ||
|
|
16550569c1 | ||
|
|
008bc9b3f3 | ||
|
|
fd69233e33 | ||
|
|
3f0503bc23 | ||
|
|
f951aee65d | ||
|
|
82ecac5281 | ||
|
|
387fe67ee7 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.1.0"}
|
||||
{".":"0.2.0"}
|
||||
@@ -6,6 +6,71 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
|
||||
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
|
||||
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
|
||||
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
|
||||
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
|
||||
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
|
||||
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
|
||||
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
|
||||
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
|
||||
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
|
||||
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
|
||||
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
|
||||
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
|
||||
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
|
||||
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
|
||||
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
|
||||
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
|
||||
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
|
||||
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
|
||||
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
|
||||
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
|
||||
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
|
||||
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
|
||||
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
|
||||
* **docs:** rename AGENTS.MD > AGENTS.md ([#438](https://github.com/Gitlawb/zero/issues/438)) ([4266baf](https://github.com/Gitlawb/zero/commit/4266baf222df583ed2078b776687f12d496475b5))
|
||||
* **gemini:** strip unsupported JSON Schema fields from tool declarations ([#374](https://github.com/Gitlawb/zero/issues/374)) ([39e7100](https://github.com/Gitlawb/zero/commit/39e7100674150144a1152e3110c64c7cf0321d64)), closes [#373](https://github.com/Gitlawb/zero/issues/373)
|
||||
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
|
||||
* **mcp:** block cross-origin credential redirects ([#396](https://github.com/Gitlawb/zero/issues/396)) ([f915f70](https://github.com/Gitlawb/zero/commit/f915f70e5a3096e2419fa8d961a0f84a626fa4a9))
|
||||
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
|
||||
* **openai:** handle Ollama reasoning stream deltas ([#486](https://github.com/Gitlawb/zero/issues/486)) ([f6c0606](https://github.com/Gitlawb/zero/commit/f6c060631e18e082dda24cc4dc0903c31c2120d6))
|
||||
* preserve conversation context in exec prompts ([#460](https://github.com/Gitlawb/zero/issues/460)) ([949ee43](https://github.com/Gitlawb/zero/commit/949ee43f71e5cb7fab4695c5cb7b442fe4ecfbf7))
|
||||
* **provider-wizard:** allow multiple custom OpenAI-compatible providers ([#403](https://github.com/Gitlawb/zero/issues/403)) ([3fbbd28](https://github.com/Gitlawb/zero/commit/3fbbd28e4c586822cc4312c86232d94befe56e87))
|
||||
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
|
||||
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
|
||||
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
|
||||
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
|
||||
* Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs ([#509](https://github.com/Gitlawb/zero/issues/509)) ([0f69d99](https://github.com/Gitlawb/zero/commit/0f69d995e9b586b774f66c066b21abab5e03024a))
|
||||
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
|
||||
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
|
||||
* **tools:** fix cmd.exe /S/C corrupting commands with embedded quotes ([#465](https://github.com/Gitlawb/zero/issues/465)) ([190241b](https://github.com/Gitlawb/zero/commit/190241bd593f43211b766e0b13c8e89802d4bb37))
|
||||
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
|
||||
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
|
||||
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
|
||||
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
|
||||
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
|
||||
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
|
||||
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
|
||||
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
|
||||
* universal tool-output ceiling with spill + async post-edit diagnostics ([#518](https://github.com/Gitlawb/zero/issues/518)) ([95ccd5b](https://github.com/Gitlawb/zero/commit/95ccd5bc327f6fb464ff0239f7229de789f578dc))
|
||||
|
||||
## 0.1.0 (2026-07-02)
|
||||
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ Common slash commands:
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
| `/add-dir` | allow an extra write directory for this session |
|
||||
|
||||
@@ -14,6 +14,7 @@ require (
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/sys v0.46.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
|
||||
@@ -32,6 +32,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
|
||||
+54
-21
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -159,8 +160,14 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
|
||||
// Load history BEFORE publishing the session so no concurrent prompt observes
|
||||
// a half-initialized session (registerSession sets history under the lock and
|
||||
// reuses an already-live session rather than orphaning its in-flight turn).
|
||||
history := a.loadHistory(meta.SessionID)
|
||||
history, historyErr := a.loadHistory(meta.SessionID)
|
||||
sess := a.registerSession(meta.SessionID, root, history)
|
||||
a.warnPersistence(
|
||||
¬ifier{conn: a.conn, sessionID: sess.id},
|
||||
"load session history",
|
||||
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
|
||||
historyErr,
|
||||
)
|
||||
return LoadSessionResult{
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
@@ -253,7 +260,14 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
if stopErr != nil {
|
||||
return "", RPCError(codeInternalError, stopErr.Error())
|
||||
}
|
||||
a.persistTurn(sess, userText, result.FinalAnswer)
|
||||
if err := a.persistTurn(sess, userText, result.FinalAnswer); err != nil {
|
||||
a.warnPersistence(
|
||||
note,
|
||||
"save session history",
|
||||
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return reason, nil
|
||||
}
|
||||
|
||||
@@ -386,29 +400,34 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
|
||||
|
||||
// ---- persistence + continuity ----
|
||||
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) {
|
||||
if a.deps.Store != nil {
|
||||
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "user", "content": user},
|
||||
})
|
||||
if assistant != "" {
|
||||
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": assistant},
|
||||
})
|
||||
}
|
||||
}
|
||||
sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string) []turnRecord {
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
|
||||
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
if a.deps.Store == nil {
|
||||
return nil
|
||||
}
|
||||
events := []sessions.AppendEventInput{
|
||||
{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "user", "content": user},
|
||||
},
|
||||
}
|
||||
if assistant != "" {
|
||||
events = append(events, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": assistant},
|
||||
})
|
||||
}
|
||||
_, err := a.deps.Store.AppendEvents(sess.id, events)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
if a.deps.Store == nil {
|
||||
return nil, nil
|
||||
}
|
||||
events, err := a.deps.Store.ReadEvents(sessionID)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
var records []turnRecord
|
||||
var pendingUser string
|
||||
@@ -444,7 +463,21 @@ func (a *Agent) loadHistory(sessionID string) []turnRecord {
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
return records
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
sessionID := ""
|
||||
if note != nil {
|
||||
sessionID = note.sessionID
|
||||
}
|
||||
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
|
||||
if note != nil {
|
||||
note.text("\n\n[zero warning] " + message + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// buildPrompt prepends prior conversation as context, since agent.Run drives a
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -242,8 +244,77 @@ func TestACPRejectsInvalidCwd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPPromptWarnsWhenTurnPersistenceFails(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
metadataPath := filepath.Join(deps.Store.RootDir, newRes.SessionID, sessions.MetadataFile)
|
||||
if err := os.Remove(metadataPath); err != nil {
|
||||
t.Fatalf("remove metadata: %v", err)
|
||||
}
|
||||
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{TextBlock("hi")},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
got := drainTextUntil(t, h.updates, func(text string) bool {
|
||||
return strings.Contains(text, "Hello from ZERO") &&
|
||||
strings.Contains(text, "Could not save session history")
|
||||
})
|
||||
if !strings.Contains(got, "Could not save session history") {
|
||||
t.Fatalf("streamed text = %q, want persistence warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPLoadWarnsWhenHistoryReadFails(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
cwd := t.TempDir()
|
||||
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
eventsPath := filepath.Join(deps.Store.RootDir, meta.SessionID, sessions.EventsFile)
|
||||
if err := os.WriteFile(eventsPath, []byte("{bad json}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write corrupt events: %v", err)
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
got := drainTextUntil(t, h.updates, func(text string) bool {
|
||||
return strings.Contains(text, "Could not load session history")
|
||||
})
|
||||
if !strings.Contains(got, "Could not load session history") {
|
||||
t.Fatalf("streamed text = %q, want load warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
// drainText collects streamed chunks for a short window and concatenates them.
|
||||
func drainText(t *testing.T, ch <-chan string) string {
|
||||
t.Helper()
|
||||
return drainTextUntil(t, ch, func(text string) bool {
|
||||
return strings.Contains(text, "Hello from ZERO")
|
||||
})
|
||||
}
|
||||
|
||||
func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
deadline := time.After(2 * time.Second)
|
||||
@@ -251,7 +322,7 @@ func drainText(t *testing.T, ch <-chan string) string {
|
||||
select {
|
||||
case s := <-ch:
|
||||
b.WriteString(s)
|
||||
if strings.Contains(b.String(), "Hello from ZERO") {
|
||||
if done(b.String()) {
|
||||
return b.String()
|
||||
}
|
||||
case <-deadline:
|
||||
|
||||
@@ -721,7 +721,8 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Content: nudge,
|
||||
})
|
||||
}
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
|
||||
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
result.Messages = copyMessages(finalMessages)
|
||||
@@ -741,7 +742,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, options Options) (string, []zeroruntime.Message, string) {
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
finalMessages := copyMessages(messages)
|
||||
finalMessages = append(finalMessages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -752,6 +753,7 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
|
||||
// transient hiccup doesn't drop the final summary (AUDIT-L1).
|
||||
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(finalMessages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}, reconnectNoticeFor(options))
|
||||
|
||||
@@ -2768,11 +2768,11 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
|
||||
t.Fatalf("expected final answer from finalization turn, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected final no-tools request after max turns, got %d requests", len(provider.requests))
|
||||
t.Fatalf("expected finalization request after max turns, got %d requests", len(provider.requests))
|
||||
}
|
||||
finalRequest := provider.requests[1]
|
||||
if len(finalRequest.Tools) != 0 {
|
||||
t.Fatalf("finalization request must not advertise tools, got %#v", finalRequest.Tools)
|
||||
if toolDefinitionByName(finalRequest.Tools, "read_file") == nil {
|
||||
t.Fatalf("finalization request must keep prior tools exposed for strict provider replay, got %#v", finalRequest.Tools)
|
||||
}
|
||||
lastMessage := finalRequest.Messages[len(finalRequest.Messages)-1]
|
||||
if lastMessage.Role != zeroruntime.MessageRoleUser || !strings.Contains(lastMessage.Content, "tool-turn limit") {
|
||||
|
||||
+20
-3
@@ -729,8 +729,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Scope: scope,
|
||||
})
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
sttServerManager := newDictationServerManager(resolved.STT)
|
||||
// Keep STT downloads in the SAME config tree the rest of the TUI uses. Deriving
|
||||
// from userConfigPath (rather than config.UserConfigDir()) matters when the config
|
||||
// root is overridden — e.g. in tests or a custom ZERO config dir — so the two
|
||||
// don't diverge. userConfigPath points at .../zero/config.json, so its dir is the
|
||||
// zero config dir.
|
||||
sttDownloadRoot := ""
|
||||
if userConfigPath != "" {
|
||||
sttDownloadRoot = filepath.Join(filepath.Dir(userConfigPath), "stt")
|
||||
}
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
Version: version,
|
||||
Theme: theme,
|
||||
SavedTheme: resolved.Preferences.Theme,
|
||||
UserConfigPath: userConfigPath,
|
||||
@@ -791,9 +802,15 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
|
||||
return merged
|
||||
}),
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
STT: resolved.STT,
|
||||
BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager),
|
||||
ShutdownDictationServer: sttServerManager.Shutdown,
|
||||
STTDownloadRoot: sttDownloadRoot,
|
||||
STTKeyStatus: newSTTKeyStatus(resolved, userConfigPath),
|
||||
SaveSTTKey: newSaveSTTKey(userConfigPath),
|
||||
Setup: tui.SetupOptions{
|
||||
Visible: setupVisible,
|
||||
Required: needsSetup,
|
||||
|
||||
+45
-2
@@ -11,10 +11,46 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/provideroauth"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
// ensureLoginProviderProfile makes a freshly stored OAuth login visible as a
|
||||
// provider: without a profile in config.json the login shows up nowhere — not in
|
||||
// `zero providers list`, not in the TUI picker — and `zero providers use <name>`
|
||||
// fails "not found", so the user's working active provider looks broken while
|
||||
// the new login looks lost. Returns user-facing guidance lines ("" when the
|
||||
// login has no catalog entry to scaffold from). Best-effort by design: the
|
||||
// token is already stored, so a profile-write failure degrades to a warning
|
||||
// line instead of failing a login that succeeded.
|
||||
func ensureLoginProviderProfile(deps appDeps, provider string) string {
|
||||
if _, ok := providercatalog.Get(provider); !ok {
|
||||
// Custom OAuth server without a catalog entry — no endpoint/model defaults
|
||||
// to scaffold a profile from; the user wires their own profile.
|
||||
return ""
|
||||
}
|
||||
configPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return "warning: login saved, but no provider profile was written: " + err.Error()
|
||||
}
|
||||
ensured, err := config.EnsureCatalogProvider(configPath, provider)
|
||||
if err != nil {
|
||||
return "warning: login saved, but no provider profile was written: " + err.Error()
|
||||
}
|
||||
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
|
||||
switch {
|
||||
case ensured.Created && active:
|
||||
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name)
|
||||
case ensured.Created:
|
||||
return fmt.Sprintf("Added provider %q to your config; the active provider is still %q.\nSwitch with: zero providers use %s", ensured.Name, ensured.Active, ensured.Name)
|
||||
case active:
|
||||
return fmt.Sprintf("Provider %q is configured and active.", ensured.Name)
|
||||
default:
|
||||
return fmt.Sprintf("Provider %q is already configured.\nSwitch with: zero providers use %s", ensured.Name, ensured.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
|
||||
// additive and independent of `zero mcp oauth` (MCP server auth), which is
|
||||
// unchanged.
|
||||
@@ -138,8 +174,10 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
|
||||
if _, err := fmt.Fprint(stdout, statuses); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5.5\n"); err != nil {
|
||||
return exitCrash
|
||||
if line := ensureLoginProviderProfile(deps, "chatgpt"); line != "" {
|
||||
if _, err := fmt.Fprintf(stdout, "\n%s\n", line); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
@@ -331,6 +369,11 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if _, err := fmt.Fprintf(stdout, "Logged in to %s.\n%s\n", parsed.positional[0], oauth.FormatStatuses([]oauth.Status{status})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if line := ensureLoginProviderProfile(deps, provider); line != "" {
|
||||
if _, err := fmt.Fprintln(stdout, line); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,24 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
// withAuthStore points the provider OAuth store at a temp file for the test.
|
||||
// withAuthStore points the provider OAuth store at a temp file for the test,
|
||||
// pinning the file backend so an inherited ZERO_OAUTH_STORAGE=keyring can't
|
||||
// ignore the temp path and hit the OS keychain.
|
||||
func withAuthStore(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -197,3 +203,81 @@ func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLoginProviderProfileAddsProviderWithoutStealingActive(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
||||
|
||||
line := ensureLoginProviderProfile(deps, "chatgpt")
|
||||
if !strings.Contains(line, `Added provider "chatgpt"`) {
|
||||
t.Fatalf("expected added-provider guidance, got %q", line)
|
||||
}
|
||||
if !strings.Contains(line, "zero providers use chatgpt") {
|
||||
t.Fatalf("expected switch hint, got %q", line)
|
||||
}
|
||||
|
||||
cfg := readCLIConfigFixture(t, configPath)
|
||||
if cfg.ActiveProvider != "opengateway" {
|
||||
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
|
||||
}
|
||||
names := make([]string, 0, len(cfg.Providers))
|
||||
for _, provider := range cfg.Providers {
|
||||
names = append(names, provider.Name)
|
||||
}
|
||||
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
|
||||
t.Fatalf("expected chatgpt profile appended, got %v", names)
|
||||
}
|
||||
|
||||
// A second login must be a no-op with switch guidance, not a duplicate.
|
||||
line = ensureLoginProviderProfile(deps, "chatgpt")
|
||||
if !strings.Contains(line, "already configured") {
|
||||
t.Fatalf("expected already-configured guidance, got %q", line)
|
||||
}
|
||||
cfg = readCLIConfigFixture(t, configPath)
|
||||
if len(cfg.Providers) != 2 {
|
||||
t.Fatalf("repeat login duplicated the profile: %d providers", len(cfg.Providers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLoginProviderProfileActivatesOnFreshConfig(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
||||
|
||||
line := ensureLoginProviderProfile(deps, "chatgpt")
|
||||
if !strings.Contains(line, "set it active") {
|
||||
t.Fatalf("fresh config should adopt the login as active, got %q", line)
|
||||
}
|
||||
cfg := readCLIConfigFixture(t, configPath)
|
||||
if cfg.ActiveProvider != "chatgpt" {
|
||||
t.Fatalf("active provider = %q, want chatgpt", cfg.ActiveProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLoginProviderProfileSkipsNonCatalogProviders(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
||||
|
||||
if line := ensureLoginProviderProfile(deps, "my-custom-oauth-server"); line != "" {
|
||||
t.Fatalf("custom OAuth server must not scaffold a profile, got %q", line)
|
||||
}
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
t.Fatalf("config must not be created for a non-catalog login")
|
||||
}
|
||||
}
|
||||
|
||||
func readCLIConfigFixture(t *testing.T, path string) config.FileConfig {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
var cfg config.FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("decode config: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -75,6 +75,12 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if command == "use" {
|
||||
return runProvidersUse(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "remove" || command == "rm" {
|
||||
return runProvidersRemove(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "rename" {
|
||||
return runProvidersRename(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "setup" {
|
||||
return runProvidersSetup(args, stdout, stderr, deps)
|
||||
}
|
||||
@@ -246,6 +252,20 @@ func parseCommandCenterArgs(args []string, allowModelFilters bool, allowProvider
|
||||
|
||||
func summarizeConfig(resolved config.ResolvedConfig) configSummary {
|
||||
summary := zerocommands.ConfigSnapshotFromResolved(resolved)
|
||||
// Mark keyless profiles that authenticate via a stored OAuth login (e.g.
|
||||
// ChatGPT) so the list shows "oauth login" instead of "api key: not set" —
|
||||
// the same candidate matching the runtime resolver uses.
|
||||
logins := oauthLoggedInProviders()
|
||||
if len(logins) > 0 {
|
||||
for index := range summary.Providers {
|
||||
for _, profile := range resolved.Providers {
|
||||
if profile.Name == summary.Providers[index].Name {
|
||||
summary.Providers[index].OAuthLogin = providerHasOAuthLogin(profile, logins)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(summary.Providers, func(i int, j int) bool {
|
||||
if summary.Providers[i].Active != summary.Providers[j].Active {
|
||||
return summary.Providers[i].Active
|
||||
@@ -323,7 +343,7 @@ func formatProviderSummaries(command string, providers []providerSummary) string
|
||||
"model: "+displayCLIValue(provider.Model, "none"),
|
||||
"api model: "+displayCLIValue(provider.APIModel, "unknown"),
|
||||
"base url: "+displayCLIValue(provider.BaseURL, "default"),
|
||||
"api key: "+apiKeyState(provider.APIKeySet),
|
||||
"api key: "+providerCredentialState(provider),
|
||||
)
|
||||
if provider.Message != "" {
|
||||
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
|
||||
@@ -340,7 +360,7 @@ func formatProviderLine(provider providerSummary) string {
|
||||
if provider.Active {
|
||||
marker = "*"
|
||||
}
|
||||
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), apiKeyState(provider.APIKeySet))
|
||||
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), providerCredentialState(provider))
|
||||
if provider.Message != "" {
|
||||
line += " (" + provider.Status + ": " + provider.Message + ")"
|
||||
}
|
||||
@@ -413,6 +433,15 @@ func apiKeyState(set bool) string {
|
||||
return "not set"
|
||||
}
|
||||
|
||||
// providerCredentialState renders the credential column: a configured key wins,
|
||||
// then a stored OAuth login, then "not set".
|
||||
func providerCredentialState(provider providerSummary) string {
|
||||
if !provider.APIKeySet && provider.OAuthLogin {
|
||||
return "oauth login"
|
||||
}
|
||||
return apiKeyState(provider.APIKeySet)
|
||||
}
|
||||
|
||||
func displayCLIValue(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
@@ -466,6 +495,8 @@ func writeProvidersHelp(w io.Writer) error {
|
||||
zero providers add <catalog-id> [flags]
|
||||
zero providers check [name] [flags]
|
||||
zero providers use <name> [flags]
|
||||
zero providers remove <name> [flags]
|
||||
zero providers rename <old> <new> [flags]
|
||||
zero providers setup <catalog-id> [flags]
|
||||
zero providers detect [flags]
|
||||
zero providers models [name] [flags]
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/zerocommands"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -835,3 +836,57 @@ func (commandCenterProvider) StreamCompletion(context.Context, zeroruntime.Compl
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestProviderCredentialStateShowsOAuthLogin(t *testing.T) {
|
||||
if got := providerCredentialState(providerSummary{APIKeySet: true}); got != "set" {
|
||||
t.Fatalf("keyed provider = %q, want set", got)
|
||||
}
|
||||
if got := providerCredentialState(providerSummary{OAuthLogin: true}); got != "oauth login" {
|
||||
t.Fatalf("token-login provider = %q, want oauth login", got)
|
||||
}
|
||||
if got := providerCredentialState(providerSummary{}); got != "not set" {
|
||||
t.Fatalf("credential-less provider = %q, want not set", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvidersListMarksOAuthLoginProviders: a keyless profile whose credential
|
||||
// is a stored OAuth login (the shape `zero auth chatgpt` now writes) must render
|
||||
// as "oauth login", not as a broken "api key: not set" entry.
|
||||
func TestProvidersListMarksOAuthLoginProviders(t *testing.T) {
|
||||
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("oauth store: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
|
||||
t.Fatalf("save token: %v", err)
|
||||
}
|
||||
|
||||
resolved := config.ResolvedConfig{
|
||||
ActiveProvider: "opengateway",
|
||||
Providers: []config.ProviderProfile{
|
||||
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", APIKeyStored: true, Model: "some-model"},
|
||||
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
|
||||
},
|
||||
}
|
||||
summary := summarizeConfig(resolved)
|
||||
byName := map[string]providerSummary{}
|
||||
for _, provider := range summary.Providers {
|
||||
byName[provider.Name] = provider
|
||||
}
|
||||
if !byName["opengateway"].APIKeySet {
|
||||
t.Fatalf("stored-key provider must report a credential: %+v", byName["opengateway"])
|
||||
}
|
||||
if byName["opengateway"].OAuthLogin {
|
||||
t.Fatalf("key-authed provider must not claim the OAuth login: %+v", byName["opengateway"])
|
||||
}
|
||||
if !byName["chatgpt"].OAuthLogin || byName["chatgpt"].APIKeySet {
|
||||
t.Fatalf("chatgpt must be marked oauth-login and keyless: %+v", byName["chatgpt"])
|
||||
}
|
||||
|
||||
rendered := formatProviderSummaries("list", summary.Providers)
|
||||
if !strings.Contains(rendered, "oauth login") {
|
||||
t.Fatalf("list should render the oauth login state, got:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/dictation"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
)
|
||||
|
||||
// Default batch cloud models (§8). Overridable via stt.model.
|
||||
const (
|
||||
defaultGroqSTTModel = "whisper-large-v3-turbo"
|
||||
defaultOpenAISTTModel = "whisper-1"
|
||||
groqSTTBaseURL = "https://api.groq.com/openai/v1"
|
||||
)
|
||||
|
||||
// newDictationTranscriberFactory returns the closure the TUI calls to build a
|
||||
// transcriber when a recording starts. It lives here, not in the TUI, because it
|
||||
// resolves provider API keys (credstore + env + configured providers) — logic
|
||||
// that belongs with the rest of the CLI's credential handling.
|
||||
//
|
||||
// serverMgr is the shared, session-long sherpa-onnx streaming server; it is
|
||||
// spawned lazily on the first local-streaming recording and torn down on exit.
|
||||
//
|
||||
// preferStreaming asks for the streaming backend; the returned bool reports
|
||||
// whether streaming was actually built (false → the caller uses the batch
|
||||
// pipeline). A misconfigured streaming backend surfaces its own SetupError.
|
||||
func newDictationTranscriberFactory(resolved config.ResolvedConfig, userConfigPath string, serverMgr *dictation.ServerManager) func(config.STTConfig, bool) (tui.Transcriber, bool, error) {
|
||||
return func(stt config.STTConfig, preferStreaming bool) (tui.Transcriber, bool, error) {
|
||||
// stt is passed in (not captured) so a mid-session change — e.g. the F9
|
||||
// auto-download writing localModelPath — takes effect on the next call.
|
||||
if serverMgr != nil {
|
||||
serverMgr.SetModelPath(stt.LocalModelPath)
|
||||
}
|
||||
if preferStreaming {
|
||||
t, err := buildStreamingTranscriber(stt, resolved, userConfigPath, serverMgr)
|
||||
switch {
|
||||
case err == nil && t != nil:
|
||||
return t, true, nil
|
||||
case err != nil && stt.STTStreamProvider() != config.STTProviderLocal:
|
||||
// An explicitly-chosen cloud streaming backend that failed to build
|
||||
// (e.g. a missing key) should surface its own error, not silently
|
||||
// fall back to batch — the user asked for that backend specifically.
|
||||
return nil, true, err
|
||||
}
|
||||
// The default local streaming backend isn't set up: fall through to the
|
||||
// batch pipeline so a cloud-batch provider (or a batch-only setup) still
|
||||
// works instead of erroring on a streaming server the user never asked for.
|
||||
}
|
||||
t, err := buildBatchTranscriber(stt, resolved, userConfigPath)
|
||||
return t, false, err
|
||||
}
|
||||
}
|
||||
|
||||
// buildStreamingTranscriber constructs the configured streaming backend, or
|
||||
// returns (nil, nil) when streaming is not applicable so the caller falls back
|
||||
// to batch.
|
||||
func buildStreamingTranscriber(stt config.STTConfig, resolved config.ResolvedConfig, userConfigPath string, serverMgr *dictation.ServerManager) (dictation.Transcriber, error) {
|
||||
switch stt.STTStreamProvider() {
|
||||
case config.STTProviderDeepgram:
|
||||
key := resolveSTTKey(resolved, userConfigPath, dictation.StreamProviderDeepgram, "DEEPGRAM_API_KEY")
|
||||
return dictation.NewDeepgramTranscriber(dictation.DeepgramConfig{
|
||||
APIKey: key,
|
||||
Model: stt.StreamModel,
|
||||
Language: stt.Language,
|
||||
})
|
||||
case config.STTProviderOpenAI:
|
||||
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderOpenAI, "OPENAI_API_KEY")
|
||||
return dictation.NewOpenAIRealtimeTranscriber(dictation.OpenAIRealtimeConfig{
|
||||
APIKey: key,
|
||||
Model: stt.StreamModel,
|
||||
})
|
||||
default: // local streaming via the warm sherpa-onnx server
|
||||
// STTStreamProvider() defaults to "local" whenever streamProvider is unset —
|
||||
// including when the user picked a CLOUD batch provider (Groq/OpenAI) and
|
||||
// never touched streaming. Don't silently stream through the local sherpa
|
||||
// server in that case: local streaming is only appropriate when it was
|
||||
// explicitly requested (streamProvider: "local") OR the batch provider is
|
||||
// itself local. Otherwise report "not applicable" so the factory falls back
|
||||
// to the cloud batch pipeline the user actually chose.
|
||||
localWanted := stt.StreamProvider == config.STTProviderLocal || stt.STTProvider() == config.STTProviderLocal
|
||||
if !localWanted {
|
||||
return nil, nil
|
||||
}
|
||||
// The local streaming transcriber validates the model only lazily (at
|
||||
// connect time), so gate on the model path here — with none set, report
|
||||
// "not applicable" (nil, nil) so the factory falls back to the batch
|
||||
// pipeline instead of committing to a server that can't start.
|
||||
if serverMgr == nil || strings.TrimSpace(stt.LocalModelPath) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return dictation.NewLocalStreamingTranscriber(serverMgr), nil
|
||||
}
|
||||
}
|
||||
|
||||
// newDictationServerManager builds the warm sherpa-onnx streaming server manager
|
||||
// from config. Construction is side-effect free — the server spawns lazily on
|
||||
// the first local-streaming recording — so it is always safe to create.
|
||||
func newDictationServerManager(stt config.STTConfig) *dictation.ServerManager {
|
||||
return dictation.NewServerManager(dictation.ServerConfig{
|
||||
Binary: stt.LocalServerBinary,
|
||||
ModelPath: stt.LocalModelPath,
|
||||
Port: stt.LocalServerPort,
|
||||
NumThreads: stt.NumThreads,
|
||||
})
|
||||
}
|
||||
|
||||
// buildBatchTranscriber constructs the configured batch transcriber, resolving
|
||||
// credentials for the cloud providers.
|
||||
func buildBatchTranscriber(stt config.STTConfig, resolved config.ResolvedConfig, userConfigPath string) (dictation.Transcriber, error) {
|
||||
switch stt.STTProvider() {
|
||||
case config.STTProviderGroq:
|
||||
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderGroq, "GROQ_API_KEY")
|
||||
model := stt.Model
|
||||
if model == "" {
|
||||
model = defaultGroqSTTModel
|
||||
}
|
||||
return dictation.NewCloudTranscriber(dictation.CloudConfig{
|
||||
Provider: dictation.ProviderGroq,
|
||||
BaseURL: groqSTTBaseURL,
|
||||
APIKey: key,
|
||||
Model: model,
|
||||
Language: stt.Language,
|
||||
})
|
||||
case config.STTProviderOpenAI:
|
||||
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderOpenAI, "OPENAI_API_KEY")
|
||||
model := stt.Model
|
||||
if model == "" {
|
||||
model = defaultOpenAISTTModel
|
||||
}
|
||||
return dictation.NewCloudTranscriber(dictation.CloudConfig{
|
||||
Provider: dictation.ProviderOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: key,
|
||||
Model: model,
|
||||
Language: stt.Language,
|
||||
})
|
||||
default: // local
|
||||
return dictation.NewLocalTranscriber(dictation.LocalConfig{
|
||||
Binary: stt.LocalBinary,
|
||||
ModelPath: stt.LocalModelPath,
|
||||
NumThreads: stt.NumThreads,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sttEnvVar returns the conventional environment variable a cloud STT provider's
|
||||
// key is read from.
|
||||
func sttEnvVar(provider string) string {
|
||||
switch provider {
|
||||
case dictation.ProviderGroq:
|
||||
return "GROQ_API_KEY"
|
||||
case dictation.ProviderOpenAI: // == StreamProviderOpenAI ("openai")
|
||||
return "OPENAI_API_KEY"
|
||||
case dictation.StreamProviderDeepgram:
|
||||
return "DEEPGRAM_API_KEY"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// newSTTKeyStatus returns a predicate reporting whether a cloud STT provider
|
||||
// already has a resolvable API key (configured provider, credstore, or env).
|
||||
func newSTTKeyStatus(resolved config.ResolvedConfig, userConfigPath string) func(string) bool {
|
||||
return func(provider string) bool {
|
||||
return resolveSTTKey(resolved, userConfigPath, provider, sttEnvVar(provider)) != ""
|
||||
}
|
||||
}
|
||||
|
||||
// newSaveSTTKey returns a function that stores a cloud STT provider's API key in
|
||||
// the encrypted credential store, so the inline prompt can persist it.
|
||||
func newSaveSTTKey(userConfigPath string) func(string, string) error {
|
||||
return func(provider, key string) error {
|
||||
if strings.TrimSpace(userConfigPath) == "" {
|
||||
return fmt.Errorf("no config path to store the key")
|
||||
}
|
||||
store, err := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.Set(provider, strings.TrimSpace(key))
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSTTKey finds an API key for an STT cloud provider, reusing Zero's
|
||||
// existing credentials (§2 — "zero new credential UI"). Precedence: a matching
|
||||
// configured provider's resolved key, then the encrypted credential store, then
|
||||
// the provider's conventional environment variable.
|
||||
func resolveSTTKey(resolved config.ResolvedConfig, userConfigPath, provider, envVar string) string {
|
||||
if key := keyFromConfiguredProviders(resolved, provider); key != "" {
|
||||
return key
|
||||
}
|
||||
if userConfigPath != "" {
|
||||
if store, err := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)); err == nil {
|
||||
if key, ok, _ := store.Get(provider); ok && strings.TrimSpace(key) != "" {
|
||||
return strings.TrimSpace(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv(envVar))
|
||||
}
|
||||
|
||||
// keyFromConfiguredProviders returns the resolved API key of a configured
|
||||
// provider that belongs to the given cloud provider (matched by catalog id,
|
||||
// provider name/kind, or base-URL host), or "" when none is configured.
|
||||
func keyFromConfiguredProviders(resolved config.ResolvedConfig, provider string) string {
|
||||
provider = strings.ToLower(provider)
|
||||
profiles := append([]config.ProviderProfile{resolved.Provider}, resolved.Providers...)
|
||||
for _, p := range profiles {
|
||||
if strings.TrimSpace(p.APIKey) == "" {
|
||||
continue
|
||||
}
|
||||
if providerMatches(p, provider) {
|
||||
return strings.TrimSpace(p.APIKey)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func providerMatches(p config.ProviderProfile, provider string) bool {
|
||||
provider = strings.ToLower(provider)
|
||||
// Match on stable identifiers (catalog id, provider name/kind, profile
|
||||
// name) rather than raw BaseURL substring matching: a Groq profile's
|
||||
// BaseURL (api.groq.com/openai/v1) contains "openai", so a substring
|
||||
// check can hand the wrong key to an OpenAI STT lookup and fail auth.
|
||||
for _, f := range []string{
|
||||
strings.ToLower(p.CatalogID),
|
||||
strings.ToLower(p.Provider),
|
||||
strings.ToLower(string(p.ProviderKind)),
|
||||
strings.ToLower(p.Name),
|
||||
} {
|
||||
if f != "" && strings.Contains(f, provider) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// BaseURL: compare the parsed host only, not a raw substring on the
|
||||
// full URL (which lets "openai" match api.groq.com/openai/v1).
|
||||
if u, err := neturl.Parse(p.BaseURL); err == nil && u.Host != "" {
|
||||
if host := strings.ToLower(u.Host); strings.Contains(host, provider) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/dictation"
|
||||
)
|
||||
|
||||
func TestFactoryFallsBackToBatchWhenLocalStreamingNotConfigured(t *testing.T) {
|
||||
t.Setenv("GROQ_API_KEY", "gsk-test")
|
||||
resolved := config.ResolvedConfig{STT: config.STTConfig{
|
||||
Provider: config.STTProviderGroq, // cloud batch
|
||||
// StreamProvider unset → defaults to local, which has no model configured.
|
||||
}}
|
||||
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(resolved.STT))
|
||||
|
||||
tr, streaming, err := factory(resolved.STT, true) // prefer streaming
|
||||
if err != nil {
|
||||
t.Fatalf("expected batch fallback, got error: %v", err)
|
||||
}
|
||||
if streaming {
|
||||
t.Error("should have fallen back to batch (streaming=false)")
|
||||
}
|
||||
if tr == nil {
|
||||
t.Error("expected a Groq batch transcriber")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryCloudProviderIgnoresLeftoverLocalModel(t *testing.T) {
|
||||
// Regression: pick local once (LocalModelPath gets set), then switch to a
|
||||
// cloud batch provider. streamProvider stays unset (defaults to "local"), so
|
||||
// the streaming factory must NOT fire up the sherpa server on the leftover
|
||||
// path — it should fall back to the cloud batch pipeline the user chose.
|
||||
t.Setenv("GROQ_API_KEY", "gsk-test")
|
||||
stt := config.STTConfig{
|
||||
Provider: config.STTProviderGroq, // cloud batch, chosen now
|
||||
LocalModelPath: "/leftover/model/dir", // set by an earlier local selection
|
||||
}
|
||||
resolved := config.ResolvedConfig{STT: stt}
|
||||
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(stt))
|
||||
|
||||
tr, streaming, err := factory(stt, true) // prefer streaming
|
||||
if err != nil {
|
||||
t.Fatalf("expected cloud batch fallback, got error: %v", err)
|
||||
}
|
||||
if streaming {
|
||||
t.Error("a cloud provider must not stream through the local sherpa server")
|
||||
}
|
||||
if tr == nil {
|
||||
t.Error("expected a Groq batch transcriber")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactorySurfacesExplicitCloudStreamingError(t *testing.T) {
|
||||
// Deepgram chosen explicitly but no key resolvable → surface the setup error.
|
||||
resolved := config.ResolvedConfig{STT: config.STTConfig{
|
||||
StreamProvider: config.STTProviderDeepgram,
|
||||
}}
|
||||
factory := newDictationTranscriberFactory(resolved, "", nil)
|
||||
|
||||
_, streaming, err := factory(resolved.STT, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected a setup error for missing Deepgram key")
|
||||
}
|
||||
if !streaming {
|
||||
t.Error("an explicit cloud streaming choice should report streaming=true even on error")
|
||||
}
|
||||
var setupErr *dictation.SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Errorf("want *SetupError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryLocalBatchSetupErrorWhenNoModel(t *testing.T) {
|
||||
resolved := config.ResolvedConfig{STT: config.STTConfig{Provider: config.STTProviderLocal}}
|
||||
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(resolved.STT))
|
||||
|
||||
_, _, err := factory(resolved.STT, true)
|
||||
var setupErr *dictation.SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError guiding local model setup, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSTTKeyPrefersConfiguredProvider(t *testing.T) {
|
||||
resolved := config.ResolvedConfig{
|
||||
Providers: []config.ProviderProfile{
|
||||
{Name: "groq", CatalogID: "groq", BaseURL: "https://api.groq.com/openai/v1", APIKey: "gsk-configured"},
|
||||
},
|
||||
}
|
||||
if got := resolveSTTKey(resolved, "", dictation.ProviderGroq, "GROQ_API_KEY"); got != "gsk-configured" {
|
||||
t.Errorf("key = %q, want the configured provider's key", got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -306,3 +307,143 @@ func providerCommandArg(value string) string {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type providerNamesOptions struct {
|
||||
names []string
|
||||
json bool
|
||||
}
|
||||
|
||||
func parseProviderNamesArgs(args []string, want int, usage string) (providerNamesOptions, bool, error) {
|
||||
options := providerNamesOptions{}
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||
default:
|
||||
options.names = append(options.names, arg)
|
||||
}
|
||||
}
|
||||
if len(options.names) != want {
|
||||
return options, false, execUsageError{usage}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
// runProvidersRemove deletes a saved provider profile and its stored API key.
|
||||
// The OAuth token (if any) is kept — logins outlive profiles so re-adding the
|
||||
// provider needs no new browser round-trip; `zero auth logout <name>` removes it.
|
||||
func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseProviderNamesArgs(args, 1, "usage: zero providers remove <name>")
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeProvidersHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
configPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
name := options.names[0]
|
||||
cfg, err := config.RemoveProvider(configPath, name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
// Delete the key from the store BESIDE the config being edited — the same
|
||||
// store setup/rename write to — not the default-path store, so a
|
||||
// non-default config path cannot leave the encrypted key behind.
|
||||
keyRemoved, keyErr := removeStoredProviderKeyAt(configPath, name)
|
||||
if options.json {
|
||||
payload := map[string]any{
|
||||
"removed": name,
|
||||
"keyRemoved": keyRemoved,
|
||||
"activeProvider": cfg.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
}
|
||||
if keyErr != nil {
|
||||
// A lingering secret must not read as a clean removal.
|
||||
payload["keyError"] = keyErr.Error()
|
||||
}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Removed provider %s\n", name); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if keyErr != nil {
|
||||
if _, err := fmt.Fprintf(stderr, "warning: its stored API key could not be deleted and remains in the credential store: %v\n", keyErr); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
} else if keyRemoved {
|
||||
if _, err := fmt.Fprintln(stdout, "Deleted its stored API key."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
if active := strings.TrimSpace(cfg.ActiveProvider); active != "" {
|
||||
if _, err := fmt.Fprintf(stdout, "Active provider: %s\n", active); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
} else {
|
||||
if _, err := fmt.Fprintln(stdout, "No providers remain — run zero setup to add one."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// removeStoredProviderKeyAt deletes a provider's API key from the credential
|
||||
// store co-located with configPath (the store SecureProviderProfile captured
|
||||
// it into and RenameProvider migrates within).
|
||||
func removeStoredProviderKeyAt(configPath string, provider string) (bool, error) {
|
||||
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return store.Delete(provider)
|
||||
}
|
||||
|
||||
// runProvidersRename renames a saved provider profile, migrating its stored
|
||||
// API key and the activeProvider pointer along with it (config.RenameProvider).
|
||||
func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseProviderNamesArgs(args, 2, "usage: zero providers rename <old> <new>")
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeProvidersHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
configPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"renamed": map[string]string{"from": options.names[0], "to": options.names[1]},
|
||||
"activeProvider": cfg.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
}); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Renamed provider %s to %s\n", options.names[0], options.names[1]); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
@@ -234,3 +234,48 @@ func writeProviderOnboardingConfig(t *testing.T, path string, cfg config.FileCon
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunProvidersRemoveDeletesKeyBesideConfig: the stored key must be deleted
|
||||
// from the credential store CO-LOCATED with the config being edited (where
|
||||
// SecureProviderProfile captured it), not the default-path store.
|
||||
func TestRunProvidersRemoveDeletesKeyBesideConfig(t *testing.T) {
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
seed := `{"activeProvider":"gw","providers":[{"name":"gw","provider_kind":"openai-compatible","baseURL":"https://gw.example.com/v1","apiKeyStored":true,"model":"m1"},{"name":"other","provider_kind":"openai-compatible","baseURL":"https://o.example.com/v1","model":"m2"}]}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
store, err := config.ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("gw", "sk-secret"); err != nil {
|
||||
t.Fatalf("seed key: %v", err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
||||
if code := runWithDeps([]string{"providers", "remove", "gw", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
|
||||
t.Fatalf("remove failed: code=%d stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Removed string `json:"removed"`
|
||||
KeyRemoved bool `json:"keyRemoved"`
|
||||
KeyError string `json:"keyError"`
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Removed != "gw" || !payload.KeyRemoved || payload.KeyError != "" {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
if payload.ActiveProvider != "other" {
|
||||
t.Fatalf("active must hand off, got %q", payload.ActiveProvider)
|
||||
}
|
||||
if _, ok, _ := store.Get("gw"); ok {
|
||||
t.Fatalf("stored key must be deleted from the store beside the config")
|
||||
}
|
||||
}
|
||||
@@ -457,7 +457,11 @@ func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config
|
||||
}
|
||||
|
||||
func validateProviderRuntimeReady(profile config.ProviderProfile) error {
|
||||
hasCredential := providerProfileHasCredential(profile)
|
||||
// A stored OAuth login (e.g. `zero auth chatgpt`) is a credential too: a
|
||||
// keyless token-login profile must pass the readiness check instead of being
|
||||
// told to set an API key it will never have.
|
||||
hasCredential := providerProfileHasCredential(profile) ||
|
||||
providerHasOAuthLogin(profile, oauthLoggedInProviders())
|
||||
if profile.CatalogID != "" {
|
||||
descriptor, err := providercatalog.Require(profile.CatalogID)
|
||||
if err != nil {
|
||||
|
||||
+14
-7
@@ -322,7 +322,9 @@ func setupRequired(resolved config.ResolvedConfig) bool {
|
||||
// the candidate set is permissive (profile name + catalog ID).
|
||||
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
|
||||
for _, name := range profile.OAuthLoginCandidates() {
|
||||
if oauthLogins[name] {
|
||||
// Store keys are normalized to lower case (oauth.ProviderKey), so the
|
||||
// presence check must compare the same way.
|
||||
if oauthLogins[strings.ToLower(strings.TrimSpace(name))] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -344,18 +346,19 @@ func oauthLoggedInProviders() map[string]bool {
|
||||
}
|
||||
for _, status := range statuses {
|
||||
if status.HasToken {
|
||||
out[strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider)] = true
|
||||
out[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstUsableProvider returns the saved provider best suited to run without
|
||||
// onboarding: the first usable (inline credential present, or no-auth/local)
|
||||
// non-local provider, else the first usable local one. It lets the CLI fall back
|
||||
// to an already-configured login when the active provider happens to lack a
|
||||
// credential, instead of re-running onboarding every launch.
|
||||
// onboarding: the first usable (inline credential, stored OAuth login, or
|
||||
// no-auth/local) non-local provider, else the first usable local one. It lets
|
||||
// the CLI fall back to an already-configured login when the active provider
|
||||
// happens to lack a credential, instead of re-running onboarding every launch.
|
||||
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
|
||||
logins := oauthLoggedInProviders()
|
||||
var localFallback config.ProviderProfile
|
||||
haveLocal := false
|
||||
for _, profile := range providers {
|
||||
@@ -371,7 +374,11 @@ func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderPro
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, missing := setupMissingCredentialEnv(profile); missing {
|
||||
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
|
||||
// when the profile has no inline key / env var — mirrors setupRequired and
|
||||
// usableSavedProviders so this fallback doesn't force onboarding for a
|
||||
// provider the user is already authenticated with.
|
||||
if _, missing := setupMissingCredentialEnv(profile); missing && !providerHasOAuthLogin(profile, logins) {
|
||||
continue
|
||||
}
|
||||
if providerProfileIsLocal(profile) {
|
||||
|
||||
@@ -2,11 +2,14 @@ package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // usable but local
|
||||
{Name: "moonshot", CatalogID: "moonshot", BaseURL: "https://api.moonshot.ai/v1", APIKey: "k"}, // usable, remote
|
||||
@@ -19,6 +22,7 @@ func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable
|
||||
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // local, usable
|
||||
@@ -30,6 +34,7 @@ func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFirstUsableProviderNoneUsable(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
|
||||
{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"},
|
||||
@@ -42,6 +47,10 @@ func TestFirstUsableProviderNoneUsable(t *testing.T) {
|
||||
// A keyless local proxy (chatgpt-proxy, RequiresAuth=false) is usable without a
|
||||
// credential, so it can serve as a fallback rather than forcing onboarding.
|
||||
func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
|
||||
// Isolate the OAuth token store: on a developer machine with a real xai
|
||||
// login, the env-only xai profile would count as usable and win over the
|
||||
// local proxy this test is about.
|
||||
withAuthStore(t)
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
|
||||
{Name: "chatgpt", CatalogID: "chatgpt-proxy", BaseURL: "http://localhost:10531/v1"},
|
||||
@@ -56,6 +65,7 @@ func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
|
||||
// BaseURL has no endpoint, so it must be skipped rather than selected as a
|
||||
// fallback that fails at first use. A stale CatalogID with a BaseURL still works.
|
||||
func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "ghost", CatalogID: "no-such-catalog-entry", APIKey: "k"}, // unusable: no endpoint
|
||||
{Name: "custom", CatalogID: "no-such-catalog-entry", BaseURL: "https://api.custom.test/v1", APIKey: "k"},
|
||||
@@ -66,6 +76,29 @@ func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// An OAuth-only provider (no inline key, no env var) must be selectable as a
|
||||
// fallback, matching setupRequired/usableSavedProviders — otherwise a fully
|
||||
// authenticated user gets forced back into onboarding when activeProvider
|
||||
// goes stale.
|
||||
func TestFirstUsableProviderRecognizesOAuthLogin(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("seed token: %v", err)
|
||||
}
|
||||
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // no inline key/env, but logged in via OAuth
|
||||
}
|
||||
got, ok := firstUsableProvider(providers)
|
||||
if !ok || got.Name != "xai" {
|
||||
t.Fatalf("want OAuth-logged-in provider (xai), got %q ok=%v", got.Name, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderProfileIsLocal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -89,3 +122,32 @@ func TestProviderProfileIsLocal(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirstUsableProviderAcceptsOAuthLoginProfile: a keyless catalog profile
|
||||
// whose credential is a stored OAuth login (the shape the login flows persist)
|
||||
// must be usable during active-pointer recovery — the same rule setupRequired
|
||||
// and usableSavedProviders apply — instead of falling through to onboarding.
|
||||
func TestFirstUsableProviderAcceptsOAuthLoginProfile(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "chatgpt", CatalogID: "chatgpt", BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
|
||||
}
|
||||
|
||||
// No login stored: still not usable.
|
||||
if got, ok := firstUsableProvider(providers); ok {
|
||||
t.Fatalf("keyless profile without a login must not be usable, got %q", got.Name)
|
||||
}
|
||||
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("oauth store: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
|
||||
t.Fatalf("save token: %v", err)
|
||||
}
|
||||
|
||||
got, ok := firstUsableProvider(providers)
|
||||
if !ok || got.Name != "chatgpt" {
|
||||
t.Fatalf("want OAuth-login profile to be usable, got %q ok=%v", got.Name, ok)
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,9 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focusMode)
|
||||
}
|
||||
}
|
||||
if err := validateSTTConfig(cfg.STT); err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
}
|
||||
|
||||
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
|
||||
if err != nil {
|
||||
@@ -150,6 +153,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
LocalControl: cfg.LocalControl,
|
||||
STT: cfg.STT,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -231,6 +235,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
}
|
||||
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
|
||||
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||
mergeSTTConfig(&dst.STT, src.STT)
|
||||
}
|
||||
|
||||
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
@@ -681,6 +686,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
}
|
||||
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
|
||||
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
|
||||
mergeSTTConfig(&cfg.STT, overrides.STT)
|
||||
for _, provider := range overrides.Providers {
|
||||
mergeProvider(cfg, provider)
|
||||
}
|
||||
@@ -734,6 +740,91 @@ func mergeKeyBindings(dst *KeyBindingsConfig, src KeyBindingsConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSTTConfig overlays any set field of src onto dst. Each field carries its
|
||||
// own "unset" sentinel (empty string, 0, or nil *bool), matching how the other
|
||||
// section mergers detect intent.
|
||||
func mergeSTTConfig(dst *STTConfig, src STTConfig) {
|
||||
if src.Provider != "" {
|
||||
dst.Provider = src.Provider
|
||||
}
|
||||
if src.StreamProvider != "" {
|
||||
dst.StreamProvider = src.StreamProvider
|
||||
}
|
||||
if src.Streaming != nil {
|
||||
dst.Streaming = src.Streaming
|
||||
}
|
||||
if src.Model != "" {
|
||||
dst.Model = src.Model
|
||||
}
|
||||
if src.StreamModel != "" {
|
||||
dst.StreamModel = src.StreamModel
|
||||
}
|
||||
if src.LocalModelPath != "" {
|
||||
dst.LocalModelPath = src.LocalModelPath
|
||||
}
|
||||
if src.LocalBinary != "" {
|
||||
dst.LocalBinary = src.LocalBinary
|
||||
}
|
||||
if src.LocalServerBinary != "" {
|
||||
dst.LocalServerBinary = src.LocalServerBinary
|
||||
}
|
||||
if src.LocalServerPort != 0 {
|
||||
dst.LocalServerPort = src.LocalServerPort
|
||||
}
|
||||
if src.EngineVersion != "" {
|
||||
dst.EngineVersion = src.EngineVersion
|
||||
}
|
||||
if src.NumThreads != 0 {
|
||||
dst.NumThreads = src.NumThreads
|
||||
}
|
||||
if src.Language != "" {
|
||||
dst.Language = src.Language
|
||||
}
|
||||
if src.MaxDurationSeconds != 0 {
|
||||
dst.MaxDurationSeconds = src.MaxDurationSeconds
|
||||
}
|
||||
if src.SilenceAutoStop != nil {
|
||||
dst.SilenceAutoStop = src.SilenceAutoStop
|
||||
}
|
||||
if src.AutoSubmit != nil {
|
||||
dst.AutoSubmit = src.AutoSubmit
|
||||
}
|
||||
if src.WindowsAudioDevice != "" {
|
||||
dst.WindowsAudioDevice = src.WindowsAudioDevice
|
||||
}
|
||||
}
|
||||
|
||||
// validateSTTConfig rejects unknown provider/streamProvider values and a
|
||||
// negative maxDurationSeconds at load time — a clear startup error naming the
|
||||
// bad value and the valid options, never a silent fallback the user did not ask
|
||||
// for (§11a).
|
||||
func validateSTTConfig(cfg STTConfig) error {
|
||||
if cfg.Provider != "" {
|
||||
switch cfg.Provider {
|
||||
case STTProviderLocal, STTProviderGroq, STTProviderOpenAI:
|
||||
default:
|
||||
return fmt.Errorf("invalid stt.provider %q: expected local, groq, or openai", cfg.Provider)
|
||||
}
|
||||
}
|
||||
if cfg.StreamProvider != "" {
|
||||
switch cfg.StreamProvider {
|
||||
case STTProviderLocal, STTProviderDeepgram, STTProviderOpenAI:
|
||||
default:
|
||||
return fmt.Errorf("invalid stt.streamProvider %q: expected local, deepgram, or openai", cfg.StreamProvider)
|
||||
}
|
||||
}
|
||||
if cfg.MaxDurationSeconds < 0 {
|
||||
return fmt.Errorf("invalid stt.maxDurationSeconds %d: must be >= 0 (0 uses the default)", cfg.MaxDurationSeconds)
|
||||
}
|
||||
if cfg.NumThreads < 0 {
|
||||
return fmt.Errorf("invalid stt.numThreads %d: must be >= 0 (0 uses the engine default)", cfg.NumThreads)
|
||||
}
|
||||
if cfg.LocalServerPort < 0 || cfg.LocalServerPort > 65535 {
|
||||
return fmt.Errorf("invalid stt.localServerPort %d: must be between 1 and 65535 (0 uses the default)", cfg.LocalServerPort)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
if len(src.Servers) == 0 {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetSTTLocalEngineResetsStreamProvider(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/config.json"
|
||||
// Pre-seed a cloud streaming provider — the situation the fix guards against.
|
||||
if err := os.WriteFile(path, []byte(`{"stt":{"streamProvider":"deepgram"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := SetSTTLocalEngine(path, "sherpa-onnx-offline", "sherpa-onnx-online-websocket-server", "/models/kroko", true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetSTTLocalEngine: %v", err)
|
||||
}
|
||||
// Switching to a local model must point BOTH pipelines at the local engine, or
|
||||
// the leftover cloud streamProvider keeps the live transcript on the cloud.
|
||||
if cfg.STT.Provider != STTProviderLocal || cfg.STT.StreamProvider != STTProviderLocal {
|
||||
t.Errorf("both providers should be local, got provider=%q streamProvider=%q", cfg.STT.Provider, cfg.STT.StreamProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSTTConfigRejectsUnknownProvider(t *testing.T) {
|
||||
_, issues := ValidateBytes([]byte(`{"stt":{"provider":"assemblyai"}}`))
|
||||
if len(issues) == 0 {
|
||||
t.Fatal("expected an issue for unknown stt.provider")
|
||||
}
|
||||
if !strings.Contains(issues[0].Message, "assemblyai") || !strings.Contains(issues[0].Message, "local, groq, or openai") {
|
||||
t.Errorf("issue message = %q, want it to name the bad value and valid options", issues[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSTTConfigRejectsUnknownStreamProvider(t *testing.T) {
|
||||
_, issues := ValidateBytes([]byte(`{"stt":{"streamProvider":"groq"}}`))
|
||||
if len(issues) == 0 {
|
||||
t.Fatal("expected an issue: groq has no streaming product, not a valid streamProvider")
|
||||
}
|
||||
if !strings.Contains(issues[0].Message, "deepgram") {
|
||||
t.Errorf("issue message = %q", issues[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSTTConfigAcceptsValid(t *testing.T) {
|
||||
_, issues := ValidateBytes([]byte(`{"stt":{"provider":"groq","streamProvider":"deepgram","maxDurationSeconds":120}}`))
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("unexpected issues: %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSTTConfigRejectsNegativeDuration(t *testing.T) {
|
||||
_, issues := ValidateBytes([]byte(`{"stt":{"maxDurationSeconds":-5}}`))
|
||||
if len(issues) == 0 {
|
||||
t.Fatal("expected an issue for negative maxDurationSeconds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTTConfigDefaults(t *testing.T) {
|
||||
cfg := STTConfig{}
|
||||
if cfg.STTProvider() != STTProviderLocal {
|
||||
t.Errorf("default provider = %q, want local", cfg.STTProvider())
|
||||
}
|
||||
if cfg.STTStreamProvider() != STTProviderLocal {
|
||||
t.Errorf("default streamProvider = %q, want local", cfg.STTStreamProvider())
|
||||
}
|
||||
if !cfg.StreamingEnabled() {
|
||||
t.Error("streaming should default on")
|
||||
}
|
||||
if !cfg.SilenceAutoStopEnabled() {
|
||||
t.Error("silence auto-stop should default on")
|
||||
}
|
||||
if cfg.AutoSubmitEnabled() {
|
||||
t.Error("auto-submit should default off (insert-for-review is the safety net)")
|
||||
}
|
||||
if !cfg.Empty() {
|
||||
t.Error("zero-value STTConfig should be Empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTTConfigTriStateBools(t *testing.T) {
|
||||
cfg, issues := ValidateBytes([]byte(`{"stt":{"autoSubmit":true,"streaming":false,"silenceAutoStop":false}}`))
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("unexpected issues: %+v", issues)
|
||||
}
|
||||
if !cfg.STT.AutoSubmitEnabled() {
|
||||
t.Error("autoSubmit:true should be enabled")
|
||||
}
|
||||
if cfg.STT.StreamingEnabled() {
|
||||
t.Error("streaming:false should be disabled")
|
||||
}
|
||||
if cfg.STT.SilenceAutoStopEnabled() {
|
||||
t.Error("silenceAutoStop:false should be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTTConfigRoundTripsThroughMarshal(t *testing.T) {
|
||||
cfg, _ := ValidateBytes([]byte(`{"stt":{"provider":"groq","model":"whisper-large-v3-turbo","localServerPort":6007}}`))
|
||||
data, err := cfg.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"stt"`) {
|
||||
t.Errorf("marshaled config dropped stt section: %s", data)
|
||||
}
|
||||
reparsed, issues := ValidateBytes(data)
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("re-parse issues: %+v", issues)
|
||||
}
|
||||
if reparsed.STT.Provider != STTProviderGroq || reparsed.STT.Model != "whisper-large-v3-turbo" || reparsed.STT.LocalServerPort != 6007 {
|
||||
t.Errorf("round-trip lost fields: %+v", reparsed.STT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSTTModelPersists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/config.json"
|
||||
// Cloud provider stores under stt.model.
|
||||
if _, err := SetSTTModel(path, STTProviderGroq, "whisper-large-v3-turbo"); err != nil {
|
||||
t.Fatalf("SetSTTModel: %v", err)
|
||||
}
|
||||
cfg, issues := ValidateFile(path)
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("issues: %+v", issues)
|
||||
}
|
||||
if cfg.STT.Provider != STTProviderGroq || cfg.STT.Model != "whisper-large-v3-turbo" {
|
||||
t.Errorf("persisted STT = %+v", cfg.STT)
|
||||
}
|
||||
// Local provider stores under stt.localModelPath.
|
||||
if _, err := SetSTTModel(path, STTProviderLocal, "/models/moonshine"); err != nil {
|
||||
t.Fatalf("SetSTTModel local: %v", err)
|
||||
}
|
||||
cfg, _ = ValidateFile(path)
|
||||
if cfg.STT.LocalModelPath != "/models/moonshine" {
|
||||
t.Errorf("local model path = %q", cfg.STT.LocalModelPath)
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,115 @@ type KeyBindingsConfig struct {
|
||||
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
|
||||
}
|
||||
|
||||
// STTProviderKind is the batch (or streaming) transcription backend selector.
|
||||
// Validated at config load time against the known set, so a typo fails loudly
|
||||
// with the valid options rather than silently falling back to a default.
|
||||
type STTProviderKind string
|
||||
|
||||
const (
|
||||
STTProviderLocal STTProviderKind = "local"
|
||||
STTProviderGroq STTProviderKind = "groq"
|
||||
STTProviderOpenAI STTProviderKind = "openai"
|
||||
STTProviderDeepgram STTProviderKind = "deepgram"
|
||||
)
|
||||
|
||||
// STTConfig configures speech-to-text dictation. All fields are optional; empty
|
||||
// values take the documented defaults. Booleans that need a real tri-state
|
||||
// (distinguishing "unset" from "false") use *bool, matching PreferencesConfig.Recaps.
|
||||
type STTConfig struct {
|
||||
// Provider is the batch transcription backend: "local" (sherpa-onnx-offline,
|
||||
// the default), "groq", or "openai". Termux and the fallback everywhere use
|
||||
// this path.
|
||||
Provider STTProviderKind `json:"provider,omitempty"`
|
||||
// StreamProvider is the streaming backend on desktop: "local" (sherpa-onnx
|
||||
// websocket server, default), "deepgram", or "openai" (Realtime).
|
||||
StreamProvider STTProviderKind `json:"streamProvider,omitempty"`
|
||||
// Streaming enables the live-transcript pipeline on platforms that support
|
||||
// it (desktop). Defaults to on; set false to always use the batch pipeline.
|
||||
Streaming *bool `json:"streaming,omitempty"`
|
||||
// Model overrides the cloud batch model (e.g. "whisper-large-v3-turbo" for
|
||||
// Groq, "whisper-1" for OpenAI). Empty uses the provider default.
|
||||
Model string `json:"model,omitempty"`
|
||||
// StreamModel overrides the cloud streaming model (Deepgram/OpenAI Realtime).
|
||||
StreamModel string `json:"streamModel,omitempty"`
|
||||
// LocalModelPath is the sherpa-onnx model directory for local transcription
|
||||
// (batch and streaming). Required to use a local provider.
|
||||
LocalModelPath string `json:"localModelPath,omitempty"`
|
||||
// LocalBinary overrides the offline binary name/path (default
|
||||
// "sherpa-onnx-offline"); LocalServerBinary overrides the streaming server
|
||||
// (default "sherpa-onnx-online-websocket-server"). Both looked up on PATH.
|
||||
LocalBinary string `json:"localBinary,omitempty"`
|
||||
LocalServerBinary string `json:"localServerBinary,omitempty"`
|
||||
// LocalServerPort is the localhost port for the sherpa-onnx websocket server
|
||||
// (default 6006).
|
||||
LocalServerPort int `json:"localServerPort,omitempty"`
|
||||
// EngineVersion selects the sherpa-onnx release the auto-download fetches
|
||||
// ("" → a pinned known-good default; "latest" or any release tag also works,
|
||||
// so a newer engine needs no Zero update). Verified against the release's
|
||||
// published SHA256 digest either way.
|
||||
EngineVersion string `json:"engineVersion,omitempty"`
|
||||
// NumThreads sets the local engine's thread count (0 = engine default).
|
||||
NumThreads int `json:"numThreads,omitempty"`
|
||||
// Language optionally constrains recognition (ISO-639-1, e.g. "en"). Empty =
|
||||
// auto-detect.
|
||||
Language string `json:"language,omitempty"`
|
||||
// MaxDurationSeconds is the hard runaway-recording cap on every platform
|
||||
// (0 = default 300s).
|
||||
MaxDurationSeconds int `json:"maxDurationSeconds,omitempty"`
|
||||
// SilenceAutoStop ends a recording ~2s after the signal goes quiet, as a
|
||||
// backstop for a forgotten manual stop (not VAD-triggered start). Defaults on.
|
||||
SilenceAutoStop *bool `json:"silenceAutoStop,omitempty"`
|
||||
// AutoSubmit fires the transcript at the agent instead of inserting it for
|
||||
// review. Defaults OFF — insert-for-review is the safety net for a misheard
|
||||
// prompt (§3).
|
||||
AutoSubmit *bool `json:"autoSubmit,omitempty"`
|
||||
// WindowsAudioDevice names the dshow capture device (auto-detected when empty).
|
||||
WindowsAudioDevice string `json:"windowsAudioDevice,omitempty"`
|
||||
}
|
||||
|
||||
// STTProvider returns the configured batch provider, defaulting to local.
|
||||
func (c STTConfig) STTProvider() STTProviderKind {
|
||||
if c.Provider == "" {
|
||||
return STTProviderLocal
|
||||
}
|
||||
return c.Provider
|
||||
}
|
||||
|
||||
// STTStreamProvider returns the configured streaming provider, defaulting to local.
|
||||
func (c STTConfig) STTStreamProvider() STTProviderKind {
|
||||
if c.StreamProvider == "" {
|
||||
return STTProviderLocal
|
||||
}
|
||||
return c.StreamProvider
|
||||
}
|
||||
|
||||
// StreamingEnabled reports whether the live pipeline is on. Unset defaults ON.
|
||||
func (c STTConfig) StreamingEnabled() bool {
|
||||
return c.Streaming == nil || *c.Streaming
|
||||
}
|
||||
|
||||
// SilenceAutoStopEnabled reports whether trailing-silence auto-stop is on.
|
||||
// Unset defaults ON.
|
||||
func (c STTConfig) SilenceAutoStopEnabled() bool {
|
||||
return c.SilenceAutoStop == nil || *c.SilenceAutoStop
|
||||
}
|
||||
|
||||
// AutoSubmitEnabled reports whether transcripts are auto-fired. Unset defaults OFF.
|
||||
func (c STTConfig) AutoSubmitEnabled() bool {
|
||||
return c.AutoSubmit != nil && *c.AutoSubmit
|
||||
}
|
||||
|
||||
// Empty reports whether the STT config carries no user-set values (so it can be
|
||||
// omitted from a marshaled config).
|
||||
func (c STTConfig) Empty() bool {
|
||||
return c.Provider == "" && c.StreamProvider == "" && c.Streaming == nil &&
|
||||
c.Model == "" && c.StreamModel == "" && c.LocalModelPath == "" &&
|
||||
c.LocalBinary == "" && c.LocalServerBinary == "" && c.LocalServerPort == 0 &&
|
||||
c.EngineVersion == "" && c.NumThreads == 0 && c.Language == "" &&
|
||||
c.MaxDurationSeconds == 0 && c.SilenceAutoStop == nil && c.AutoSubmit == nil &&
|
||||
c.WindowsAudioDevice == ""
|
||||
}
|
||||
|
||||
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
|
||||
// Helpers are discovered lazily by the tool that needs them; no setup command or
|
||||
// background probe is required during startup.
|
||||
@@ -227,6 +336,7 @@ type FileConfig struct {
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT STTConfig `json:"stt,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
@@ -242,6 +352,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT *STTConfig `json:"stt,omitempty"`
|
||||
}
|
||||
raw := rawConfig{
|
||||
ActiveProvider: cfg.ActiveProvider,
|
||||
@@ -258,6 +369,9 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
if !cfg.LocalControl.Empty() {
|
||||
raw.LocalControl = &cfg.LocalControl
|
||||
}
|
||||
if !cfg.STT.Empty() {
|
||||
raw.STT = &cfg.STT
|
||||
}
|
||||
return json.Marshal(raw)
|
||||
}
|
||||
|
||||
@@ -280,6 +394,7 @@ type Overrides struct {
|
||||
Tools ToolsConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
STT STTConfig
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
@@ -295,6 +410,7 @@ type ResolvedConfig struct {
|
||||
Preferences PreferencesConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
STT STTConfig
|
||||
}
|
||||
|
||||
type MCPConfig struct {
|
||||
@@ -342,6 +458,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
Preferences PreferencesConfig `json:"preferences"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings"`
|
||||
LocalControl LocalControlConfig `json:"localControl"`
|
||||
STT STTConfig `json:"stt"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
}
|
||||
@@ -369,6 +486,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.Preferences = raw.Preferences
|
||||
cfg.KeyBindings = raw.KeyBindings
|
||||
cfg.LocalControl = raw.LocalControl
|
||||
cfg.STT = raw.STT
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
|
||||
@@ -54,5 +54,8 @@ func validateSemantics(cfg FileConfig) []Issue {
|
||||
// normalizeProviders already redacts secrets via providerError.
|
||||
return []Issue{{FieldPath: "providers", Message: err.Error()}}
|
||||
}
|
||||
if err := validateSTTConfig(cfg.STT); err != nil {
|
||||
return []Issue{{FieldPath: "stt", Message: err.Error()}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileConfig, error) {
|
||||
@@ -29,6 +31,20 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC
|
||||
}
|
||||
|
||||
mergeProvider(&cfg, profile)
|
||||
// mergeProfile deliberately ignores APIKeyStored — during resolve-time
|
||||
// layering a project config must not be able to claim the user's stored
|
||||
// keys. This user-config WRITE path re-applies the marker: capturing a key
|
||||
// via SecureProviderProfile onto a previously env/no-key profile must
|
||||
// persist apiKeyStored, or the secret sits in the credential store while
|
||||
// every ApplyStoredAPIKey gate skips it (PR #560 review).
|
||||
if profile.APIKeyStored {
|
||||
for index := range cfg.Providers {
|
||||
if cfg.Providers[index].Name == profile.Name {
|
||||
cfg.Providers[index].APIKeyStored = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if setActive || strings.TrimSpace(cfg.ActiveProvider) == "" {
|
||||
cfg.ActiveProvider = profile.Name
|
||||
}
|
||||
@@ -39,6 +55,62 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// EnsuredProvider reports the outcome of EnsureCatalogProvider: the profile name
|
||||
// that serves the catalog entry, whether it was newly created, and which provider
|
||||
// is active after the call (unchanged unless it was blank).
|
||||
type EnsuredProvider struct {
|
||||
Name string
|
||||
Created bool
|
||||
Active string
|
||||
}
|
||||
|
||||
// EnsureCatalogProvider guarantees a provider profile exists in the config at
|
||||
// path for the given catalog entry. OAuth login flows call this right after
|
||||
// storing a token: a login is only reachable from the provider list and
|
||||
// `zero providers use` when a profile exists, but a login must never replace or
|
||||
// deactivate the user's current active provider — so an existing profile whose
|
||||
// Name or CatalogID already matches is left completely untouched (its name,
|
||||
// credentials, and model are the user's), and a created profile is NOT marked
|
||||
// active unless no provider was active at all.
|
||||
func EnsureCatalogProvider(path string, catalogID string) (EnsuredProvider, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return EnsuredProvider{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
descriptor, err := providercatalog.Require(catalogID)
|
||||
if err != nil {
|
||||
return EnsuredProvider{}, err
|
||||
}
|
||||
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return EnsuredProvider{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return EnsuredProvider{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
for _, provider := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(provider.CatalogID), descriptor.ID) ||
|
||||
strings.EqualFold(strings.TrimSpace(provider.Name), descriptor.ID) {
|
||||
return EnsuredProvider{Name: provider.Name, Active: cfg.ActiveProvider}, nil
|
||||
}
|
||||
}
|
||||
|
||||
profile := ProviderProfile{
|
||||
Name: descriptor.ID,
|
||||
ProviderKind: providerKindForCatalogTransport(descriptor.Transport),
|
||||
CatalogID: descriptor.ID,
|
||||
BaseURL: descriptor.DefaultBaseURL,
|
||||
Model: descriptor.DefaultModel,
|
||||
}
|
||||
written, err := UpsertProvider(path, profile, false)
|
||||
if err != nil {
|
||||
return EnsuredProvider{}, err
|
||||
}
|
||||
return EnsuredProvider{Name: profile.Name, Created: true, Active: written.ActiveProvider}, nil
|
||||
}
|
||||
|
||||
func SetActiveProvider(path string, name string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
@@ -72,6 +144,296 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
// RemoveProvider deletes the named provider profile from the config at path.
|
||||
// When the removed profile was active, activeProvider hands off to the first
|
||||
// remaining provider (or clears when none remain) so the config never points at
|
||||
// a profile that no longer exists. The caller owns cleaning up the credential
|
||||
// store entry — config stays pure of secret I/O on the read path, and remove
|
||||
// keeps that symmetry by only touching config.json.
|
||||
func RemoveProvider(path string, name string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
|
||||
index := -1
|
||||
for i, provider := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
removed := cfg.Providers[index]
|
||||
cfg.Providers = append(cfg.Providers[:index], cfg.Providers[index+1:]...)
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(removed.Name)) {
|
||||
cfg.ActiveProvider = ""
|
||||
if len(cfg.Providers) > 0 {
|
||||
cfg.ActiveProvider = cfg.Providers[0].Name
|
||||
}
|
||||
}
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RenameProvider renames a provider profile, keeping everything keyed by the
|
||||
// profile name consistent: the activeProvider pointer follows the rename, and a
|
||||
// key in the encrypted credential store (APIKeyStored) is migrated to the new
|
||||
// name BEFORE the config is rewritten — the store write must succeed first so a
|
||||
// failed migration never strands the config pointing at a key that no longer
|
||||
// resolves. OAuth tokens are deliberately not migrated: the runtime's login
|
||||
// candidates fall back to the profile's CatalogID, which every OAuth-capable
|
||||
// catalog profile carries, so a rename keeps the login reachable.
|
||||
func RenameProvider(path string, oldName string, newName string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
oldName = strings.TrimSpace(oldName)
|
||||
newName = strings.TrimSpace(newName)
|
||||
if oldName == "" || newName == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider names are required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
|
||||
index := -1
|
||||
for i, provider := range cfg.Providers {
|
||||
providerName := strings.TrimSpace(provider.Name)
|
||||
if strings.EqualFold(providerName, oldName) {
|
||||
index = i
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(providerName, newName) {
|
||||
return FileConfig{}, fmt.Errorf("provider %q already exists", newName)
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", oldName)
|
||||
}
|
||||
if strings.EqualFold(oldName, newName) && cfg.Providers[index].Name == newName {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
previousName := cfg.Providers[index].Name
|
||||
keyMigrated := false
|
||||
if cfg.Providers[index].APIKeyStored {
|
||||
if err := migrateStoredProviderKey(path, previousName, newName); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("migrate stored key for %q: %w", oldName, err)
|
||||
}
|
||||
keyMigrated = true
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(previousName)) {
|
||||
cfg.ActiveProvider = newName
|
||||
}
|
||||
cfg.Providers[index].Name = newName
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
if keyMigrated {
|
||||
// Compensate best-effort: config.json still names the OLD profile, so
|
||||
// move the key back where that config can find it — otherwise a failed
|
||||
// rewrite strands the key under a name no profile carries.
|
||||
_ = migrateStoredProviderKey(path, newName, previousName)
|
||||
}
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ProviderEdit is a field-level edit of one saved provider, applied by
|
||||
// EditProvider in a single atomic write. Name is the CURRENT profile name
|
||||
// (matched case-insensitively); NewName renames (case-only renames included).
|
||||
// Empty BaseURL/Model/APIKey mean "leave unchanged"; Description is applied
|
||||
// VERBATIM (the editor always knows the full desired text, so clearing works).
|
||||
type ProviderEdit struct {
|
||||
Name string
|
||||
NewName string
|
||||
BaseURL string
|
||||
Model string
|
||||
APIKey string
|
||||
APIKeyStored bool
|
||||
Description string
|
||||
}
|
||||
|
||||
// EditProvider applies a provider edit in ONE read-modify-write: rename
|
||||
// (activeProvider follows; a stored key migrates, with a best-effort rollback
|
||||
// if the config write fails), field updates, the stored-key marker, and the
|
||||
// verbatim description. A single write keeps the operation atomic — the
|
||||
// previous rename+upsert+describe sequence could fail halfway and leave
|
||||
// config.json renamed while every in-memory consumer still held the old name —
|
||||
// and, unlike UpsertProvider's exact-name merge, the case-insensitive match
|
||||
// here makes a case-only rename (groq -> Groq) an in-place update instead of
|
||||
// an appended duplicate profile.
|
||||
func EditProvider(path string, edit ProviderEdit) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
oldName := strings.TrimSpace(edit.Name)
|
||||
if oldName == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
newName := strings.TrimSpace(edit.NewName)
|
||||
if newName == "" {
|
||||
newName = oldName
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
|
||||
index := -1
|
||||
for i, provider := range cfg.Providers {
|
||||
providerName := strings.TrimSpace(provider.Name)
|
||||
if strings.EqualFold(providerName, oldName) {
|
||||
index = i
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(providerName, newName) {
|
||||
return FileConfig{}, fmt.Errorf("provider %q already exists", newName)
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", oldName)
|
||||
}
|
||||
|
||||
previousName := cfg.Providers[index].Name
|
||||
renamed := previousName != newName
|
||||
keyMigrated := false
|
||||
// A rename moves the stored key along: either the profile's existing entry,
|
||||
// or a replacement key the caller just captured — the contract is that a
|
||||
// captured key is stored under the CURRENT name before EditProvider runs, so
|
||||
// one migration covers both. migrateStoredProviderKey no-ops on case-only
|
||||
// renames (the store normalizes names), so it cannot delete the key it just
|
||||
// moved.
|
||||
if renamed && (cfg.Providers[index].APIKeyStored || edit.APIKeyStored) {
|
||||
if err := migrateStoredProviderKey(path, previousName, newName); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("migrate stored key for %q: %w", oldName, err)
|
||||
}
|
||||
keyMigrated = true
|
||||
}
|
||||
if renamed && strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(previousName)) {
|
||||
cfg.ActiveProvider = newName
|
||||
}
|
||||
|
||||
profile := &cfg.Providers[index]
|
||||
profile.Name = newName
|
||||
if baseURL := strings.TrimSpace(edit.BaseURL); baseURL != "" {
|
||||
profile.BaseURL = baseURL
|
||||
}
|
||||
if model := strings.TrimSpace(edit.Model); model != "" {
|
||||
profile.Model = model
|
||||
}
|
||||
if apiKey := strings.TrimSpace(edit.APIKey); apiKey != "" {
|
||||
profile.APIKey = apiKey
|
||||
}
|
||||
if edit.APIKeyStored {
|
||||
profile.APIKeyStored = true
|
||||
}
|
||||
profile.Description = strings.TrimSpace(edit.Description)
|
||||
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
if keyMigrated {
|
||||
// Compensate best-effort: config.json still names the OLD profile, so
|
||||
// move the key back where that config can find it.
|
||||
_ = migrateStoredProviderKey(path, newName, previousName)
|
||||
}
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetProviderDescription sets a provider's description VERBATIM — including to
|
||||
// empty. The generic UpsertProvider merge treats empty fields as "leave
|
||||
// unchanged", so clearing a description needs this dedicated setter.
|
||||
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
for index := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), name) {
|
||||
cfg.Providers[index].Description = strings.TrimSpace(description)
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
// migrateStoredProviderKey moves a credential-store entry to a new provider
|
||||
// name: write-new-then-delete-old, so an interruption can leave a duplicate but
|
||||
// never a missing key. A missing source entry is a no-op (the marker may be
|
||||
// stale); only a failed WRITE aborts the rename.
|
||||
func migrateStoredProviderKey(configPath string, oldName string, newName string) error {
|
||||
// The store normalizes names case-insensitively, so a case-only rename
|
||||
// (groq -> Groq) targets ONE entry: Set(new) rewrites it in place and
|
||||
// Delete(old) would then remove the key that was just "moved". Nothing to
|
||||
// migrate — the existing entry already serves the new name.
|
||||
if strings.EqualFold(strings.TrimSpace(oldName), strings.TrimSpace(newName)) {
|
||||
return nil
|
||||
}
|
||||
store, err := ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, ok, err := store.Get(oldName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || strings.TrimSpace(key) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := store.Set(newName, key); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = store.Delete(oldName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetProviderModel(path string, name string, model string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
@@ -176,6 +538,106 @@ func SetTheme(path string, theme string) (FileConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetSTTModel persists the dictation model and its provider, mirroring
|
||||
// SetTheme (read-modify-atomic-write). provider must be one of the known STT
|
||||
// provider kinds; a local provider stores the model as stt.localModelPath,
|
||||
// otherwise as stt.model. A blank model clears the stored value for that slot.
|
||||
func SetSTTModel(path string, provider STTProviderKind, model string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
if provider != "" {
|
||||
cfg.STT.Provider = provider
|
||||
}
|
||||
model = strings.TrimSpace(model)
|
||||
if cfg.STT.STTProvider() == STTProviderLocal {
|
||||
cfg.STT.LocalModelPath = model
|
||||
} else {
|
||||
cfg.STT.Model = model
|
||||
}
|
||||
if err := validateSTTConfig(cfg.STT); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetSTTLocalEngine persists the paths of an auto-downloaded local engine +
|
||||
// model and switches dictation to the local provider, mirroring SetTheme
|
||||
// (read-modify-atomic-write). streaming selects the pipeline matching the
|
||||
// downloaded model (a streaming transducer vs a batch model). Called after a
|
||||
// download completes.
|
||||
func SetSTTLocalEngine(path, binary, serverBinary, modelPath string, streaming bool) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg.STT.Provider = STTProviderLocal
|
||||
// Point BOTH pipelines at the local engine. Without resetting StreamProvider, a
|
||||
// previously-chosen cloud value (deepgram/openai) would still win in
|
||||
// buildStreamingTranscriber, so the live transcript would keep hitting the cloud
|
||||
// after the user switched to a downloaded local model.
|
||||
cfg.STT.StreamProvider = STTProviderLocal
|
||||
cfg.STT.LocalBinary = strings.TrimSpace(binary)
|
||||
cfg.STT.LocalServerBinary = strings.TrimSpace(serverBinary)
|
||||
cfg.STT.LocalModelPath = strings.TrimSpace(modelPath)
|
||||
// Match the pipeline to the downloaded model: a streaming transducer drives
|
||||
// the websocket server for a live transcript; a batch model uses the offline
|
||||
// binary.
|
||||
s := streaming
|
||||
cfg.STT.Streaming = &s
|
||||
if err := validateSTTConfig(cfg.STT); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetSTTProvider persists just the dictation batch provider, mirroring SetTheme.
|
||||
func SetSTTProvider(path string, provider STTProviderKind) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg.STT.Provider = provider
|
||||
if err := validateSTTConfig(cfg.STT); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func normalizeFavoriteModels(models []string) []string {
|
||||
seen := map[string]bool{}
|
||||
favorites := make([]string, 0, len(models))
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
@@ -385,3 +386,545 @@ func readConfigFixture(t *testing.T, path string) FileConfig {
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestEnsureCatalogProviderCreatesProfileWithoutStealingActive(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "opengateway",
|
||||
Providers: []ProviderProfile{
|
||||
{
|
||||
Name: "opengateway",
|
||||
ProviderKind: ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://gateway.example.com/v1",
|
||||
APIKeyStored: true,
|
||||
Model: "some-model",
|
||||
},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
ensured, err := EnsureCatalogProvider(path, "chatgpt")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCatalogProvider: %v", err)
|
||||
}
|
||||
if !ensured.Created {
|
||||
t.Fatalf("expected profile to be created")
|
||||
}
|
||||
if ensured.Name != "chatgpt" {
|
||||
t.Fatalf("expected profile name chatgpt, got %q", ensured.Name)
|
||||
}
|
||||
if ensured.Active != "opengateway" {
|
||||
t.Fatalf("active provider must stay opengateway, got %q", ensured.Active)
|
||||
}
|
||||
|
||||
cfg := readConfigFixture(t, path)
|
||||
if cfg.ActiveProvider != "opengateway" {
|
||||
t.Fatalf("persisted active provider changed to %q", cfg.ActiveProvider)
|
||||
}
|
||||
if len(cfg.Providers) != 2 {
|
||||
t.Fatalf("expected 2 providers, got %d", len(cfg.Providers))
|
||||
}
|
||||
chatgpt := cfg.Providers[1]
|
||||
if chatgpt.Name != "chatgpt" || chatgpt.CatalogID != "chatgpt" {
|
||||
t.Fatalf("unexpected created profile: %+v", chatgpt)
|
||||
}
|
||||
if chatgpt.Model == "" || chatgpt.BaseURL == "" {
|
||||
t.Fatalf("created profile must carry catalog defaults, got %+v", chatgpt)
|
||||
}
|
||||
if chatgpt.APIKey != "" || chatgpt.APIKeyStored {
|
||||
t.Fatalf("OAuth-created profile must stay keyless, got %+v", chatgpt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCatalogProviderLeavesExistingProfileUntouched(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
original := FileConfig{
|
||||
ActiveProvider: "opengateway",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "opengateway", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
|
||||
// Renamed profile that already serves the chatgpt catalog entry.
|
||||
{Name: "codex", CatalogID: "chatgpt", Model: "gpt-5.5"},
|
||||
},
|
||||
}
|
||||
data := writeConfigFixture(t, path, original, 0o600)
|
||||
|
||||
ensured, err := EnsureCatalogProvider(path, "chatgpt")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCatalogProvider: %v", err)
|
||||
}
|
||||
if ensured.Created {
|
||||
t.Fatalf("existing profile must not be recreated")
|
||||
}
|
||||
if ensured.Name != "codex" {
|
||||
t.Fatalf("expected existing profile name codex, got %q", ensured.Name)
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if string(after) != string(data) {
|
||||
t.Fatalf("config rewritten for a no-op ensure:\nbefore: %s\nafter: %s", data, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCatalogProviderActivatesOnEmptyConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
|
||||
ensured, err := EnsureCatalogProvider(path, "chatgpt")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCatalogProvider: %v", err)
|
||||
}
|
||||
if !ensured.Created {
|
||||
t.Fatalf("expected profile to be created")
|
||||
}
|
||||
if ensured.Active != "chatgpt" {
|
||||
t.Fatalf("blank active must adopt the new provider, got %q", ensured.Active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCatalogProviderRejectsUnknownCatalogID(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
if _, err := EnsureCatalogProvider(path, "no-such-provider"); err == nil {
|
||||
t.Fatalf("expected unknown catalog id to error")
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("config must not be written for an unknown catalog id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveProviderDeletesAndHandsOffActive(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "beta",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
|
||||
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := RemoveProvider(path, " BETA ")
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveProvider() error = %v", err)
|
||||
}
|
||||
if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "alpha" {
|
||||
t.Fatalf("expected only alpha to remain, got %+v", cfg.Providers)
|
||||
}
|
||||
if cfg.ActiveProvider != "alpha" {
|
||||
t.Fatalf("active must hand off to the first remaining provider, got %q", cfg.ActiveProvider)
|
||||
}
|
||||
|
||||
persisted := readConfigFixture(t, path)
|
||||
if len(persisted.Providers) != 1 || persisted.ActiveProvider != "alpha" {
|
||||
t.Fatalf("persisted config wrong: %+v", persisted)
|
||||
}
|
||||
|
||||
// Removing the last provider clears the active pointer entirely.
|
||||
cfg, err = RemoveProvider(path, "alpha")
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveProvider(last) error = %v", err)
|
||||
}
|
||||
if len(cfg.Providers) != 0 || cfg.ActiveProvider != "" {
|
||||
t.Fatalf("expected empty providers and no active, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveProviderKeepsActiveWhenOtherRemoved(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "alpha",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
|
||||
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := RemoveProvider(path, "beta")
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveProvider() error = %v", err)
|
||||
}
|
||||
if cfg.ActiveProvider != "alpha" {
|
||||
t.Fatalf("active provider must be untouched, got %q", cfg.ActiveProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveProviderRejectsUnknownWithoutRewriting(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
before := writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "alpha",
|
||||
Providers: []ProviderProfile{{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"}},
|
||||
}, 0o600)
|
||||
|
||||
if _, err := RemoveProvider(path, "ghost"); err == nil {
|
||||
t.Fatal("RemoveProvider() error = nil, want not-found error")
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if string(after) != string(before) {
|
||||
t.Fatalf("config was rewritten for unknown provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameProviderFollowsActiveAndMigratesStoredKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Force the file credential backend so the test never touches the real OS
|
||||
// keyring regardless of platform.
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "oldname",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "oldname", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", APIKeyStored: true, Model: "m1"},
|
||||
{Name: "other", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
|
||||
},
|
||||
}, 0o600)
|
||||
store, err := ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("oldname", "sk-secret"); err != nil {
|
||||
t.Fatalf("seed store: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := RenameProvider(path, "oldname", "newname")
|
||||
if err != nil {
|
||||
t.Fatalf("RenameProvider() error = %v", err)
|
||||
}
|
||||
if cfg.ActiveProvider != "newname" {
|
||||
t.Fatalf("active must follow the rename, got %q", cfg.ActiveProvider)
|
||||
}
|
||||
if cfg.Providers[0].Name != "newname" || !cfg.Providers[0].APIKeyStored {
|
||||
t.Fatalf("renamed profile wrong: %+v", cfg.Providers[0])
|
||||
}
|
||||
if cfg.Providers[1].Name != "other" {
|
||||
t.Fatalf("unrelated profile changed: %+v", cfg.Providers[1])
|
||||
}
|
||||
|
||||
key, ok, err := store.Get("newname")
|
||||
if err != nil || !ok || key != "sk-secret" {
|
||||
t.Fatalf("stored key must migrate to the new name, got key=%q ok=%v err=%v", key, ok, err)
|
||||
}
|
||||
if _, ok, _ := store.Get("oldname"); ok {
|
||||
t.Fatalf("old credential entry must be deleted after migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameProviderRejectsCollisionAndUnknown(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
before := writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "alpha",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
|
||||
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
if _, err := RenameProvider(path, "alpha", "BETA"); err == nil {
|
||||
t.Fatal("rename onto an existing name must fail")
|
||||
}
|
||||
if _, err := RenameProvider(path, "ghost", "gamma"); err == nil {
|
||||
t.Fatal("renaming an unknown provider must fail")
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if string(after) != string(before) {
|
||||
t.Fatalf("config was rewritten by a rejected rename")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProviderPreservesStoredKeyMarkerOnExistingProfile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
// An env-keyed profile with NO stored-key marker — the shape a provider has
|
||||
// before its key is captured into the credential store.
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "groq",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyEnv: "GROQ_API_KEY", Model: "m1"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
// The manager/setup edit paths persist a SecureProviderProfile-shaped
|
||||
// profile: key already in the store, marker set, inline key cleared.
|
||||
cfg, err := UpsertProvider(path, ProviderProfile{Name: "groq", APIKeyStored: true}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertProvider() error = %v", err)
|
||||
}
|
||||
if !cfg.Providers[0].APIKeyStored {
|
||||
t.Fatalf("APIKeyStored marker must survive the merge, got %+v", cfg.Providers[0])
|
||||
}
|
||||
if cfg.Providers[0].APIKeyEnv != "GROQ_API_KEY" || cfg.Providers[0].BaseURL == "" {
|
||||
t.Fatalf("other fields must be preserved: %+v", cfg.Providers[0])
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if !persisted.Providers[0].APIKeyStored {
|
||||
t.Fatalf("marker not persisted to disk: %+v", persisted.Providers[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderDescriptionSetsAndClears(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "alpha",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1", Description: "old text"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := SetProviderDescription(path, " ALPHA ", "new text")
|
||||
if err != nil {
|
||||
t.Fatalf("SetProviderDescription() error = %v", err)
|
||||
}
|
||||
if cfg.Providers[0].Description != "new text" {
|
||||
t.Fatalf("description not set: %+v", cfg.Providers[0])
|
||||
}
|
||||
|
||||
// Clearing must persist too — the reason this setter exists (UpsertProvider's
|
||||
// merge treats an empty description as "leave unchanged").
|
||||
cfg, err = SetProviderDescription(path, "alpha", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("SetProviderDescription(clear) error = %v", err)
|
||||
}
|
||||
if cfg.Providers[0].Description != "" {
|
||||
t.Fatalf("description not cleared: %+v", cfg.Providers[0])
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Providers[0].Description != "" {
|
||||
t.Fatalf("cleared description not persisted: %+v", persisted.Providers[0])
|
||||
}
|
||||
|
||||
if _, err := SetProviderDescription(path, "ghost", "x"); err == nil {
|
||||
t.Fatal("unknown provider must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameProviderRollsBackKeyMigrationWhenConfigWriteFails(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("uses chflags uchg to force the config write to fail; macOS only")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "oldname",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "oldname", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", APIKeyStored: true, Model: "m1"},
|
||||
},
|
||||
}, 0o600)
|
||||
store, err := ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("oldname", "sk-secret"); err != nil {
|
||||
t.Fatalf("seed store: %v", err)
|
||||
}
|
||||
|
||||
// Immutable flag: temp-file creation and store writes in the directory keep
|
||||
// working, but the final rename over config.json fails — the exact window
|
||||
// where a migrated key would otherwise strand under the new name.
|
||||
if out, err := exec.Command("chflags", "uchg", path).CombinedOutput(); err != nil {
|
||||
t.Skipf("chflags uchg unavailable: %v (%s)", err, out)
|
||||
}
|
||||
t.Cleanup(func() { _ = exec.Command("chflags", "nouchg", path).Run() })
|
||||
|
||||
if _, err := RenameProvider(path, "oldname", "newname"); err == nil {
|
||||
t.Fatal("expected the config write to fail under the immutable flag")
|
||||
}
|
||||
|
||||
key, ok, err := store.Get("oldname")
|
||||
if err != nil || !ok || key != "sk-secret" {
|
||||
t.Fatalf("key must be rolled back to the old name, got key=%q ok=%v err=%v", key, ok, err)
|
||||
}
|
||||
if _, ok, _ := store.Get("newname"); ok {
|
||||
t.Fatalf("rolled-back migration must not leave a key under the new name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenameProviderCaseOnlyKeepsStoredKey: the credential store normalizes
|
||||
// names case-insensitively, so a case-only rename targets ONE store entry —
|
||||
// migrating would Set and then Delete the same key, losing it (PR #560 review).
|
||||
func TestRenameProviderCaseOnlyKeepsStoredKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "groq",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1"},
|
||||
},
|
||||
}, 0o600)
|
||||
store, err := ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("groq", "sk-secret"); err != nil {
|
||||
t.Fatalf("seed store: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := RenameProvider(path, "groq", "Groq")
|
||||
if err != nil {
|
||||
t.Fatalf("RenameProvider() error = %v", err)
|
||||
}
|
||||
if cfg.Providers[0].Name != "Groq" || cfg.ActiveProvider != "Groq" {
|
||||
t.Fatalf("case-only rename must still apply to config: %+v", cfg)
|
||||
}
|
||||
// The store is case-insensitive: the key must remain retrievable under the
|
||||
// new casing (same entry), not deleted by a same-entry "migration".
|
||||
if key, ok, err := store.Get("Groq"); err != nil || !ok || key != "sk-secret" {
|
||||
t.Fatalf("stored key lost on case-only rename: key=%q ok=%v err=%v", key, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditProviderAppliesRenameFieldsAndDescriptionAtomically(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "groq",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1", Description: "old text"},
|
||||
{Name: "other", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://o.example.com/v1", Model: "m2"},
|
||||
},
|
||||
}, 0o600)
|
||||
store, err := ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("groq", "sk-old"); err != nil {
|
||||
t.Fatalf("seed store: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := EditProvider(path, ProviderEdit{
|
||||
Name: "groq",
|
||||
NewName: "grok-main",
|
||||
Model: "m1-pro",
|
||||
Description: "", // cleared — must persist verbatim
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EditProvider() error = %v", err)
|
||||
}
|
||||
edited := cfg.Providers[0]
|
||||
if edited.Name != "grok-main" || edited.Model != "m1-pro" || edited.Description != "" {
|
||||
t.Fatalf("edit not applied: %+v", edited)
|
||||
}
|
||||
if edited.BaseURL != "https://api.groq.com/openai/v1" || !edited.APIKeyStored {
|
||||
t.Fatalf("untouched fields must survive: %+v", edited)
|
||||
}
|
||||
if cfg.ActiveProvider != "grok-main" {
|
||||
t.Fatalf("active must follow the rename, got %q", cfg.ActiveProvider)
|
||||
}
|
||||
if key, ok, _ := store.Get("grok-main"); !ok || key != "sk-old" {
|
||||
t.Fatalf("stored key must migrate with the rename, got %q ok=%v", key, ok)
|
||||
}
|
||||
if len(cfg.Providers) != 2 || cfg.Providers[1].Name != "other" {
|
||||
t.Fatalf("unrelated profile changed: %+v", cfg.Providers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditProviderCaseOnlyRenameUpdatesInPlace: the manager previously skipped
|
||||
// RenameProvider on case-insensitively-equal names and fell into UpsertProvider,
|
||||
// whose case-SENSITIVE merge appended a duplicate profile. EditProvider matches
|
||||
// case-insensitively, so a case-only rename is an in-place update and the store
|
||||
// entry (case-normalized) survives.
|
||||
func TestEditProviderCaseOnlyRenameUpdatesInPlace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "groq",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1"},
|
||||
},
|
||||
}, 0o600)
|
||||
store, err := ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("groq", "sk-secret"); err != nil {
|
||||
t.Fatalf("seed store: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := EditProvider(path, ProviderEdit{Name: "groq", NewName: "Groq"})
|
||||
if err != nil {
|
||||
t.Fatalf("EditProvider() error = %v", err)
|
||||
}
|
||||
if len(cfg.Providers) != 1 {
|
||||
t.Fatalf("case-only rename must not duplicate the profile: %+v", cfg.Providers)
|
||||
}
|
||||
if cfg.Providers[0].Name != "Groq" || !cfg.Providers[0].APIKeyStored {
|
||||
t.Fatalf("in-place update wrong: %+v", cfg.Providers[0])
|
||||
}
|
||||
if cfg.Providers[0].BaseURL != "https://api.groq.com/openai/v1" {
|
||||
t.Fatalf("fields must survive a case-only rename: %+v", cfg.Providers[0])
|
||||
}
|
||||
if cfg.ActiveProvider != "Groq" {
|
||||
t.Fatalf("active must follow, got %q", cfg.ActiveProvider)
|
||||
}
|
||||
if key, ok, _ := store.Get("Groq"); !ok || key != "sk-secret" {
|
||||
t.Fatalf("stored key lost on case-only rename: %q ok=%v", key, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditProviderRenameMigratesFreshlyCapturedKey: replacing the key AND
|
||||
// renaming in one edit — the caller captures under the CURRENT name and the
|
||||
// rename migration moves it, so the new key lands under the new name.
|
||||
func TestEditProviderRenameMigratesFreshlyCapturedKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "gw",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "gw", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://gw.example.com/v1", APIKeyEnv: "GW_KEY", Model: "m1"},
|
||||
},
|
||||
}, 0o600)
|
||||
store, err := ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
// The caller's contract: a replacement key is stored under the CURRENT name
|
||||
// before EditProvider runs (what SecureProviderProfile does).
|
||||
if err := store.Set("gw", "sk-new"); err != nil {
|
||||
t.Fatalf("seed store: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := EditProvider(path, ProviderEdit{Name: "gw", NewName: "gateway", APIKeyStored: true})
|
||||
if err != nil {
|
||||
t.Fatalf("EditProvider() error = %v", err)
|
||||
}
|
||||
if !cfg.Providers[0].APIKeyStored || cfg.Providers[0].Name != "gateway" {
|
||||
t.Fatalf("marker/rename wrong: %+v", cfg.Providers[0])
|
||||
}
|
||||
if key, ok, _ := store.Get("gateway"); !ok || key != "sk-new" {
|
||||
t.Fatalf("captured key must migrate to the new name, got %q ok=%v", key, ok)
|
||||
}
|
||||
if _, ok, _ := store.Get("gw"); ok {
|
||||
t.Fatalf("old entry must be cleaned up after migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditProviderRejectsCollisionAndUnknown(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
before := writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "alpha",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
|
||||
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
if _, err := EditProvider(path, ProviderEdit{Name: "alpha", NewName: "BETA"}); err == nil {
|
||||
t.Fatal("rename onto an existing name must fail")
|
||||
}
|
||||
if _, err := EditProvider(path, ProviderEdit{Name: "ghost", Model: "x"}); err == nil {
|
||||
t.Fatal("editing an unknown provider must fail")
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if string(after) != string(before) {
|
||||
t.Fatalf("config was rewritten by a rejected edit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Package dictation implements speech-to-text capture and transcription for
|
||||
// the composer: press a key, talk, and the transcript lands in the input for
|
||||
// review — never auto-fired at the agent.
|
||||
//
|
||||
// Two pipelines share a keybinding and a composer-insertion target:
|
||||
//
|
||||
// - Batch: record to a temp WAV file (Recorder.Start/Stop), then transcribe
|
||||
// in one shot (Transcriber.Transcribe) — a local sherpa-onnx-offline exec
|
||||
// or a single HTTPS POST to Groq/OpenAI. The only pipeline on Termux.
|
||||
// - Streaming: capture raw PCM from the mic tool's stdout
|
||||
// (Recorder.StartStreaming), feed 50ms chunks to a websocket
|
||||
// (Transcriber.StreamTranscribe), and render partial transcripts live.
|
||||
//
|
||||
// Every OS integration shells out via exec.Command with discrete argv — never
|
||||
// a shell string, never CGO — matching internal/imageinput/clipboard.go. The
|
||||
// external binaries (arecord/sox/ffmpeg/termux-microphone-record, sherpa-onnx)
|
||||
// are looked up on PATH and degrade to a clear setup message when missing,
|
||||
// matching internal/lsp's philosophy for language servers.
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Batch transcription providers (config value stt.provider).
|
||||
const (
|
||||
ProviderLocal = "local"
|
||||
ProviderGroq = "groq"
|
||||
ProviderOpenAI = "openai"
|
||||
)
|
||||
|
||||
// Streaming transcription providers (config value stt.streamProvider).
|
||||
const (
|
||||
StreamProviderLocal = "local"
|
||||
StreamProviderDeepgram = "deepgram"
|
||||
StreamProviderOpenAI = "openai"
|
||||
)
|
||||
|
||||
// BatchProviders lists valid stt.provider values, for config validation.
|
||||
func BatchProviders() []string {
|
||||
return []string{ProviderLocal, ProviderGroq, ProviderOpenAI}
|
||||
}
|
||||
|
||||
// StreamProviders lists valid stt.streamProvider values, for config validation.
|
||||
func StreamProviders() []string {
|
||||
return []string{StreamProviderLocal, StreamProviderDeepgram, StreamProviderOpenAI}
|
||||
}
|
||||
|
||||
// AudioFormat identifies a recorded container format, sniffed from the bytes —
|
||||
// desktop recorders produce WAV, Termux's recorder produces M4A/AAC.
|
||||
type AudioFormat string
|
||||
|
||||
const (
|
||||
FormatWAV AudioFormat = "wav"
|
||||
FormatM4A AudioFormat = "m4a"
|
||||
FormatUnknown AudioFormat = ""
|
||||
)
|
||||
|
||||
// SniffFormat detects the audio container from magic bytes. WAV is
|
||||
// "RIFF....WAVE"; MP4-family (Termux's .m4a) has "ftyp" at offset 4.
|
||||
func SniffFormat(data []byte) AudioFormat {
|
||||
if len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WAVE")) {
|
||||
return FormatWAV
|
||||
}
|
||||
if len(data) >= 8 && bytes.Equal(data[4:8], []byte("ftyp")) {
|
||||
return FormatM4A
|
||||
}
|
||||
return FormatUnknown
|
||||
}
|
||||
|
||||
// FileName returns a transcription-upload filename for the format.
|
||||
func (f AudioFormat) FileName() string {
|
||||
switch f {
|
||||
case FormatM4A:
|
||||
return "audio.m4a"
|
||||
default:
|
||||
return "audio.wav"
|
||||
}
|
||||
}
|
||||
|
||||
// SetupError reports a missing external tool with install guidance. Missing
|
||||
// binaries are an expected condition (the user hasn't set dictation up yet),
|
||||
// not a bug — callers show the hint instead of a raw exec error.
|
||||
type SetupError struct {
|
||||
Tool string
|
||||
Hint string
|
||||
}
|
||||
|
||||
func (e *SetupError) Error() string {
|
||||
return fmt.Sprintf("%s not found — %s", e.Tool, e.Hint)
|
||||
}
|
||||
|
||||
// AuthError reports that a cloud provider rejected a transcription request for
|
||||
// authentication reasons (HTTP 401/403) — a missing or invalid API key. It is a
|
||||
// fixable credential problem, so callers (the TUI) can offer an inline key
|
||||
// prompt for Provider instead of a dead-end error. Message is the already-
|
||||
// classified, secret-redacted user-facing text.
|
||||
type AuthError struct {
|
||||
Provider string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string { return e.Message }
|
||||
@@ -0,0 +1,893 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/bzip2"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Auto-download of the local engine + a default model (opt-in, behind a confirm
|
||||
// in the F9 flow). Assets come from the official k2-fsa/sherpa-onnx GitHub
|
||||
// releases and are verified against a SHA256 digest before use — the same trust
|
||||
// boundary as installing them yourself, automated. Auto-downloading a native
|
||||
// executable is a real trust decision, which is why it is opt-in and verified.
|
||||
//
|
||||
// Rather than freeze a single hardcoded version, the downloader resolves the
|
||||
// release ASSETS via the GitHub API at download time and verifies each download
|
||||
// against the API's per-asset digest — so a newer sherpa-onnx release (or
|
||||
// "latest") works with no code change. A known-good version is still the DEFAULT
|
||||
// (reproducible, smoke-tested), and for that default the resolved digest is
|
||||
// cross-checked against a pinned value so a silently-changed release is caught.
|
||||
|
||||
// DefaultSherpaVersion is the pinned, smoke-tested release used unless
|
||||
// stt.engineVersion overrides it. "latest" or any tag also works.
|
||||
const DefaultSherpaVersion = "v1.13.3"
|
||||
|
||||
const (
|
||||
sherpaRepo = "k2-fsa/sherpa-onnx"
|
||||
modelReleaseTag = "asr-models" // rolling release the ASR models live in
|
||||
modelAssetName = "sherpa-onnx-moonshine-tiny-en-int8.tar.bz2"
|
||||
defaultAPIBase = "https://api.github.com"
|
||||
)
|
||||
|
||||
// engineAssetSuffix maps GOOS-GOARCH to the version-independent suffix of the
|
||||
// sherpa-onnx executable bundle for that platform. Matching by suffix (not a
|
||||
// full version-embedded name) is what lets any release resolve. The bundles are
|
||||
// self-contained (RPATH $ORIGIN/../lib), so the extracted bin/ executables run
|
||||
// with no library-path setup.
|
||||
var engineAssetSuffix = map[string]string{
|
||||
"linux-amd64": "linux-x64-shared-no-tts.tar.bz2",
|
||||
"darwin-arm64": "osx-arm64-shared-no-tts.tar.bz2",
|
||||
"darwin-amd64": "osx-x64-shared-no-tts.tar.bz2",
|
||||
"windows-amd64": "win-x64-shared-MT-Release-no-tts.tar.bz2",
|
||||
}
|
||||
|
||||
// pinnedEngineDigest cross-checks the DefaultSherpaVersion engine assets (a
|
||||
// silently-changed pinned release is refused). Empty for non-default versions,
|
||||
// which are verified against the API digest only.
|
||||
var pinnedEngineDigest = map[string]string{
|
||||
"linux-amd64": "e5cbfd96f3666c25d2272d468e337764fce6a4d50ed074e4b8e5021bfd7fefc1",
|
||||
"darwin-arm64": "ea73233bc250019ccf1f8a16feb29460efcd3425b50cac16980bcb76281d60bf",
|
||||
"darwin-amd64": "563423887d63d8a831c473e0ab6815139fe2e7a3482936716c2a128071dd217f",
|
||||
"windows-amd64": "343c6c39cb4fe1880a9fb1abc8401d5174629ccbb147fca1fd53c440b2664570",
|
||||
}
|
||||
|
||||
// pinnedModelDigest cross-checks the default Moonshine-tiny int8 model.
|
||||
const pinnedModelDigest = "d5fe6ec4334fef36255b2a4010412cad4c007e33103fec62fb5d17cad88086f2"
|
||||
|
||||
// ModelVariant is a downloadable model the /stt-model picker offers, with a
|
||||
// pinned SHA256 (the model release predates GitHub's per-asset digests, so these
|
||||
// must be pinned). Batch variants use the offline binary; the streaming variant
|
||||
// (a transducer) drives the websocket server for a live transcript.
|
||||
type ModelVariant struct {
|
||||
ID string
|
||||
Label string
|
||||
Description string
|
||||
Bytes int64
|
||||
AssetName string
|
||||
Digest string
|
||||
DirName string
|
||||
Streaming bool
|
||||
// Recommended marks a curated, checksum-pinned, validated model (shown first).
|
||||
Recommended bool
|
||||
}
|
||||
|
||||
// ModelVariants returns the curated English models the download picker offers.
|
||||
// This is a hand-picked shortlist (each pinned by SHA256 since the model release
|
||||
// predates GitHub's per-asset digests), NOT the whole sherpa-onnx model zoo —
|
||||
// many more models (other languages, sizes, and streaming/batch families) work
|
||||
// via a manual stt.localModelPath. Streaming (live) options come first.
|
||||
func ModelVariants() []ModelVariant {
|
||||
return []ModelVariant{
|
||||
// Live / streaming — transcript builds up as you speak.
|
||||
// DirName MUST match what ListModels derives from the asset ("model-"+id, id =
|
||||
// asset minus "sherpa-onnx-" prefix and ".tar.bz2" suffix, dates included) so a
|
||||
// model extracts to the same directory whether picked from the curated shortlist
|
||||
// or the full browse list — otherwise installed-detection disagrees between the
|
||||
// two views and the same model can be downloaded twice. Recommended matches the
|
||||
// full list too (recommendedModelIDs marks every curated variant), so the ★ and
|
||||
// ordering don't change when the background fetch swaps the curated list out.
|
||||
{
|
||||
ID: "streaming-zipformer-en-kroko", Label: "Kroko (newest, fastest)",
|
||||
Description: "smallest live model", Bytes: 54 << 20,
|
||||
AssetName: "sherpa-onnx-streaming-zipformer-en-kroko-2025-08-06.tar.bz2",
|
||||
Digest: "c8676e5ff9ac2a85296e53ee0fd4d5fb1db6770e7a7647166eeafe349ade6834",
|
||||
DirName: "model-streaming-zipformer-en-kroko-2025-08-06",
|
||||
Streaming: true,
|
||||
Recommended: true,
|
||||
},
|
||||
{
|
||||
ID: "streaming-zipformer-20m", Label: "Zipformer 20M",
|
||||
Description: "small, well-tested", Bytes: 121 << 20,
|
||||
AssetName: "sherpa-onnx-streaming-zipformer-en-20M-2023-02-17.tar.bz2",
|
||||
Digest: "9c559283e8498d3fe95913c79ca1cb454bb26281ac2b102b41306c7d752765d9",
|
||||
DirName: "model-streaming-zipformer-en-20M-2023-02-17",
|
||||
Streaming: true,
|
||||
},
|
||||
{
|
||||
ID: "streaming-zipformer", Label: "Zipformer",
|
||||
Description: "larger, more accurate", Bytes: 297 << 20,
|
||||
AssetName: "sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2",
|
||||
Digest: "639e25b578e9e997131402199419c13a941f8e4e198e2da1ce57dbf5cf401282",
|
||||
DirName: "model-streaming-zipformer-en-2023-06-26",
|
||||
Streaming: true,
|
||||
},
|
||||
{
|
||||
ID: "nemo-streaming-fast-conformer-transducer-en-80ms-int8", Label: "NeMo streaming (fast)",
|
||||
Description: "low-latency English", Bytes: 98 << 20,
|
||||
AssetName: "sherpa-onnx-nemo-streaming-fast-conformer-transducer-en-80ms-int8.tar.bz2",
|
||||
Digest: "7bd33a914e93370a1ba9c2066d9e841bdcad8613fa2a00537c1ae15d851a14d8",
|
||||
DirName: "model-nemo-streaming-fast-conformer-transducer-en-80ms-int8",
|
||||
Streaming: true,
|
||||
Recommended: true,
|
||||
},
|
||||
// Batch — transcribes once when you stop.
|
||||
{
|
||||
ID: "moonshine-tiny", Label: "Moonshine tiny",
|
||||
Description: "fast, smallest", Bytes: 103 << 20,
|
||||
AssetName: "sherpa-onnx-moonshine-tiny-en-int8.tar.bz2",
|
||||
Digest: pinnedModelDigest,
|
||||
DirName: "model-moonshine-tiny-en-int8",
|
||||
},
|
||||
{
|
||||
ID: "moonshine-base", Label: "Moonshine base",
|
||||
Description: "more accurate, larger", Bytes: 239 << 20,
|
||||
AssetName: "sherpa-onnx-moonshine-base-en-int8.tar.bz2",
|
||||
Digest: "21870cecaa2e44e4e2bf63e02d1072bed183ccd10284871353bd9d24dad14e5e",
|
||||
DirName: "model-moonshine-base-en-int8",
|
||||
Recommended: true,
|
||||
},
|
||||
{
|
||||
ID: "sense-voice-zh-en-ja-ko-yue-int8-2025-09-09", Label: "SenseVoice",
|
||||
Description: "multilingual, accurate", Bytes: 158 << 20,
|
||||
AssetName: "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2",
|
||||
Digest: "7305f7905bfcf77fa0b39388a313f3da35c68d971661a65475b56fb2162c8e63",
|
||||
DirName: "model-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09",
|
||||
Recommended: true,
|
||||
},
|
||||
{
|
||||
// Heavy (~1 GB) but the best transcription quality — recommended when
|
||||
// accuracy matters more than speed/size. The asr-models release publishes
|
||||
// no digest for it, so (like every browse-listed model) it downloads with
|
||||
// TLS-only verification.
|
||||
ID: "whisper-large-v3", Label: "Whisper large v3",
|
||||
Description: "best accuracy, heavy", Bytes: 1019 << 20,
|
||||
AssetName: "sherpa-onnx-whisper-large-v3.tar.bz2",
|
||||
DirName: "model-whisper-large-v3",
|
||||
Recommended: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// recommendedModelIDs marks the curated variants flagged Recommended so the full
|
||||
// browse list stars (and surfaces) exactly the ones the curated shortlist does —
|
||||
// keyed off the same Recommended flag so the two views never disagree.
|
||||
var recommendedModelIDs = func() map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, v := range ModelVariants() {
|
||||
if v.Recommended {
|
||||
m[v.AssetName] = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// pinnedModelDigests maps a model asset name to its pinned SHA256 (the curated
|
||||
// variants), so browse-listed models we happen to know get verified too.
|
||||
var pinnedModelDigests = func() map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, v := range ModelVariants() {
|
||||
m[v.AssetName] = v.Digest
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// curatedLabels maps a curated model's asset name to its friendly label, so the
|
||||
// recommended rows read nicely in the full browse list too.
|
||||
var curatedLabels = func() map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, v := range ModelVariants() {
|
||||
m[v.AssetName] = v.Label
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// browseLabel turns a raw asset id into a shorter display label: it drops the
|
||||
// redundant "-int8"/quantization noise and the "streaming-" prefix (the group
|
||||
// header already says live vs batch).
|
||||
func browseLabel(id string) string {
|
||||
label := strings.TrimPrefix(id, "streaming-")
|
||||
for _, drop := range []string{"-int8", ".int8", "-quantized", "-fp16"} {
|
||||
label = strings.ReplaceAll(label, drop, "")
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// ListModels fetches every transcription model in the sherpa-onnx asr-models
|
||||
// release and returns them as variants (the curated ones flagged Recommended and
|
||||
// carrying a pinned digest; the rest with an empty digest, downloaded with
|
||||
// TLS-only verification since that release predates GitHub's per-asset digests).
|
||||
// Non-ASR assets (VAD, TTS, punctuation, speaker/keyword/…): filtered out.
|
||||
func ListModels(ctx context.Context, client *http.Client, apiBase string) ([]ModelVariant, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if apiBase == "" {
|
||||
apiBase = defaultAPIBase
|
||||
}
|
||||
// Resolve the release id, then page its assets (a release can have hundreds).
|
||||
relURL := fmt.Sprintf("%s/repos/%s/releases/tags/%s", strings.TrimRight(apiBase, "/"), sherpaRepo, modelReleaseTag)
|
||||
var rel struct {
|
||||
ID int64 `json:"id"`
|
||||
Assets []githubAsset
|
||||
}
|
||||
if err := getJSON(ctx, client, relURL, &rel); err != nil {
|
||||
return nil, fmt.Errorf("listing dictation models: %w", err)
|
||||
}
|
||||
assets := rel.Assets
|
||||
// If the inline assets look capped, page the assets endpoint for the rest.
|
||||
for page := 1; len(assets)%100 == 0 && len(assets) > 0; page++ {
|
||||
var more []githubAsset
|
||||
u := fmt.Sprintf("%s/repos/%s/releases/%d/assets?per_page=100&page=%d", strings.TrimRight(apiBase, "/"), sherpaRepo, rel.ID, page+1)
|
||||
if err := getJSON(ctx, client, u, &more); err != nil || len(more) == 0 {
|
||||
break
|
||||
}
|
||||
assets = append(assets, more...)
|
||||
if len(more) < 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var out []ModelVariant
|
||||
for _, a := range assets {
|
||||
if seen[a.Name] || !isTranscriptionModelAsset(a.Name) {
|
||||
continue
|
||||
}
|
||||
seen[a.Name] = true
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(a.Name, "sherpa-onnx-"), ".tar.bz2")
|
||||
digest := strings.TrimPrefix(a.Digest, "sha256:")
|
||||
if digest == "" {
|
||||
digest = pinnedModelDigests[a.Name] // may still be empty (browse/unverified)
|
||||
}
|
||||
label := browseLabel(id)
|
||||
desc := modelFamily(a.Name) // readable language, e.g. "French"
|
||||
if nice, ok := curatedLabels[a.Name]; ok {
|
||||
label = nice
|
||||
desc = curatedDescriptions[a.Name] // curated blurb ("fast, smallest")
|
||||
}
|
||||
out = append(out, ModelVariant{
|
||||
ID: id,
|
||||
Label: label,
|
||||
Description: desc,
|
||||
Bytes: a.Size,
|
||||
AssetName: a.Name,
|
||||
Digest: digest,
|
||||
DirName: "model-" + id,
|
||||
Streaming: strings.Contains(strings.ToLower(a.Name), "streaming-"),
|
||||
Recommended: recommendedModelIDs[a.Name],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type githubAsset struct {
|
||||
Name string `json:"name"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
func getJSON(ctx context.Context, client *http.Client, url string, v any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GitHub API HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
// isTranscriptionModelAsset reports whether an asset is a speech-to-text model
|
||||
// (as opposed to a VAD/TTS/punctuation/speaker/keyword/enhancement tool).
|
||||
func isTranscriptionModelAsset(name string) bool {
|
||||
n := strings.ToLower(name)
|
||||
if !strings.HasPrefix(n, "sherpa-onnx-") || !strings.HasSuffix(n, ".tar.bz2") {
|
||||
return false
|
||||
}
|
||||
for _, bad := range []string{
|
||||
"-vad", "vad-", "tts", "punct", "speaker", "diariz", "kws", "keyword",
|
||||
"audio-tagging", "denois", "source-separation", "speech-enhancement",
|
||||
"spoken-language", "language-identification", "-lid", "melo", "kokoro",
|
||||
"vits", "matcha", "piper", "wespeaker",
|
||||
// Rockchip NPU (RKNN) builds for specific ARM boards — not CPU/ONNX, so
|
||||
// the standard sherpa-onnx binary can't run them.
|
||||
"rknn", "rk3566", "rk3568", "rk3562", "rk3588", "rk3576", "rk3528", "rk3399",
|
||||
} {
|
||||
if strings.Contains(n, bad) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, fam := range []string{
|
||||
"zipformer", "moonshine", "whisper", "paraformer", "sense-voice",
|
||||
"sensevoice", "conformer", "nemo", "canary", "parakeet", "fire-red",
|
||||
"fireredasr", "dolphin", "telespeech", "wenet", "omnilingual", "funasr",
|
||||
"medasr", "cohere", "qwen", "citrinet", "transducer", "-ctc-", "zipvoice",
|
||||
} {
|
||||
if strings.Contains(n, fam) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// langNames maps ISO-ish language tokens in a model name to readable names.
|
||||
var langNames = map[string]string{
|
||||
"en": "English", "zh": "Chinese", "yue": "Cantonese", "fr": "French",
|
||||
"de": "German", "es": "Spanish", "ru": "Russian", "ja": "Japanese",
|
||||
"ko": "Korean", "bn": "Bengali", "ar": "Arabic", "hi": "Hindi",
|
||||
"pt": "Portuguese", "nl": "Dutch", "vi": "Vietnamese", "th": "Thai",
|
||||
"uk": "Ukrainian", "fa": "Persian", "pl": "Polish", "tr": "Turkish",
|
||||
"cs": "Czech", "sv": "Swedish", "fil": "Filipino", "tl": "Tagalog",
|
||||
"fi": "Finnish", "el": "Greek", "he": "Hebrew", "ro": "Romanian",
|
||||
}
|
||||
|
||||
// modelFamily is a short readable descriptor for a model, favoring the language
|
||||
// (the most useful thing not already obvious from the group header) and falling
|
||||
// back to the family. E.g. "French", "Chinese", "Multilingual".
|
||||
func modelFamily(name string) string {
|
||||
n := strings.ToLower(name)
|
||||
if strings.Contains(n, "multi") || strings.Contains(n, "bilingual") ||
|
||||
strings.Contains(n, "trilingual") || strings.Contains(n, "polyglot") ||
|
||||
strings.Contains(n, "omnilingual") {
|
||||
return "Multilingual"
|
||||
}
|
||||
var langs []string
|
||||
seen := map[string]bool{}
|
||||
add := func(name string) {
|
||||
if name != "" && !seen[name] {
|
||||
seen[name] = true
|
||||
langs = append(langs, name)
|
||||
}
|
||||
}
|
||||
for _, tok := range strings.Split(strings.TrimSuffix(n, ".tar.bz2"), "-") {
|
||||
add(langNames[tok])
|
||||
}
|
||||
// Full language words spelled out in some names (e.g. "-cantonese-").
|
||||
for word, name := range map[string]string{
|
||||
"cantonese": "Cantonese", "mandarin": "Chinese", "english": "English",
|
||||
"chinese": "Chinese", "korean": "Korean", "japanese": "Japanese",
|
||||
"russian": "Russian", "arabic": "Arabic", "spanish": "Spanish",
|
||||
"french": "French", "german": "German", "vietnamese": "Vietnamese",
|
||||
"thai": "Thai", "farsi": "Persian", "persian": "Persian",
|
||||
"portuguese": "Portuguese", "italian": "Italian", "dutch": "Dutch",
|
||||
"ukrainian": "Ukrainian", "polish": "Polish", "turkish": "Turkish",
|
||||
} {
|
||||
if strings.Contains(n, word) {
|
||||
add(name)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(langs) == 0:
|
||||
return ""
|
||||
case len(langs) > 2:
|
||||
return "Multilingual"
|
||||
default:
|
||||
return strings.Join(langs, " + ")
|
||||
}
|
||||
}
|
||||
|
||||
// curatedDescriptions maps a curated model's asset name to its quality blurb.
|
||||
var curatedDescriptions = func() map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, v := range ModelVariants() {
|
||||
m[v.AssetName] = v.Description
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// EngineComponents identifies the resolved local-engine paths.
|
||||
type EngineComponents struct {
|
||||
BinaryPath string // extracted sherpa-onnx-offline
|
||||
ServerPath string // extracted sherpa-onnx-online-websocket-server
|
||||
ModelPath string // extracted model directory
|
||||
}
|
||||
|
||||
// DownloadOptions configures EnsureLocalEngine.
|
||||
type DownloadOptions struct {
|
||||
// DestRoot is where extracted engines/models live (e.g. ~/.config/zero/stt).
|
||||
DestRoot string
|
||||
// EngineVersion selects the sherpa-onnx release tag ("" → DefaultSherpaVersion;
|
||||
// "latest" and any tag also work).
|
||||
EngineVersion string
|
||||
// HTTPClient is injectable for tests; nil uses a plain client.
|
||||
HTTPClient *http.Client
|
||||
// APIBase overrides the GitHub API base (tests). "" → api.github.com.
|
||||
APIBase string
|
||||
// Model selection (default: the Moonshine-tiny int8 batch model). Set these
|
||||
// from a ModelVariant to download a different model.
|
||||
ModelAssetName string
|
||||
ModelPinnedDigest string
|
||||
ModelDirName string
|
||||
// ModelLabel is the friendly model name shown in progress ("Zipformer 20M").
|
||||
ModelLabel string
|
||||
// Progress receives live status strings for the UI, including a percentage
|
||||
// while downloading (e.g. "Engine 45% · 57/126 MB").
|
||||
Progress func(status string)
|
||||
// platformKey overrides runtime detection (tests).
|
||||
platformKey string
|
||||
// skipPinned disables the pinned-digest cross-check (tests, which use fixture
|
||||
// assets); the API-digest verification still runs.
|
||||
skipPinned bool
|
||||
}
|
||||
|
||||
// AutoDownloadSupported reports whether the current platform has a known engine
|
||||
// asset (so the F9 flow can offer the download). Termux/Android and unusual
|
||||
// arches install manually or use a cloud provider.
|
||||
func AutoDownloadSupported() bool {
|
||||
_, ok := engineAssetSuffix[platformKey()]
|
||||
return ok
|
||||
}
|
||||
|
||||
func platformKey() string { return runtime.GOOS + "-" + runtime.GOARCH }
|
||||
|
||||
// EnsureLocalEngine downloads (if absent) and verifies the sherpa-onnx engine and
|
||||
// the default model into DestRoot, returning the resolved paths. Idempotent: an
|
||||
// already-extracted, still-present engine/model is reused without re-downloading.
|
||||
// Every archive is SHA256-verified against the GitHub API digest before extraction.
|
||||
func EnsureLocalEngine(ctx context.Context, opts DownloadOptions) (EngineComponents, error) {
|
||||
if strings.TrimSpace(opts.DestRoot) == "" {
|
||||
return EngineComponents{}, fmt.Errorf("dictation download: DestRoot is required")
|
||||
}
|
||||
key := opts.platformKey
|
||||
if key == "" {
|
||||
key = platformKey()
|
||||
}
|
||||
suffix, ok := engineAssetSuffix[key]
|
||||
if !ok {
|
||||
return EngineComponents{}, &SetupError{
|
||||
Tool: "sherpa-onnx auto-download",
|
||||
Hint: fmt.Sprintf("no prebuilt engine is known for %s — install sherpa-onnx manually or use a cloud provider (see docs/dictation.md)", key),
|
||||
}
|
||||
}
|
||||
version := strings.TrimSpace(opts.EngineVersion)
|
||||
if version == "" {
|
||||
version = DefaultSherpaVersion
|
||||
}
|
||||
progress := opts.Progress
|
||||
if progress == nil {
|
||||
progress = func(string) {}
|
||||
}
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
apiBase := opts.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = defaultAPIBase
|
||||
}
|
||||
|
||||
engineDir := filepath.Join(opts.DestRoot, "engine-"+version+"-"+key)
|
||||
targetWindows := strings.HasPrefix(key, "windows-")
|
||||
// Resolve through the tarball's flattened subdir so an ALREADY-extracted
|
||||
// engine is found and not needlessly re-downloaded (the idempotency check).
|
||||
binPath, serverPath := resolveEnginePaths(engineDir, targetWindows)
|
||||
if !fileExists(binPath) {
|
||||
pinned := ""
|
||||
if version == DefaultSherpaVersion && !opts.skipPinned {
|
||||
pinned = pinnedEngineDigest[key]
|
||||
}
|
||||
asset, err := resolveAsset(ctx, client, apiBase, version, "sherpa-onnx-", suffix)
|
||||
if err != nil {
|
||||
return EngineComponents{}, err
|
||||
}
|
||||
if err := downloadVerifyExtract(ctx, client, asset, pinned, false, "Engine", engineDir, progress); err != nil {
|
||||
return EngineComponents{}, err
|
||||
}
|
||||
binPath, serverPath = resolveEnginePaths(engineDir, targetWindows)
|
||||
}
|
||||
if !fileExists(binPath) {
|
||||
return EngineComponents{}, fmt.Errorf("dictation download: engine binary not found after extraction in %s", engineDir)
|
||||
}
|
||||
|
||||
modelName := opts.ModelAssetName
|
||||
if modelName == "" {
|
||||
modelName = modelAssetName
|
||||
}
|
||||
modelDirName := opts.ModelDirName
|
||||
if modelDirName == "" {
|
||||
modelDirName = "model-moonshine-tiny-en-int8"
|
||||
}
|
||||
modelDir := filepath.Join(opts.DestRoot, modelDirName)
|
||||
if !dirHasModel(modelDir) {
|
||||
asset, err := resolveAsset(ctx, client, apiBase, modelReleaseTag, modelName, "")
|
||||
if err != nil {
|
||||
return EngineComponents{}, err
|
||||
}
|
||||
modelPinned := opts.ModelPinnedDigest
|
||||
// Only fall back to the built-in digest for the DEFAULT model — a different
|
||||
// (browse-listed) model must never be checked against the default's digest.
|
||||
if modelPinned == "" && opts.ModelAssetName == "" {
|
||||
modelPinned = pinnedModelDigest
|
||||
}
|
||||
if opts.skipPinned {
|
||||
modelPinned = ""
|
||||
}
|
||||
modelLabel := opts.ModelLabel
|
||||
if modelLabel == "" {
|
||||
modelLabel = "Model"
|
||||
}
|
||||
// Models are data files from the official release: verify against a digest
|
||||
// when we have one (curated/pinned or API-provided), else allow a TLS-only
|
||||
// download (the model release predates GitHub's per-asset digests). The
|
||||
// engine BINARY above never allows this — a native executable is always
|
||||
// digest-verified.
|
||||
if err := downloadVerifyExtract(ctx, client, asset, modelPinned, true, modelLabel, modelDir, progress); err != nil {
|
||||
return EngineComponents{}, err
|
||||
}
|
||||
}
|
||||
resolvedModel := modelDir
|
||||
if !hasTokensFile(resolvedModel) {
|
||||
if child, err := flattenSingleChild(modelDir); err == nil {
|
||||
resolvedModel = child
|
||||
}
|
||||
}
|
||||
if !hasTokensFile(resolvedModel) {
|
||||
return EngineComponents{}, fmt.Errorf("dictation download: model files not found after extraction in %s", modelDir)
|
||||
}
|
||||
return EngineComponents{BinaryPath: binPath, ServerPath: serverPath, ModelPath: resolvedModel}, nil
|
||||
}
|
||||
|
||||
type resolvedAsset struct {
|
||||
url string
|
||||
sha256 string
|
||||
size int64
|
||||
name string
|
||||
}
|
||||
|
||||
// resolveAsset queries the GitHub release for tag and returns the asset whose
|
||||
// name has the given prefix and suffix, plus its SHA256 digest and size.
|
||||
func resolveAsset(ctx context.Context, client *http.Client, apiBase, tag, namePrefix, nameSuffix string) (resolvedAsset, error) {
|
||||
tagPath := "tags/" + tag
|
||||
if tag == "" || tag == "latest" {
|
||||
tagPath = "latest"
|
||||
}
|
||||
url := fmt.Sprintf("%s/repos/%s/releases/%s", strings.TrimRight(apiBase, "/"), sherpaRepo, tagPath)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return resolvedAsset{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return resolvedAsset{}, fmt.Errorf("resolving sherpa-onnx release %s: %w", tag, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resolvedAsset{}, fmt.Errorf("resolving sherpa-onnx release %s: GitHub API HTTP %d", tag, resp.StatusCode)
|
||||
}
|
||||
var release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
DownloadURL string `json:"browser_download_url"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return resolvedAsset{}, fmt.Errorf("parsing sherpa-onnx release %s: %w", tag, err)
|
||||
}
|
||||
for _, a := range release.Assets {
|
||||
if !strings.HasPrefix(a.Name, namePrefix) || !strings.HasSuffix(a.Name, nameSuffix) {
|
||||
continue
|
||||
}
|
||||
// The API digest may be empty for older assets (GitHub only populates it for
|
||||
// assets uploaded after ~2024 — the ASR models predate that). An empty
|
||||
// digest is fine here; downloadVerifyExtract requires the API digest OR a
|
||||
// pinned one and refuses only when neither exists.
|
||||
digest := strings.TrimPrefix(a.Digest, "sha256:")
|
||||
return resolvedAsset{url: a.DownloadURL, sha256: digest, size: a.Size, name: a.Name}, nil
|
||||
}
|
||||
return resolvedAsset{}, &SetupError{
|
||||
Tool: "sherpa-onnx auto-download",
|
||||
Hint: fmt.Sprintf("release %s has no asset matching %s*%s — try a different stt.engineVersion or install manually", tag, namePrefix, nameSuffix),
|
||||
}
|
||||
}
|
||||
|
||||
// downloadVerifyExtract fetches the asset to a temp file, verifies its SHA256
|
||||
// (against the API digest, and — when pinned is set — a cross-check that the
|
||||
// resolved digest equals the audited value), and extracts the tar.bz2. A
|
||||
// mismatch aborts before anything is extracted or run.
|
||||
func downloadVerifyExtract(ctx context.Context, client *http.Client, asset resolvedAsset, pinned string, allowUnverified bool, label, destDir string, progress func(string)) error {
|
||||
// Determine the digest to verify against: the API digest and the pinned digest
|
||||
// must agree when both exist; at least one must exist unless allowUnverified
|
||||
// (models are data files from the official release — TLS-only is acceptable;
|
||||
// a native executable never sets allowUnverified).
|
||||
expected := asset.sha256
|
||||
if pinned != "" {
|
||||
if expected != "" && !strings.EqualFold(expected, pinned) {
|
||||
return fmt.Errorf("dictation download: %s digest %s does not match the pinned known-good value %s — refusing (the release may have changed)", asset.name, asset.sha256, pinned)
|
||||
}
|
||||
expected = pinned
|
||||
}
|
||||
if expected == "" && !allowUnverified {
|
||||
return fmt.Errorf("dictation download: no SHA256 available for %s (neither the GitHub API nor a pinned value) — refusing an unverifiable download", asset.name)
|
||||
}
|
||||
// Ensure the parent of destDir exists; the final destDir is created by
|
||||
// the atomic rename at the end of the function.
|
||||
if err := os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Stage the extraction into a sibling temp dir and only swap it into
|
||||
// destDir on success. An interrupted run that left a truncated engine
|
||||
// binary in destDir would otherwise pass fileExists() on the next start
|
||||
// and break the local recorder silently.
|
||||
stageDir, err := os.MkdirTemp(filepath.Dir(destDir), filepath.Base(destDir)+".stage-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanupStage := true
|
||||
defer func() {
|
||||
if cleanupStage {
|
||||
_ = os.RemoveAll(stageDir)
|
||||
}
|
||||
}()
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(destDir), "stt-dl-*.tar.bz2")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.url, nil)
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("downloading %s: %w", asset.name, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("downloading %s: HTTP %d", asset.name, resp.StatusCode)
|
||||
}
|
||||
hasher := sha256.New()
|
||||
total := asset.size
|
||||
if total <= 0 {
|
||||
total = resp.ContentLength
|
||||
}
|
||||
src := io.Reader(resp.Body)
|
||||
if total > 0 {
|
||||
src = &progressReader{r: resp.Body, total: total, label: label, report: progress, lastPct: -1}
|
||||
}
|
||||
if _, err := io.Copy(io.MultiWriter(tmp, hasher), src); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("downloading %s: %w", asset.name, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
got := hex.EncodeToString(hasher.Sum(nil))
|
||||
if expected != "" && !strings.EqualFold(got, expected) {
|
||||
return fmt.Errorf("dictation download: checksum mismatch for %s (want %s, got %s) — refusing to extract", asset.name, expected, got)
|
||||
}
|
||||
if err := extractTarBz2(tmpPath, stageDir, "Extracting "+label, progress); err != nil {
|
||||
return err
|
||||
}
|
||||
// Remove any pre-existing destDir so Rename is atomic on the same
|
||||
// filesystem (os.Rename refuses to overwrite a non-empty dir).
|
||||
if err := os.RemoveAll(destDir); err != nil {
|
||||
return fmt.Errorf("clearing previous %s install: %w", label, err)
|
||||
}
|
||||
if err := os.Rename(stageDir, destDir); err != nil {
|
||||
return fmt.Errorf("promoting staged %s: %w", label, err)
|
||||
}
|
||||
cleanupStage = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// progressReader reports download progress as a percentage on each whole-percent
|
||||
// change, formatted "<label> NN% · X/Y MB".
|
||||
type progressReader struct {
|
||||
r io.Reader
|
||||
total int64
|
||||
read int64
|
||||
label string
|
||||
report func(string)
|
||||
lastPct int
|
||||
}
|
||||
|
||||
func (p *progressReader) Read(b []byte) (int, error) {
|
||||
n, err := p.r.Read(b)
|
||||
p.read += int64(n)
|
||||
if p.total > 0 {
|
||||
pct := int(p.read * 100 / p.total)
|
||||
if pct != p.lastPct {
|
||||
p.lastPct = pct
|
||||
p.report(fmt.Sprintf("%s %d%% · %d/%d MB", p.label, pct, p.read>>20, p.total>>20))
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// enginePaths builds the engine executable paths. The ".exe" suffix follows the
|
||||
// TARGET platform (the one the engine was downloaded for), not the host — they
|
||||
// match in production (you download your own platform), but keying off the target
|
||||
// keeps resolution correct when the two differ (e.g. a cross-platform test).
|
||||
func enginePaths(engineDir string, targetWindows bool) (bin, server string) {
|
||||
ext := ""
|
||||
if targetWindows {
|
||||
ext = ".exe"
|
||||
}
|
||||
return filepath.Join(engineDir, "bin", "sherpa-onnx-offline"+ext),
|
||||
filepath.Join(engineDir, "bin", "sherpa-onnx-online-websocket-server"+ext)
|
||||
}
|
||||
|
||||
// resolveEnginePaths finds the executables, flattening the tarball's single
|
||||
// top-level directory when present.
|
||||
func resolveEnginePaths(engineDir string, targetWindows bool) (bin, server string) {
|
||||
bin, server = enginePaths(engineDir, targetWindows)
|
||||
if fileExists(bin) {
|
||||
return bin, server
|
||||
}
|
||||
if child, err := flattenSingleChild(engineDir); err == nil && child != engineDir {
|
||||
return enginePaths(child, targetWindows)
|
||||
}
|
||||
return bin, server
|
||||
}
|
||||
|
||||
// extractTarBz2 unpacks a bzip2-compressed tar into destDir, guarding against
|
||||
// path-traversal entries. It reports extraction progress by how much of the
|
||||
// (compressed) archive it has consumed — the same MB scale as the download, so
|
||||
// "Extracting … 50% · 520/1041 MB" lines up with the download that preceded it.
|
||||
func extractTarBz2(archivePath, destDir, label string, progress func(string)) error {
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
var src io.Reader = f
|
||||
if progress != nil {
|
||||
if info, statErr := f.Stat(); statErr == nil && info.Size() > 0 {
|
||||
src = &progressReader{r: f, total: info.Size(), label: label, report: progress, lastPct: -1}
|
||||
}
|
||||
}
|
||||
tr := tar.NewReader(bzip2.NewReader(src))
|
||||
destAbs, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(destDir, hdr.Name)
|
||||
absTarget, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if absTarget != destAbs && !strings.HasPrefix(absTarget, destAbs+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("dictation download: archive entry %q escapes destination", hdr.Name)
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)&0o777)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flattenSingleChild returns the sole subdirectory of dir (the tarball's
|
||||
// top-level folder) when dir contains exactly one directory entry.
|
||||
func flattenSingleChild(dir string) (string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var dirs []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
dirs = append(dirs, e.Name())
|
||||
}
|
||||
}
|
||||
if len(dirs) == 1 {
|
||||
return filepath.Join(dir, dirs[0]), nil
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// ModelDownloaded reports whether a model variant is already extracted under
|
||||
// destRoot (so the picker can mark it as installed). dirName is the variant's
|
||||
// DirName.
|
||||
func ModelDownloaded(destRoot, dirName string) bool {
|
||||
if destRoot == "" || dirName == "" {
|
||||
return false
|
||||
}
|
||||
return dirHasModel(filepath.Join(destRoot, dirName))
|
||||
}
|
||||
|
||||
// EngineDownloaded reports whether the shared sherpa-onnx engine is already on
|
||||
// disk for this platform (one engine serves every model), so the picker can drop
|
||||
// the engine's size from a model's download total once it's been fetched.
|
||||
func EngineDownloaded(destRoot, version string) bool {
|
||||
if destRoot == "" {
|
||||
return false
|
||||
}
|
||||
if version == "" {
|
||||
version = DefaultSherpaVersion
|
||||
}
|
||||
engineDir := filepath.Join(destRoot, "engine-"+version+"-"+platformKey())
|
||||
bin, _ := resolveEnginePaths(engineDir, runtime.GOOS == "windows")
|
||||
return fileExists(bin)
|
||||
}
|
||||
|
||||
func dirHasModel(dir string) bool {
|
||||
if hasTokensFile(dir) {
|
||||
return true
|
||||
}
|
||||
if child, err := flattenSingleChild(dir); err == nil && child != dir {
|
||||
return hasTokensFile(child)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasTokensFile reports whether dir holds a sherpa tokens file — usually
|
||||
// "tokens.txt", but some families (Whisper) prefix it as "<model>-tokens.txt".
|
||||
func hasTokensFile(dir string) bool {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), "tokens.txt") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEnsureLocalEngineRealDownload exercises the full auto-download against the
|
||||
// REAL k2-fsa/sherpa-onnx GitHub API (resolve → verify digest → extract → run).
|
||||
// It downloads ~130MB, so it is opt-in via ZERO_STT_DOWNLOAD_TEST=1.
|
||||
//
|
||||
// ZERO_STT_DOWNLOAD_TEST=1 go test ./internal/dictation/ -run RealDownload -v -timeout 10m
|
||||
func TestEnsureLocalEngineRealDownload(t *testing.T) {
|
||||
if os.Getenv("ZERO_STT_DOWNLOAD_TEST") == "" {
|
||||
t.Skip("set ZERO_STT_DOWNLOAD_TEST=1 to run the real ~130MB download smoke test")
|
||||
}
|
||||
dest := t.TempDir()
|
||||
comp, err := EnsureLocalEngine(context.Background(), DownloadOptions{
|
||||
DestRoot: dest,
|
||||
Progress: func(s string) { t.Logf("stage: %s", s) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureLocalEngine: %v", err)
|
||||
}
|
||||
if !fileExists(comp.BinaryPath) || !fileExists(filepath.Join(comp.ModelPath, "tokens.txt")) {
|
||||
t.Fatalf("missing components: %+v", comp)
|
||||
}
|
||||
|
||||
// The downloaded engine + model must actually transcribe.
|
||||
wav := filepath.Join(comp.ModelPath, "test_wavs", "0.wav")
|
||||
if !fileExists(wav) {
|
||||
t.Skipf("no bundled test wav at %s", wav)
|
||||
}
|
||||
audio, err := os.ReadFile(wav)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr, err := NewLocalTranscriber(LocalConfig{Binary: comp.BinaryPath, ModelPath: comp.ModelPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, err := tr.Transcribe(context.Background(), audio)
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe with downloaded engine: %v", err)
|
||||
}
|
||||
if text == "" {
|
||||
t.Fatal("downloaded engine returned empty transcript")
|
||||
}
|
||||
t.Logf("downloaded-engine transcript: %q", text)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Tiny tar.bz2 fixtures (generated in-repo): the engine has top/bin/sherpa-onnx-
|
||||
// offline + server; the model has top/tokens.txt.
|
||||
const (
|
||||
engineTarB64 = "QlpoOTFBWSZTWW+fEfAAAW1/hMGRAIBAA/+AAgAgRH9v3+AAAQCoMAEYAYA00NGjEZANABoYYA00NGjEZANABoYIpE0TRoTGkA2poGgxqepl1H6w5anW3tY2OggUi1AW/d29pO0GLUoOtnQsrGFNowIEh0cImtQ9gyHqYH7unm+/wZSLXsBuBDDCfHiahCsGEgGL9/pUG+RQZo76xlaDIGKWDErPKuyhXYOlzJMG3iNa56kiluctzUqgZ2A1AGwgcB3jtB5PB7pYYlDHH8OvOznM6B6MHPRqa7sTE1vwDBc0LjmG4ah5F+YYaXHTZpGmcemWW1yMh1adhdrB6A3nAQP8XckU4UJBvnxHwA=="
|
||||
engineSHA = "97b5cb6a417705860f8940e1bf9ffec8cc60e8666a0d228cd60f1de7607d503d"
|
||||
modelTarB64 = "QlpoOTFBWSZTWTtAoX4AANp/hMOQBIBAAf+AAAIQBHoJ3mAAAQAIMAC5sG1SNMhNqGmQwIyeKEagam1DQ0AAABFRT0T0mg0aeoDQHpH7Nzrs24wsbVW8hMQHClNI0LuLhKMGY4sEQi6N2qQWP1id8o1KtprGd4hll4uQkMGA1nFMXAwohEZOnIMIPQKQl2k0GbSGwkC0lJb2IWOQ57ETYbmxFGspaRp1QyT6q4sKj855st7GzOGj5eIwmWt6wRajEQl/F3JFOFCQO0Chfg=="
|
||||
modelSHA = "16c6b1dcb40f6b21c0de70bc1a8edab95375e4de90dac7c076fd2cc7a34fc59a"
|
||||
)
|
||||
|
||||
// fakeReleaseServer serves the GitHub release API + asset bytes for the engine
|
||||
// and model, exercising resolveAsset + downloadVerifyExtract against a real HTTP
|
||||
// round-trip (with the real coder/websocket-free download path).
|
||||
func fakeReleaseServer(t *testing.T, engineDigest, modelDigest string) *httptest.Server {
|
||||
t.Helper()
|
||||
engineBytes, _ := base64.StdEncoding.DecodeString(engineTarB64)
|
||||
modelBytes, _ := base64.StdEncoding.DecodeString(modelTarB64)
|
||||
mux := http.NewServeMux()
|
||||
base := ""
|
||||
release := func(assetName, digest, dlPath string) string {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"tag_name": "test",
|
||||
"assets": []map[string]any{{
|
||||
"name": assetName,
|
||||
"browser_download_url": base + dlPath,
|
||||
"digest": "sha256:" + digest,
|
||||
"size": 123,
|
||||
}},
|
||||
})
|
||||
return string(body)
|
||||
}
|
||||
var srv *httptest.Server
|
||||
// Any release tag under this prefix resolves: asr-models → the model, every
|
||||
// other tag → the engine (so the default-version pinned-check test works too).
|
||||
mux.HandleFunc("/repos/k2-fsa/sherpa-onnx/releases/tags/", func(w http.ResponseWriter, r *http.Request) {
|
||||
base = srv.URL
|
||||
if strings.HasSuffix(r.URL.Path, "/asr-models") {
|
||||
fmt.Fprint(w, release(modelAssetName, modelDigest, "/model.tar.bz2"))
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, release("sherpa-onnx-test-linux-x64-shared-no-tts.tar.bz2", engineDigest, "/engine.tar.bz2"))
|
||||
})
|
||||
mux.HandleFunc("/engine.tar.bz2", func(w http.ResponseWriter, r *http.Request) { w.Write(engineBytes) })
|
||||
mux.HandleFunc("/model.tar.bz2", func(w http.ResponseWriter, r *http.Request) { w.Write(modelBytes) })
|
||||
srv = httptest.NewServer(mux)
|
||||
base = srv.URL
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestResolveEnginePathsHonorsTargetPlatform(t *testing.T) {
|
||||
// The engine binary name follows the TARGET platform, not the host: a Windows
|
||||
// engine has ".exe", a Linux one doesn't. Resolution must find each regardless
|
||||
// of the OS the test runs on (this is what makes the cross-platform download
|
||||
// test pass on Windows CI).
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
target bool
|
||||
binName string
|
||||
}{
|
||||
{"linux target", false, "sherpa-onnx-offline"},
|
||||
{"windows target", true, "sherpa-onnx-offline.exe"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(binDir, tc.binName), []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin, _ := resolveEnginePaths(dir, tc.target)
|
||||
if !fileExists(bin) {
|
||||
t.Errorf("resolveEnginePaths(target-windows=%v) = %q; not found", tc.target, bin)
|
||||
}
|
||||
if !strings.HasSuffix(bin, tc.binName) {
|
||||
t.Errorf("bin = %q, want suffix %q", bin, tc.binName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLocalEngineDownloadsAndExtracts(t *testing.T) {
|
||||
srv := fakeReleaseServer(t, engineSHA, modelSHA)
|
||||
dest := t.TempDir()
|
||||
var stages []string
|
||||
comp, err := EnsureLocalEngine(context.Background(), DownloadOptions{
|
||||
DestRoot: dest,
|
||||
EngineVersion: "test",
|
||||
APIBase: srv.URL,
|
||||
platformKey: "linux-amd64",
|
||||
skipPinned: true,
|
||||
Progress: func(s string) { stages = append(stages, s) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureLocalEngine: %v", err)
|
||||
}
|
||||
if !fileExists(comp.BinaryPath) || !strings.HasSuffix(comp.BinaryPath, "sherpa-onnx-offline") {
|
||||
t.Errorf("binary path wrong: %q", comp.BinaryPath)
|
||||
}
|
||||
if !fileExists(comp.ServerPath) {
|
||||
t.Errorf("server path missing: %q", comp.ServerPath)
|
||||
}
|
||||
if !fileExists(filepath.Join(comp.ModelPath, "tokens.txt")) {
|
||||
t.Errorf("model tokens.txt missing under %q", comp.ModelPath)
|
||||
}
|
||||
// The extracted binary keeps its exec bit — Unix only; Windows has no
|
||||
// executable permission bit, so os.FileMode never reports 0o100 there.
|
||||
if runtime.GOOS != "windows" {
|
||||
if info, err := os.Stat(comp.BinaryPath); err == nil && info.Mode()&0o100 == 0 {
|
||||
t.Error("engine binary should be executable")
|
||||
}
|
||||
}
|
||||
if len(stages) == 0 {
|
||||
t.Error("expected progress stages")
|
||||
}
|
||||
|
||||
// Idempotent: a second call reuses the extracted engine + model and downloads
|
||||
// NOTHING (no progress stages) — the fix for re-downloading an already-present
|
||||
// engine whose binary lives in the tarball's flattened subdir.
|
||||
var stages2 []string
|
||||
comp2, err := EnsureLocalEngine(context.Background(), DownloadOptions{
|
||||
DestRoot: dest, EngineVersion: "test", APIBase: srv.URL, platformKey: "linux-amd64", skipPinned: true,
|
||||
Progress: func(s string) { stages2 = append(stages2, s) },
|
||||
})
|
||||
if err != nil || comp2.BinaryPath != comp.BinaryPath {
|
||||
t.Errorf("second call should reuse: %v / %q vs %q", err, comp2.BinaryPath, comp.BinaryPath)
|
||||
}
|
||||
if len(stages2) != 0 {
|
||||
t.Errorf("a fully-cached second call must not download/extract anything, got stages: %v", stages2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLocalEngineRejectsChecksumMismatch(t *testing.T) {
|
||||
// Serve the engine with a wrong digest → verification must refuse to extract.
|
||||
srv := fakeReleaseServer(t, strings.Repeat("0", 64), modelSHA)
|
||||
_, err := EnsureLocalEngine(context.Background(), DownloadOptions{
|
||||
DestRoot: t.TempDir(), EngineVersion: "test", APIBase: srv.URL, platformKey: "linux-amd64", skipPinned: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
|
||||
t.Fatalf("expected checksum-mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLocalEnginePinnedCrossCheckRefusesChangedRelease(t *testing.T) {
|
||||
// Default version + a digest that doesn't match the pinned value → refuse,
|
||||
// even though the download itself would verify against the API digest.
|
||||
srv := fakeReleaseServer(t, engineSHA, modelSHA)
|
||||
_, err := EnsureLocalEngine(context.Background(), DownloadOptions{
|
||||
DestRoot: t.TempDir(),
|
||||
EngineVersion: DefaultSherpaVersion, // triggers the pinned cross-check
|
||||
APIBase: srv.URL,
|
||||
platformKey: "linux-amd64",
|
||||
// skipPinned false: the pinned digest for linux-amd64 != engineSHA fixture.
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "pinned known-good") {
|
||||
t.Fatalf("expected pinned cross-check refusal, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAssetMissingDigestAllowed(t *testing.T) {
|
||||
// An older asset with no API digest resolves fine (verification against a
|
||||
// pinned value happens later); resolveAsset no longer refuses here.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"assets": []map[string]any{{"name": "x-linux-x64-shared-no-tts.tar.bz2", "browser_download_url": "http://x", "digest": ""}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
asset, err := resolveAsset(context.Background(), http.DefaultClient, srv.URL, "test", "x-", "linux-x64-shared-no-tts.tar.bz2")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAsset should tolerate a missing digest, got %v", err)
|
||||
}
|
||||
if asset.sha256 != "" {
|
||||
t.Errorf("expected empty digest, got %q", asset.sha256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnverifiableDownloadRefused(t *testing.T) {
|
||||
// No API digest AND no pinned digest → refuse to download/extract.
|
||||
srv := fakeReleaseServer(t, "", "") // both assets served with empty digest
|
||||
_, err := EnsureLocalEngine(context.Background(), DownloadOptions{
|
||||
DestRoot: t.TempDir(), EngineVersion: "test", APIBase: srv.URL, platformKey: "linux-amd64", skipPinned: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "unverifiable") {
|
||||
t.Fatalf("expected refusal of an unverifiable download, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReaderReportsPercent(t *testing.T) {
|
||||
data := make([]byte, 1000)
|
||||
var last string
|
||||
pr := &progressReader{r: bytesReader(data), total: 1000, label: "Engine", report: func(s string) { last = s }, lastPct: -1}
|
||||
buf := make([]byte, 100)
|
||||
for {
|
||||
if _, err := pr.Read(buf); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(last, "Engine") || !strings.Contains(last, "100%") {
|
||||
t.Errorf("expected a final 'Engine 100%%' report, got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func bytesReader(b []byte) *sliceReader { return &sliceReader{b: b} }
|
||||
|
||||
type sliceReader struct {
|
||||
b []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func (s *sliceReader) Read(p []byte) (int, error) {
|
||||
if s.pos >= len(s.b) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, s.b[s.pos:])
|
||||
s.pos += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func TestUnsupportedPlatformSetupError(t *testing.T) {
|
||||
_, err := EnsureLocalEngine(context.Background(), DownloadOptions{DestRoot: t.TempDir(), platformKey: "plan9-mips"})
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError for unsupported platform, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListModelsFiltersAndFlags(t *testing.T) {
|
||||
assets := []map[string]any{
|
||||
{"name": "sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2", "size": 1 << 20, "digest": "sha256:abc"},
|
||||
{"name": "sherpa-onnx-whisper-tiny.en.tar.bz2", "size": 2 << 20},
|
||||
{"name": "sherpa-onnx-moonshine-tiny-en-int8.tar.bz2", "size": 3 << 20}, // curated but not flagged recommended
|
||||
{"name": "sherpa-onnx-streaming-zipformer-en-kroko-2025-08-06.tar.bz2", "size": 1 << 20}, // curated → recommended
|
||||
{"name": "sherpa-onnx-paraformer-zh-2024-03-09.tar.bz2", "size": 4 << 20},
|
||||
// Non-ASR tools that MUST be filtered out:
|
||||
{"name": "sherpa-onnx-vad-silero.tar.bz2", "size": 1},
|
||||
{"name": "vits-piper-en_US-amy.tar.bz2", "size": 1},
|
||||
{"name": "sherpa-onnx-punct-en.tar.bz2", "size": 1},
|
||||
{"name": "sherpa-onnx-kws-zipformer.tar.bz2", "size": 1},
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"id": 1, "assets": assets})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
vs, err := ListModels(context.Background(), http.DefaultClient, srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vs) != 5 {
|
||||
t.Fatalf("expected 5 transcription models (tools filtered), got %d", len(vs))
|
||||
}
|
||||
byID := map[string]ModelVariant{}
|
||||
for _, v := range vs {
|
||||
byID[v.ID] = v
|
||||
}
|
||||
if !byID["streaming-zipformer-en-2023-06-26"].Streaming {
|
||||
t.Error("streaming model should be flagged Streaming")
|
||||
}
|
||||
if byID["whisper-tiny.en"].Streaming {
|
||||
t.Error("whisper is batch, not streaming")
|
||||
}
|
||||
if !byID["streaming-zipformer-en-kroko-2025-08-06"].Recommended {
|
||||
t.Error("a curated model flagged Recommended should be Recommended")
|
||||
}
|
||||
if byID["moonshine-tiny-en-int8"].Recommended {
|
||||
t.Error("moonshine tiny is curated but no longer flagged Recommended (no star)")
|
||||
}
|
||||
if byID["paraformer-zh-2024-03-09"].Recommended {
|
||||
t.Error("an uncurated model should not be Recommended")
|
||||
}
|
||||
// A curated model with no API digest still gets its pinned digest.
|
||||
if byID["moonshine-tiny-en-int8"].Digest == "" {
|
||||
t.Error("curated model should carry a pinned digest")
|
||||
}
|
||||
// An uncurated model with no API digest has an empty digest (TLS-only download).
|
||||
if byID["whisper-tiny.en"].Digest != "" {
|
||||
t.Error("uncurated model without an API digest should have an empty digest")
|
||||
}
|
||||
// The API-provided digest is used when present.
|
||||
if byID["streaming-zipformer-en-2023-06-26"].Digest != "abc" {
|
||||
t.Errorf("expected the API digest, got %q", byID["streaming-zipformer-en-2023-06-26"].Digest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLocalTranscribeRealBinary exercises the full local batch path against a
|
||||
// REAL sherpa-onnx-offline binary and model — the manual smoke test §14 calls
|
||||
// out as not CI-able. Skipped unless both env vars point at a real install:
|
||||
//
|
||||
// ZERO_STT_TEST_BINARY=/path/to/sherpa-onnx-offline \
|
||||
// ZERO_STT_TEST_MODEL=/path/to/model-dir \
|
||||
// ZERO_STT_TEST_WAV=/path/to/16k-mono.wav \
|
||||
// go test ./internal/dictation/ -run TestLocalTranscribeRealBinary -v
|
||||
func TestLocalTranscribeRealBinary(t *testing.T) {
|
||||
binary := os.Getenv("ZERO_STT_TEST_BINARY")
|
||||
model := os.Getenv("ZERO_STT_TEST_MODEL")
|
||||
wav := os.Getenv("ZERO_STT_TEST_WAV")
|
||||
if binary == "" || model == "" || wav == "" {
|
||||
t.Skip("set ZERO_STT_TEST_BINARY, ZERO_STT_TEST_MODEL, ZERO_STT_TEST_WAV to run the real-engine smoke test")
|
||||
}
|
||||
audio, err := os.ReadFile(wav)
|
||||
if err != nil {
|
||||
t.Fatalf("read wav: %v", err)
|
||||
}
|
||||
tr, err := NewLocalTranscriber(LocalConfig{Binary: binary, ModelPath: model})
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalTranscriber: %v", err)
|
||||
}
|
||||
text, err := tr.Transcribe(context.Background(), audio)
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if text == "" {
|
||||
t.Fatal("real engine returned an empty transcript")
|
||||
}
|
||||
t.Logf("transcript: %q", text)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build !windows
|
||||
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// checkAudioDirOwner rejects a dictation temp directory not owned by the
|
||||
// current user — on a shared /tmp another user could have pre-created the
|
||||
// path and would then control its lifetime. Mirrors
|
||||
// internal/tools/spill_owner_unix.go.
|
||||
func checkAudioDirOwner(info os.FileInfo) error {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if int(stat.Uid) != os.Geteuid() {
|
||||
return fmt.Errorf("dictation temp directory is owned by uid %d, not the current user", stat.Uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build windows
|
||||
|
||||
package dictation
|
||||
|
||||
import "os"
|
||||
|
||||
// checkAudioDirOwner is a no-op on Windows: %TEMP% is per-user by default and
|
||||
// there is no portable uid to compare. The Lstat symlink/dir check in
|
||||
// audioSpillDir still applies. Mirrors internal/tools/spill_owner_windows.go.
|
||||
func checkAudioDirOwner(os.FileInfo) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Recorder captures microphone audio. Start/Stop is the batch path (used on
|
||||
// Termux, and as the fallback everywhere); StartStreaming is the desktop-only
|
||||
// continuous-PCM path.
|
||||
type Recorder interface {
|
||||
Start() error
|
||||
// Stop ends a batch recording and returns the recorded audio bytes (WAV on
|
||||
// desktop, M4A on Termux — SniffFormat distinguishes them).
|
||||
Stop() ([]byte, error)
|
||||
// StartStreaming begins continuous capture of raw little-endian PCM16 mono
|
||||
// at the configured sample rate. Chunks arrive on the channel in ~50ms
|
||||
// units; the channel closes when capture ends (stop called, max duration
|
||||
// hit, or trailing-silence auto-stop). stop is idempotent.
|
||||
StartStreaming() (chunks <-chan []byte, stop func() error, err error)
|
||||
}
|
||||
|
||||
// Platform selects the audio-capture toolchain (§9 of the design doc).
|
||||
type Platform string
|
||||
|
||||
const (
|
||||
PlatformLinux Platform = "linux"
|
||||
PlatformDarwin Platform = "darwin"
|
||||
PlatformWindows Platform = "windows"
|
||||
PlatformTermux Platform = "termux"
|
||||
PlatformUnsupported Platform = ""
|
||||
)
|
||||
|
||||
// DetectPlatform picks the capture toolchain for the current host. Termux is
|
||||
// detected by its environment (it reports GOOS=linux/android): TERMUX_VERSION
|
||||
// is exported to every Termux shell, and PREFIX carries the app package path.
|
||||
func DetectPlatform() Platform {
|
||||
if os.Getenv("TERMUX_VERSION") != "" || strings.Contains(os.Getenv("PREFIX"), "com.termux") {
|
||||
return PlatformTermux
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return PlatformLinux
|
||||
case "darwin":
|
||||
return PlatformDarwin
|
||||
case "windows":
|
||||
return PlatformWindows
|
||||
}
|
||||
return PlatformUnsupported
|
||||
}
|
||||
|
||||
// StreamingSupported reports whether the platform's capture tool can write raw
|
||||
// PCM to stdout. Termux's termux-microphone-record requires a regular output
|
||||
// file — a hard tool limitation, so Termux is batch-only permanently.
|
||||
func (p Platform) StreamingSupported() bool {
|
||||
switch p {
|
||||
case PlatformLinux, PlatformDarwin, PlatformWindows:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
// DefaultSampleRate is what the local engine and Deepgram consume.
|
||||
// OpenAI Realtime's pcm16 requires 24kHz; its transcriber constructs the
|
||||
// recorder accordingly.
|
||||
DefaultSampleRate = 16000
|
||||
// DefaultMaxDuration is the hard runaway-recording cap (§12), applied on
|
||||
// every platform, batch and streaming. Overridable via
|
||||
// stt.maxDurationSeconds.
|
||||
DefaultMaxDuration = 5 * time.Minute
|
||||
|
||||
// Trailing-silence auto-stop (§9): stop ~2s after the signal falls below a
|
||||
// 3% amplitude threshold — SoX's own `silence 1 0.1 3% 1 2.0 3%` numbers.
|
||||
silenceThresholdPct = 3
|
||||
silenceHold = 2 * time.Second
|
||||
|
||||
chunkInterval = 50 * time.Millisecond
|
||||
stopGrace = 3 * time.Second
|
||||
)
|
||||
|
||||
// RecorderOptions configures a Recorder. Zero values pick sensible defaults.
|
||||
type RecorderOptions struct {
|
||||
Platform Platform
|
||||
SampleRate int
|
||||
MaxDuration time.Duration
|
||||
SilenceAutoStop bool
|
||||
// WindowsAudioDevice is the dshow capture device name; auto-detected from
|
||||
// `ffmpeg -list_devices` when empty.
|
||||
WindowsAudioDevice string
|
||||
|
||||
// Test seams (unexported): fake the OS boundary, mirroring how
|
||||
// internal/lsp/manager_test.go stubs serverStarter.
|
||||
starter processStarter
|
||||
runOutput commandOutputRunner
|
||||
tempDir func() (string, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (o RecorderOptions) withDefaults() RecorderOptions {
|
||||
if o.Platform == PlatformUnsupported {
|
||||
o.Platform = DetectPlatform()
|
||||
}
|
||||
if o.SampleRate <= 0 {
|
||||
o.SampleRate = DefaultSampleRate
|
||||
}
|
||||
if o.MaxDuration <= 0 {
|
||||
o.MaxDuration = DefaultMaxDuration
|
||||
}
|
||||
if o.starter == nil {
|
||||
o.starter = startProcess
|
||||
}
|
||||
if o.runOutput == nil {
|
||||
o.runOutput = runCommandOutput
|
||||
}
|
||||
if o.tempDir == nil {
|
||||
o.tempDir = audioSpillDir
|
||||
}
|
||||
if o.now == nil {
|
||||
o.now = time.Now
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// NewRecorder builds the platform recorder. Construction is cheap and
|
||||
// side-effect free; tools are only exec'd on Start/StartStreaming.
|
||||
func NewRecorder(opts RecorderOptions) Recorder {
|
||||
o := opts.withDefaults()
|
||||
return &recorder{opts: o}
|
||||
}
|
||||
|
||||
type recorder struct {
|
||||
opts RecorderOptions
|
||||
|
||||
mu sync.Mutex
|
||||
proc processHandle
|
||||
filePath string
|
||||
recording bool
|
||||
}
|
||||
|
||||
var errAlreadyRecording = errors.New("a recording is already in progress")
|
||||
|
||||
// Start begins a batch (record-to-file) capture.
|
||||
func (r *recorder) Start() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.recording {
|
||||
return errAlreadyRecording
|
||||
}
|
||||
if r.opts.Platform == PlatformUnsupported {
|
||||
return fmt.Errorf("dictation is not supported on %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
dir, err := r.opts.tempDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("preparing dictation temp dir: %w", err)
|
||||
}
|
||||
ext := ".wav"
|
||||
if r.opts.Platform == PlatformTermux {
|
||||
ext = ".m4a"
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "rec-*"+ext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating dictation temp file: %w", err)
|
||||
}
|
||||
path := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
|
||||
if r.opts.Platform == PlatformTermux {
|
||||
// termux-microphone-record starts the recording in the Termux:API
|
||||
// companion app and exits immediately; there is no long-lived process
|
||||
// to hold. Stop is a separate `-q` invocation.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
out, err := r.opts.runOutput(ctx, "termux-microphone-record",
|
||||
"-l", strconv.Itoa(maxSeconds(r.opts.MaxDuration)), "-f", path)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return startError("termux-microphone-record", r.opts.Platform, err, out)
|
||||
}
|
||||
r.filePath = path
|
||||
r.recording = true
|
||||
return nil
|
||||
}
|
||||
|
||||
spec, err := batchCommand(r.opts, path)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
}
|
||||
proc, _, err := r.opts.starter(spec)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return startError(spec.name, r.opts.Platform, err, nil)
|
||||
}
|
||||
r.proc = proc
|
||||
r.filePath = path
|
||||
r.recording = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop ends a batch capture, reads the recorded file, deletes it, and returns
|
||||
// the audio bytes.
|
||||
func (r *recorder) Stop() ([]byte, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.recording {
|
||||
return nil, errors.New("no recording in progress")
|
||||
}
|
||||
proc, path := r.proc, r.filePath
|
||||
// Termux: Start's termux-microphone-record branch leaves r.proc nil because
|
||||
// there's no long-lived process to hold (Stop is a separate `-q` invocation
|
||||
// via runOutput). Treat that as legitimate; only reject a nil proc that
|
||||
// came from StartStreaming (which owns its process via the returned
|
||||
// closure).
|
||||
if proc == nil && r.opts.Platform != PlatformTermux {
|
||||
return nil, errors.New("recording was started with StartStreaming; call the stop closure returned from StartStreaming instead of Stop()")
|
||||
}
|
||||
r.proc, r.filePath, r.recording = nil, "", false
|
||||
defer os.Remove(path)
|
||||
|
||||
if r.opts.Platform == PlatformTermux {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if out, err := r.opts.runOutput(ctx, "termux-microphone-record", "-q"); err != nil {
|
||||
return nil, fmt.Errorf("stopping termux recording: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return readRecordingWithRetry(path, r.opts.now)
|
||||
}
|
||||
|
||||
if err := proc.StopGracefully(); err != nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
// A signal-terminated capture tool reports a non-zero exit — expected, not
|
||||
// an error. The recorded file's contents are the real success signal.
|
||||
_ = waitWithTimeout(proc, stopGrace)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading recorded audio: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("no audio was captured (is the microphone available?)")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// readRecordingWithRetry reads the Termux recording, retrying briefly: the
|
||||
// companion app finalizes the file asynchronously after `-q` returns.
|
||||
func readRecordingWithRetry(path string, now func() time.Time) ([]byte, error) {
|
||||
deadline := now().Add(2 * time.Second)
|
||||
for {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil && len(data) > 0 {
|
||||
return data, nil
|
||||
}
|
||||
if now().After(deadline) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading recorded audio: %w", err)
|
||||
}
|
||||
return nil, errors.New("no audio was captured (check Termux:API app and microphone permission)")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// batchCommand builds the record-to-file argv for desktop platforms (§9).
|
||||
// The max-duration cap is enforced by the tool itself so a runaway recording
|
||||
// ends even if Zero never calls Stop.
|
||||
func batchCommand(opts RecorderOptions, path string) (commandSpec, error) {
|
||||
rate := strconv.Itoa(opts.SampleRate)
|
||||
maxSec := strconv.Itoa(maxSeconds(opts.MaxDuration))
|
||||
switch opts.Platform {
|
||||
case PlatformLinux:
|
||||
return commandSpec{
|
||||
name: "arecord",
|
||||
args: []string{"-q", "-f", "S16_LE", "-r", rate, "-c", "1", "-t", "wav", "-d", maxSec, path},
|
||||
}, nil
|
||||
case PlatformDarwin:
|
||||
args := []string{"-q", "-d", "-r", rate, "-c", "1", "-b", "16", "-e", "signed-integer", path, "trim", "0", maxSec}
|
||||
if opts.SilenceAutoStop {
|
||||
// SoX's built-in trailing-silence auto-stop: end ~2s after the
|
||||
// signal falls below 3% amplitude. A backstop for a forgotten
|
||||
// manual stop, not VAD — recording still starts on an explicit key.
|
||||
args = append(args, "silence", "1", "0.1", "3%", "1", "2.0", "3%")
|
||||
}
|
||||
return commandSpec{name: "sox", args: args}, nil
|
||||
case PlatformWindows:
|
||||
device := opts.WindowsAudioDevice
|
||||
if device == "" {
|
||||
detected, err := detectWindowsAudioDevice(opts.runOutput)
|
||||
if err != nil {
|
||||
return commandSpec{}, err
|
||||
}
|
||||
device = detected
|
||||
}
|
||||
return commandSpec{
|
||||
name: "ffmpeg",
|
||||
args: []string{"-hide_banner", "-loglevel", "error", "-f", "dshow", "-i", "audio=" + device,
|
||||
"-ac", "1", "-ar", rate, "-c:a", "pcm_s16le", "-t", maxSec, "-y", path},
|
||||
stopViaStdin: "q",
|
||||
}, nil
|
||||
}
|
||||
return commandSpec{}, fmt.Errorf("no batch capture tool for platform %q", opts.Platform)
|
||||
}
|
||||
|
||||
// streamCommand builds the raw-PCM-to-stdout argv for desktop platforms (§9).
|
||||
// Same tools as batch, invoked with their stdout flag — Termux has no stdout
|
||||
// mode, which is why it is batch-only.
|
||||
func streamCommand(opts RecorderOptions) (commandSpec, error) {
|
||||
rate := strconv.Itoa(opts.SampleRate)
|
||||
switch opts.Platform {
|
||||
case PlatformLinux:
|
||||
return commandSpec{
|
||||
name: "arecord",
|
||||
args: []string{"-q", "-t", "raw", "-f", "S16_LE", "-r", rate, "-c", "1", "-"},
|
||||
wantStdout: true,
|
||||
}, nil
|
||||
case PlatformDarwin:
|
||||
return commandSpec{
|
||||
name: "sox",
|
||||
args: []string{"-q", "-d", "-t", "raw", "-r", rate, "-e", "signed", "-b", "16", "-c", "1", "-"},
|
||||
wantStdout: true,
|
||||
}, nil
|
||||
case PlatformWindows:
|
||||
device := opts.WindowsAudioDevice
|
||||
if device == "" {
|
||||
detected, err := detectWindowsAudioDevice(opts.runOutput)
|
||||
if err != nil {
|
||||
return commandSpec{}, err
|
||||
}
|
||||
device = detected
|
||||
}
|
||||
return commandSpec{
|
||||
name: "ffmpeg",
|
||||
args: []string{"-hide_banner", "-loglevel", "error", "-f", "dshow", "-i", "audio=" + device,
|
||||
"-ac", "1", "-ar", rate, "-f", "s16le", "-"},
|
||||
wantStdout: true,
|
||||
stopViaStdin: "q",
|
||||
}, nil
|
||||
}
|
||||
return commandSpec{}, fmt.Errorf("streaming capture is not supported on platform %q", opts.Platform)
|
||||
}
|
||||
|
||||
// StartStreaming begins continuous PCM capture with the §12 safety nets: a
|
||||
// hard max-duration cap and Go-side trailing-silence auto-stop (the streaming
|
||||
// analog of SoX's silence effect — we see the samples, so we measure them).
|
||||
func (r *recorder) StartStreaming() (<-chan []byte, func() error, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.recording {
|
||||
return nil, nil, errAlreadyRecording
|
||||
}
|
||||
spec, err := streamCommand(r.opts)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
proc, stdout, err := r.opts.starter(spec)
|
||||
if err != nil {
|
||||
return nil, nil, startError(spec.name, r.opts.Platform, err, nil)
|
||||
}
|
||||
if stdout == nil {
|
||||
_ = proc.Kill()
|
||||
return nil, nil, errors.New("capture process has no stdout pipe")
|
||||
}
|
||||
r.recording = true
|
||||
|
||||
chunkBytes := chunkSizeBytes(r.opts.SampleRate)
|
||||
chunks := make(chan []byte, 8)
|
||||
done := make(chan struct{})
|
||||
var stopOnce sync.Once
|
||||
stop := func() error {
|
||||
stopOnce.Do(func() {
|
||||
close(done)
|
||||
if err := proc.StopGracefully(); err != nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
_ = waitWithTimeout(proc, stopGrace)
|
||||
_ = stdout.Close()
|
||||
r.mu.Lock()
|
||||
r.recording = false
|
||||
r.mu.Unlock()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Max-duration cap: streaming has no tool-side -d/-t equivalent that also
|
||||
// flushes stdout cleanly everywhere, so the cap runs on our side of the pipe.
|
||||
capTimer := time.AfterFunc(r.opts.MaxDuration, func() { _ = stop() })
|
||||
|
||||
go func() {
|
||||
defer close(chunks)
|
||||
defer capTimer.Stop()
|
||||
// Tear down on ANY exit — including the capture process ending on its own
|
||||
// (EOF/crash), which the loop below returns on without going through an
|
||||
// explicit stop(). stop() is idempotent (sync.Once), so this is safe even
|
||||
// when the session was already stopped externally; it clears r.recording and
|
||||
// closes proc/stdout so a self-terminating device doesn't wedge the recorder.
|
||||
defer func() { _ = stop() }()
|
||||
threshold := int16(32767 * silenceThresholdPct / 100)
|
||||
silentChunks, speechSeen := 0, false
|
||||
silenceLimit := int(silenceHold / chunkInterval)
|
||||
for {
|
||||
buf := make([]byte, chunkBytes)
|
||||
n, err := readFullOrEOF(stdout, buf)
|
||||
if n > 0 {
|
||||
chunk := buf[:n]
|
||||
if r.opts.SilenceAutoStop {
|
||||
if chunkPeak(chunk) >= threshold {
|
||||
speechSeen = true
|
||||
silentChunks = 0
|
||||
} else if speechSeen {
|
||||
silentChunks++
|
||||
if silentChunks >= silenceLimit {
|
||||
go func() { _ = stop() }()
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case chunks <- chunk:
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return chunks, stop, nil
|
||||
}
|
||||
|
||||
// chunkSizeBytes is 50ms of PCM16 mono at the given rate — small chunks mean
|
||||
// faster time-to-first-partial (§6a).
|
||||
func chunkSizeBytes(sampleRate int) int {
|
||||
return sampleRate * 2 / int(time.Second/chunkInterval)
|
||||
}
|
||||
|
||||
// readFullOrEOF fills buf from r, returning what it read and any terminal
|
||||
// error. A short final read (pipe closing mid-chunk) still returns its bytes.
|
||||
func readFullOrEOF(r interface{ Read([]byte) (int, error) }, buf []byte) (int, error) {
|
||||
total := 0
|
||||
for total < len(buf) {
|
||||
n, err := r.Read(buf[total:])
|
||||
total += n
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// chunkPeak returns the loudest absolute sample in a little-endian PCM16 chunk.
|
||||
func chunkPeak(chunk []byte) int16 {
|
||||
var peak int16
|
||||
for i := 0; i+1 < len(chunk); i += 2 {
|
||||
s := int16(uint16(chunk[i]) | uint16(chunk[i+1])<<8)
|
||||
if s == -32768 {
|
||||
return 32767
|
||||
}
|
||||
if s < 0 {
|
||||
s = -s
|
||||
}
|
||||
if s > peak {
|
||||
peak = s
|
||||
}
|
||||
}
|
||||
return peak
|
||||
}
|
||||
|
||||
// levelFloorDB and levelCeilDB bound the RMS→meter mapping. Speech RMS is small
|
||||
// (roughly -45 dBFS quiet to -15 dBFS loud), so a LINEAR scale barely moves the
|
||||
// bars — a dB (logarithmic) scale, like real audio meters, is what makes normal
|
||||
// speech span the meter.
|
||||
const (
|
||||
levelFloorDB = -50.0 // ≈ near-silence → empty
|
||||
levelCeilDB = -15.0 // loud speech → full
|
||||
)
|
||||
|
||||
// ChunkLevel returns a perceptual 0..1 loudness for a little-endian PCM16 chunk,
|
||||
// driving the live recording waveform from the actual microphone input. It uses
|
||||
// an RMS→dBFS→linear mapping over [levelFloorDB, levelCeilDB].
|
||||
func ChunkLevel(chunk []byte) float64 {
|
||||
var sumSq float64
|
||||
n := 0
|
||||
for i := 0; i+1 < len(chunk); i += 2 {
|
||||
s := int16(uint16(chunk[i]) | uint16(chunk[i+1])<<8)
|
||||
f := float64(s) / 32768.0
|
||||
sumSq += f * f
|
||||
n++
|
||||
}
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
rms := math.Sqrt(sumSq / float64(n))
|
||||
if rms <= 0 {
|
||||
return 0
|
||||
}
|
||||
db := 20 * math.Log10(rms) // dBFS (negative)
|
||||
level := (db - levelFloorDB) / (levelCeilDB - levelFloorDB)
|
||||
if level < 0 {
|
||||
level = 0
|
||||
}
|
||||
if level > 1 {
|
||||
level = 1
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
func maxSeconds(d time.Duration) int {
|
||||
sec := int(d / time.Second)
|
||||
if sec < 1 {
|
||||
sec = 1
|
||||
}
|
||||
return sec
|
||||
}
|
||||
|
||||
// detectWindowsAudioDevice picks the first dshow audio capture device from
|
||||
// `ffmpeg -list_devices`. dshow has no "default" alias, so a concrete device
|
||||
// name is required; stt.windowsAudioDevice overrides the auto-pick.
|
||||
func detectWindowsAudioDevice(runOutput commandOutputRunner) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
out, _ := runOutput(ctx, "ffmpeg", "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy")
|
||||
// Listing always "fails" (dummy isn't a real input); the device table is
|
||||
// in the output regardless. Lines look like: [dshow @ ...] "Mic name" (audio)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if !strings.Contains(line, "(audio)") {
|
||||
continue
|
||||
}
|
||||
start := strings.Index(line, `"`)
|
||||
if start < 0 {
|
||||
continue
|
||||
}
|
||||
rest := line[start+1:]
|
||||
end := strings.Index(rest, `"`)
|
||||
if end <= 0 {
|
||||
continue
|
||||
}
|
||||
return rest[:end], nil
|
||||
}
|
||||
return "", &SetupError{
|
||||
Tool: "ffmpeg",
|
||||
Hint: "no dshow audio capture device found — set stt.windowsAudioDevice to your microphone's name (list with: ffmpeg -list_devices true -f dshow -i dummy)",
|
||||
}
|
||||
}
|
||||
|
||||
// startError maps a failed capture-tool launch to setup guidance: a missing
|
||||
// binary is an expected unconfigured state (matching internal/lsp's
|
||||
// missing-server philosophy), not an internal error.
|
||||
func startError(tool string, platform Platform, err error, output []byte) error {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return &SetupError{Tool: tool, Hint: installHint(platform)}
|
||||
}
|
||||
if len(output) > 0 {
|
||||
return fmt.Errorf("starting %s: %w (%s)", tool, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return fmt.Errorf("starting %s: %w", tool, err)
|
||||
}
|
||||
|
||||
func installHint(platform Platform) string {
|
||||
switch platform {
|
||||
case PlatformLinux:
|
||||
return "install alsa-utils to enable dictation (e.g. sudo dnf install alsa-utils / sudo apt install alsa-utils)"
|
||||
case PlatformDarwin:
|
||||
return "install sox to enable dictation (brew install sox)"
|
||||
case PlatformWindows:
|
||||
return "install ffmpeg and ensure it is on PATH to enable dictation"
|
||||
case PlatformTermux:
|
||||
return "run `pkg install termux-api` and install the Termux:API companion app to enable dictation"
|
||||
}
|
||||
return "dictation is not supported on this platform"
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeProcess is a processHandle whose stdout is driven from an in-memory
|
||||
// buffer, mirroring how internal/lsp/manager_test.go stubs its subprocess over
|
||||
// in-memory pipes rather than spawning a real one.
|
||||
type fakeProcess struct {
|
||||
stopped bool
|
||||
killed bool
|
||||
waitErr error
|
||||
stopErr error
|
||||
closeOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newFakeProcess() *fakeProcess { return &fakeProcess{done: make(chan struct{})} }
|
||||
|
||||
func (f *fakeProcess) StopGracefully() error {
|
||||
f.stopped = true
|
||||
f.closeOnce.Do(func() { close(f.done) })
|
||||
return f.stopErr
|
||||
}
|
||||
func (f *fakeProcess) Wait() error {
|
||||
<-f.done
|
||||
return f.waitErr
|
||||
}
|
||||
func (f *fakeProcess) Kill() error {
|
||||
f.killed = true
|
||||
f.closeOnce.Do(func() { close(f.done) })
|
||||
return nil
|
||||
}
|
||||
|
||||
// captured records the last spec a starter was asked to launch, so tests can
|
||||
// assert the exact discrete argv per platform.
|
||||
type captured struct {
|
||||
spec commandSpec
|
||||
}
|
||||
|
||||
// fileWritingStarter simulates a record-to-file batch tool: on Start it writes
|
||||
// fixture bytes to the output path (last positional arg for arecord/sox, or the
|
||||
// -y target for ffmpeg) and returns a process that "records" until stopped.
|
||||
func fileWritingStarter(t *testing.T, cap *captured, fixture []byte) processStarter {
|
||||
t.Helper()
|
||||
return func(spec commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
cap.spec = spec
|
||||
path := outputPath(spec)
|
||||
if path == "" {
|
||||
t.Fatalf("could not determine output path from argv: %v", spec.args)
|
||||
}
|
||||
if err := os.WriteFile(path, fixture, 0o600); err != nil {
|
||||
t.Fatalf("fixture write: %v", err)
|
||||
}
|
||||
return newFakeProcess(), nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func outputPath(spec commandSpec) string {
|
||||
for i, a := range spec.args {
|
||||
if a == "-y" && i+1 < len(spec.args) {
|
||||
return spec.args[i+1]
|
||||
}
|
||||
}
|
||||
// arecord/sox: the path is the last arg (sox appends trim/silence effects
|
||||
// after it, so scan from the front for the first existing-dir target).
|
||||
last := spec.args[len(spec.args)-1]
|
||||
if filepath.IsAbs(last) {
|
||||
return last
|
||||
}
|
||||
for _, a := range spec.args {
|
||||
if filepath.IsAbs(a) && filepath.Ext(a) != "" {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func wavFixture() []byte {
|
||||
// Minimal valid RIFF/WAVE header + a little data, enough for SniffFormat.
|
||||
var b bytes.Buffer
|
||||
b.WriteString("RIFF")
|
||||
binary.Write(&b, binary.LittleEndian, uint32(36))
|
||||
b.WriteString("WAVE")
|
||||
b.WriteString("fmt ")
|
||||
binary.Write(&b, binary.LittleEndian, uint32(16))
|
||||
b.Write(make([]byte, 16))
|
||||
b.WriteString("data")
|
||||
binary.Write(&b, binary.LittleEndian, uint32(4))
|
||||
b.Write([]byte{1, 2, 3, 4})
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func testTempDir(t *testing.T) func() (string, error) {
|
||||
dir := t.TempDir()
|
||||
return func() (string, error) { return dir, nil }
|
||||
}
|
||||
|
||||
func TestBatchRecordLinuxArgv(t *testing.T) {
|
||||
cap := &captured{}
|
||||
fixture := wavFixture()
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
starter: fileWritingStarter(t, cap, fixture),
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
got, err := r.Stop()
|
||||
if err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, fixture) {
|
||||
t.Fatalf("audio mismatch: got %d bytes", len(got))
|
||||
}
|
||||
if cap.spec.name != "arecord" {
|
||||
t.Errorf("tool = %q, want arecord", cap.spec.name)
|
||||
}
|
||||
assertArgs(t, cap.spec.args, "-f", "S16_LE")
|
||||
assertArgs(t, cap.spec.args, "-r", "16000")
|
||||
assertArgs(t, cap.spec.args, "-c", "1")
|
||||
}
|
||||
|
||||
func TestBatchRecordDarwinArgv(t *testing.T) {
|
||||
cap := &captured{}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformDarwin,
|
||||
SilenceAutoStop: true,
|
||||
starter: fileWritingStarter(t, cap, wavFixture()),
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if _, err := r.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if cap.spec.name != "sox" {
|
||||
t.Errorf("tool = %q, want sox", cap.spec.name)
|
||||
}
|
||||
if !containsSubseq(cap.spec.args, []string{"silence", "1", "0.1", "3%", "1", "2.0", "3%"}) {
|
||||
t.Errorf("expected SoX silence auto-stop effect in argv: %v", cap.spec.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchRecordWindowsArgv(t *testing.T) {
|
||||
cap := &captured{}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformWindows,
|
||||
WindowsAudioDevice: "Microphone (Realtek)",
|
||||
starter: fileWritingStarter(t, cap, wavFixture()),
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if _, err := r.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if cap.spec.name != "ffmpeg" {
|
||||
t.Errorf("tool = %q, want ffmpeg", cap.spec.name)
|
||||
}
|
||||
assertArgs(t, cap.spec.args, "-i", "audio=Microphone (Realtek)")
|
||||
if cap.spec.stopViaStdin != "q" {
|
||||
t.Errorf("ffmpeg should stop via stdin 'q', got %q", cap.spec.stopViaStdin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchRecordTermuxUsesCommandRunner(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var calls [][]string
|
||||
runOutput := func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
calls = append(calls, append([]string{name}, args...))
|
||||
if len(args) > 0 && args[0] == "-l" {
|
||||
// The -f target is the last arg; write the fixture there.
|
||||
path := args[len(args)-1]
|
||||
_ = os.WriteFile(path, m4aFixture(), 0o600)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformTermux,
|
||||
starter: func(commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
t.Fatal("termux must not use the process starter")
|
||||
return nil, nil, nil
|
||||
},
|
||||
runOutput: runOutput,
|
||||
tempDir: func() (string, error) { return dir, nil },
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
got, err := r.Stop()
|
||||
if err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if SniffFormat(got) != FormatM4A {
|
||||
t.Errorf("expected m4a audio, got format %q", SniffFormat(got))
|
||||
}
|
||||
if len(calls) != 2 || calls[0][0] != "termux-microphone-record" || calls[1][len(calls[1])-1] != "-q" {
|
||||
t.Errorf("unexpected termux calls: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartMissingBinaryReturnsSetupError(t *testing.T) {
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
starter: func(commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
return nil, nil, exec.ErrNotFound
|
||||
},
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
err := r.Start()
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError for missing binary, got %v", err)
|
||||
}
|
||||
if setupErr.Tool != "arecord" {
|
||||
t.Errorf("SetupError.Tool = %q, want arecord", setupErr.Tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoubleStartRejected(t *testing.T) {
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
starter: fileWritingStarter(t, &captured{}, wavFixture()),
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if err := r.Start(); !errors.Is(err, errAlreadyRecording) {
|
||||
t.Errorf("second Start should be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingChunksAndStop(t *testing.T) {
|
||||
// 300ms of PCM16 @16kHz = 6 chunks of 50ms. Feed a fixed buffer, then EOF.
|
||||
pcm := make([]byte, chunkSizeBytes(16000)*6)
|
||||
starter := func(spec commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
if !spec.wantStdout {
|
||||
t.Fatal("streaming spec must request stdout")
|
||||
}
|
||||
return newFakeProcess(), io.NopCloser(bytes.NewReader(pcm)), nil
|
||||
}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
starter: starter,
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
chunks, stop, err := r.StartStreaming()
|
||||
if err != nil {
|
||||
t.Fatalf("StartStreaming: %v", err)
|
||||
}
|
||||
var total int
|
||||
for c := range chunks {
|
||||
total += len(c)
|
||||
}
|
||||
_ = stop()
|
||||
if total != len(pcm) {
|
||||
t.Errorf("streamed %d bytes, want %d", total, len(pcm))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingStopIdempotent(t *testing.T) {
|
||||
// A blocking reader so the capture goroutine parks until we stop it.
|
||||
pr, pw := io.Pipe()
|
||||
defer pw.Close()
|
||||
starter := func(commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
return newFakeProcess(), pr, nil
|
||||
}
|
||||
r := NewRecorder(RecorderOptions{Platform: PlatformLinux, starter: starter, tempDir: testTempDir(t)})
|
||||
chunks, stop, err := r.StartStreaming()
|
||||
if err != nil {
|
||||
t.Fatalf("StartStreaming: %v", err)
|
||||
}
|
||||
if err := stop(); err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if err := stop(); err != nil {
|
||||
t.Fatalf("second stop should be a no-op, got %v", err)
|
||||
}
|
||||
// Draining the channel must terminate now that we've stopped.
|
||||
drained := make(chan struct{})
|
||||
go func() {
|
||||
for range chunks {
|
||||
}
|
||||
close(drained)
|
||||
}()
|
||||
select {
|
||||
case <-drained:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("chunks channel did not close after stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMaxDurationCapInArgv(t *testing.T) {
|
||||
cap := &captured{}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
MaxDuration: 90 * time.Second,
|
||||
starter: fileWritingStarter(t, cap, wavFixture()),
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if _, err := r.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
// arecord's -d bounds a runaway recording even if Stop is never called.
|
||||
assertArgs(t, cap.spec.args, "-d", "90")
|
||||
}
|
||||
|
||||
func TestRecordedTempFileDeletedAfterStop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cap := &captured{}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
starter: fileWritingStarter(t, cap, wavFixture()),
|
||||
tempDir: func() (string, error) { return dir, nil },
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if _, err := r.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("recorded audio should be deleted after Stop; found %d files", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingSilenceAutoStop(t *testing.T) {
|
||||
// Speech (loud) then a run of silence longer than silenceHold must auto-stop.
|
||||
rate := 16000
|
||||
chunk := chunkSizeBytes(rate)
|
||||
loud := make([]byte, chunk)
|
||||
for i := 0; i+1 < len(loud); i += 2 {
|
||||
loud[i], loud[i+1] = 0x00, 0x40 // +16384, well above threshold
|
||||
}
|
||||
silence := make([]byte, chunk) // zeros
|
||||
var buf []byte
|
||||
buf = append(buf, loud...)
|
||||
silentChunks := int(silenceHold/chunkInterval) + 3
|
||||
for i := 0; i < silentChunks; i++ {
|
||||
buf = append(buf, silence...)
|
||||
}
|
||||
starter := func(commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
return newFakeProcess(), io.NopCloser(bytes.NewReader(buf)), nil
|
||||
}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
SampleRate: rate,
|
||||
SilenceAutoStop: true,
|
||||
starter: starter,
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
chunks, stop, err := r.StartStreaming()
|
||||
if err != nil {
|
||||
t.Fatalf("StartStreaming: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
// Draining must terminate on its own once silence auto-stop fires.
|
||||
drained := make(chan struct{})
|
||||
go func() {
|
||||
for range chunks {
|
||||
}
|
||||
close(drained)
|
||||
}()
|
||||
select {
|
||||
case <-drained:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("silence auto-stop did not end the stream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkPeak(t *testing.T) {
|
||||
loud := []byte{0x00, 0x40} // +16384
|
||||
if got := chunkPeak(loud); got != 16384 {
|
||||
t.Errorf("peak = %d, want 16384", got)
|
||||
}
|
||||
quiet := []byte{0x10, 0x00} // +16
|
||||
if got := chunkPeak(quiet); got != 16 {
|
||||
t.Errorf("peak = %d, want 16", got)
|
||||
}
|
||||
min := []byte{0x00, 0x80} // -32768 -> clamped to 32767
|
||||
if got := chunkPeak(min); got != 32767 {
|
||||
t.Errorf("peak = %d, want 32767 (clamp)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectWindowsAudioDevice(t *testing.T) {
|
||||
listing := `[dshow @ 0000] DirectShow audio devices
|
||||
[dshow @ 0000] "Microphone (USB Audio)" (audio)
|
||||
[dshow @ 0000] Alternative name "@device_cm_..."`
|
||||
runOutput := func(context.Context, string, ...string) ([]byte, error) { return []byte(listing), nil }
|
||||
got, err := detectWindowsAudioDevice(runOutput)
|
||||
if err != nil {
|
||||
t.Fatalf("detect: %v", err)
|
||||
}
|
||||
if got != "Microphone (USB Audio)" {
|
||||
t.Errorf("device = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniffFormat(t *testing.T) {
|
||||
if SniffFormat(wavFixture()) != FormatWAV {
|
||||
t.Error("wav not detected")
|
||||
}
|
||||
if SniffFormat(m4aFixture()) != FormatM4A {
|
||||
t.Error("m4a not detected")
|
||||
}
|
||||
if SniffFormat([]byte("nope")) != FormatUnknown {
|
||||
t.Error("unknown not detected")
|
||||
}
|
||||
}
|
||||
|
||||
func m4aFixture() []byte {
|
||||
b := make([]byte, 16)
|
||||
copy(b[4:], []byte("ftyp"))
|
||||
copy(b[8:], []byte("M4A "))
|
||||
return b
|
||||
}
|
||||
|
||||
func assertArgs(t *testing.T, args []string, want ...string) {
|
||||
t.Helper()
|
||||
if !containsSubseq(args, want) {
|
||||
t.Errorf("argv %v missing subsequence %v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func containsSubseq(hay, needle []string) bool {
|
||||
if len(needle) == 0 {
|
||||
return true
|
||||
}
|
||||
for i := 0; i+len(needle) <= len(hay); i++ {
|
||||
if equalSlice(hay[i:i+len(needle)], needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func equalSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestChunkLevel(t *testing.T) {
|
||||
// Silence → ~0.
|
||||
silence := make([]byte, 640)
|
||||
if lvl := ChunkLevel(silence); lvl > 0.01 {
|
||||
t.Errorf("silence level = %f, want ~0", lvl)
|
||||
}
|
||||
// A loud full-scale tone → clamped to 1.
|
||||
loud := make([]byte, 640)
|
||||
for i := 0; i+1 < len(loud); i += 2 {
|
||||
loud[i], loud[i+1] = 0xFF, 0x7F // +32767
|
||||
}
|
||||
if lvl := ChunkLevel(loud); lvl < 0.99 {
|
||||
t.Errorf("full-scale level = %f, want ~1", lvl)
|
||||
}
|
||||
// A normal speech-level signal (~-30 dBFS) should land mid-meter, not pinned
|
||||
// to the bottom (the whole point of the dB mapping).
|
||||
mid := make([]byte, 640)
|
||||
for i := 0; i+1 < len(mid); i += 2 {
|
||||
mid[i], mid[i+1] = byte(1000&0xFF), byte(1000>>8) // ≈ -30 dBFS
|
||||
}
|
||||
if lvl := ChunkLevel(mid); lvl < 0.4 || lvl > 0.75 {
|
||||
t.Errorf("normal-speech level = %f, want a visible mid-range value", lvl)
|
||||
}
|
||||
// A quiet signal (~-45 dBFS) should be low but non-zero.
|
||||
quiet := make([]byte, 640)
|
||||
for i := 0; i+1 < len(quiet); i += 2 {
|
||||
quiet[i], quiet[i+1] = byte(180&0xFF), byte(180>>8)
|
||||
}
|
||||
if lvl := ChunkLevel(quiet); lvl <= 0 || lvl > 0.35 {
|
||||
t.Errorf("quiet level = %f, want a small non-zero value", lvl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// commandSpec describes one capture-process invocation. Argv is always
|
||||
// discrete — never a shell string (the same invariant documented in
|
||||
// internal/imageinput/clipboard.go).
|
||||
type commandSpec struct {
|
||||
name string
|
||||
args []string
|
||||
// wantStdout wires up a stdout pipe (streaming capture reads raw PCM).
|
||||
wantStdout bool
|
||||
// stopViaStdin, when non-empty, makes graceful stop write this to the
|
||||
// process's stdin instead of signaling — ffmpeg finalizes cleanly on "q",
|
||||
// and Windows has no os.Interrupt delivery anyway.
|
||||
stopViaStdin string
|
||||
}
|
||||
|
||||
// processHandle is a started capture process. Injectable via processStarter so
|
||||
// unit tests fake OS-boundary behavior, mirroring internal/lsp's serverStarter
|
||||
// seam (this package's clipboard.go-style shell-outs get the test seam that
|
||||
// file lacks).
|
||||
type processHandle interface {
|
||||
// StopGracefully asks the tool to finish and flush output (SIGINT, or the
|
||||
// spec's stdin stop text). The recorder falls back to Kill on timeout.
|
||||
StopGracefully() error
|
||||
Wait() error
|
||||
Kill() error
|
||||
}
|
||||
|
||||
// processStarter launches a capture process, returning its handle and — when
|
||||
// the spec asks for it — its stdout pipe.
|
||||
type processStarter func(spec commandSpec) (processHandle, io.ReadCloser, error)
|
||||
|
||||
// commandOutputRunner runs a short one-shot command to completion and returns
|
||||
// its combined output. Used for helper invocations that aren't recordings
|
||||
// (Termux start/stop, Windows ffmpeg device listing) and the offline
|
||||
// transcription exec — ctx lets a caller cancel/timeout a wedged process.
|
||||
type commandOutputRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
|
||||
func startProcess(spec commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
cmd := exec.Command(spec.name, spec.args...)
|
||||
var stdout io.ReadCloser
|
||||
if spec.wantStdout {
|
||||
pipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
stdout = pipe
|
||||
}
|
||||
var stdin io.WriteCloser
|
||||
if spec.stopViaStdin != "" {
|
||||
pipe, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
stdin = pipe
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
// StdoutPipe/StdinPipe allocate fds the runtime only reclaims via
|
||||
// Wait; on Start failure the process is never established, so close
|
||||
// any pipes we already opened to avoid leaking them.
|
||||
if stdout != nil {
|
||||
_ = stdout.Close()
|
||||
}
|
||||
if stdin != nil {
|
||||
_ = stdin.Close()
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
return &realProcess{cmd: cmd, stdin: stdin, stopText: spec.stopViaStdin}, stdout, nil
|
||||
}
|
||||
|
||||
type realProcess struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stopText string
|
||||
}
|
||||
|
||||
func (p *realProcess) StopGracefully() error {
|
||||
if p.stopText != "" && p.stdin != nil {
|
||||
_, err := io.WriteString(p.stdin, p.stopText)
|
||||
closeErr := p.stdin.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
// os.Interrupt is how arecord/sox finalize their WAV header. Unsupported
|
||||
// on Windows, but the only Windows tool (ffmpeg) stops via stdin above.
|
||||
return p.cmd.Process.Signal(os.Interrupt)
|
||||
}
|
||||
|
||||
func (p *realProcess) Wait() error { return p.cmd.Wait() }
|
||||
|
||||
func (p *realProcess) Kill() error { return p.cmd.Process.Kill() }
|
||||
|
||||
func runCommandOutput(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
err := cmd.Run()
|
||||
return out.Bytes(), err
|
||||
}
|
||||
|
||||
// waitWithTimeout waits for the process, killing it if it ignores the graceful
|
||||
// stop for longer than the grace window. Capture tools exit on SIGINT/"q"
|
||||
// almost instantly; a hang here means a wedged device driver, and a leaked
|
||||
// recording process holds the microphone open.
|
||||
func waitWithTimeout(proc processHandle, grace time.Duration) error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- proc.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(grace):
|
||||
_ = proc.Kill()
|
||||
return <-done
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultServerPort is the localhost port the sherpa-onnx streaming server binds.
|
||||
const DefaultServerPort = 6006
|
||||
|
||||
// ServerConfig configures the warm sherpa-onnx streaming server (§6a).
|
||||
type ServerConfig struct {
|
||||
// Binary is the streaming server executable (default
|
||||
// "sherpa-onnx-online-websocket-server"); looked up on PATH.
|
||||
Binary string
|
||||
// ModelPath is the sherpa-onnx streaming model directory (transducer:
|
||||
// encoder/decoder/joiner + tokens).
|
||||
ModelPath string
|
||||
Port int
|
||||
NumThreads int
|
||||
|
||||
// Test seams.
|
||||
starter processStarter
|
||||
healthCheck func(ctx context.Context, port int) error
|
||||
aliveCheck func(port int) bool
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// ServerManager owns one long-lived sherpa-onnx streaming server, spawned lazily
|
||||
// on first streaming use and kept warm for the session — a websocket server with
|
||||
// ~1-2s startup latency can't be respawned per utterance (§6a). Its lifecycle
|
||||
// mirrors internal/lsp's Manager: lazy start, health-checked reuse, one restart
|
||||
// on a crashed process, torn down on exit. Safe for concurrent use.
|
||||
type ServerManager struct {
|
||||
cfg ServerConfig
|
||||
|
||||
mu sync.Mutex
|
||||
proc processHandle
|
||||
url string
|
||||
}
|
||||
|
||||
// NewServerManager builds a manager. Construction is side-effect free; the
|
||||
// server is only spawned on the first EnsureRunning.
|
||||
func NewServerManager(cfg ServerConfig) *ServerManager {
|
||||
if cfg.Binary == "" {
|
||||
cfg.Binary = "sherpa-onnx-online-websocket-server"
|
||||
}
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = DefaultServerPort
|
||||
}
|
||||
if cfg.starter == nil {
|
||||
cfg.starter = startProcess
|
||||
}
|
||||
if cfg.healthCheck == nil {
|
||||
cfg.healthCheck = dialHealthCheck
|
||||
}
|
||||
if cfg.aliveCheck == nil {
|
||||
cfg.aliveCheck = func(port int) bool { return portListening(port) }
|
||||
}
|
||||
if cfg.now == nil {
|
||||
cfg.now = time.Now
|
||||
}
|
||||
return &ServerManager{cfg: cfg}
|
||||
}
|
||||
|
||||
// EnsureRunning returns the websocket URL of a live server, starting one if
|
||||
// needed. A previously-started server whose process has since died is reaped and
|
||||
// replaced (mirroring the LSP manager's crashed-session eviction).
|
||||
func (m *ServerManager) EnsureRunning(ctx context.Context) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.proc != nil {
|
||||
if m.processAlive() {
|
||||
return m.url, nil
|
||||
}
|
||||
// The cached server died; reap it so a fresh one starts below.
|
||||
_ = m.proc.Kill()
|
||||
m.proc = nil
|
||||
m.url = ""
|
||||
}
|
||||
|
||||
spec, err := onlineServerCommand(m.cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
proc, _, err := m.cfg.starter(spec)
|
||||
if err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", &SetupError{
|
||||
Tool: m.cfg.Binary,
|
||||
Hint: "install sherpa-onnx and put its online-websocket-server binary on PATH (see docs/dictation.md)",
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("starting sherpa-onnx server: %w", err)
|
||||
}
|
||||
|
||||
// Health-check the port before handing back a URL — the server takes ~1-2s to
|
||||
// bind, and a client connecting too early would fail (§6a).
|
||||
if err := m.cfg.healthCheck(ctx, m.cfg.Port); err != nil {
|
||||
_ = proc.Kill()
|
||||
return "", fmt.Errorf("sherpa-onnx server did not become ready: %w", err)
|
||||
}
|
||||
m.proc = proc
|
||||
m.url = fmt.Sprintf("ws://127.0.0.1:%d", m.cfg.Port)
|
||||
return m.url, nil
|
||||
}
|
||||
|
||||
// processAlive reports whether the tracked server is still reachable. Probing
|
||||
// the port (rather than the process handle) reflects real reachability and is
|
||||
// the seam tests replace.
|
||||
func (m *ServerManager) processAlive() bool {
|
||||
return m.cfg.aliveCheck(m.cfg.Port)
|
||||
}
|
||||
|
||||
// portListening reports whether something accepts TCP connections on the port.
|
||||
func portListening(port int) bool {
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 200*time.Millisecond)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// Shutdown stops the running server. Called alongside the LSP manager's shutdown
|
||||
// on exit — a leaked warm server holds a port and CPU.
|
||||
func (m *ServerManager) Shutdown(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
proc := m.proc
|
||||
m.proc = nil
|
||||
m.url = ""
|
||||
m.mu.Unlock()
|
||||
if proc == nil {
|
||||
return nil
|
||||
}
|
||||
if err := proc.StopGracefully(); err != nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- waitWithTimeout(proc, stopGrace) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
_ = proc.Kill()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// URL returns the current server URL without starting one ("" when not running).
|
||||
func (m *ServerManager) URL() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.url
|
||||
}
|
||||
|
||||
// SetModelPath updates the model directory the server launches with, for a
|
||||
// mid-session config change (e.g. an F9 auto-download). It takes effect the next
|
||||
// time the server (re)starts; a running server is left as-is.
|
||||
func (m *ServerManager) SetModelPath(path string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if path != "" {
|
||||
m.cfg.ModelPath = path
|
||||
}
|
||||
}
|
||||
|
||||
// onlineServerCommand builds the streaming-server argv from the model directory.
|
||||
// The online server uses a streaming transducer (encoder/decoder/joiner) plus
|
||||
// tokens — the shape the sherpa-onnx docs' example uses (§6a).
|
||||
func onlineServerCommand(cfg ServerConfig) (commandSpec, error) {
|
||||
if strings.TrimSpace(cfg.ModelPath) == "" {
|
||||
return commandSpec{}, &SetupError{
|
||||
Tool: "local STT streaming model",
|
||||
Hint: "set stt.localModelPath to a sherpa-onnx streaming model directory (see docs/dictation.md)",
|
||||
}
|
||||
}
|
||||
files, err := onnxFilesIn(cfg.ModelPath)
|
||||
if err != nil {
|
||||
return commandSpec{}, &SetupError{Tool: "local STT streaming model", Hint: err.Error()}
|
||||
}
|
||||
tokens := findTokensFile(files)
|
||||
// A streaming model ships encoder/decoder/joiner, often BOTH an int8 and an
|
||||
// fp32 copy of each. Pick a CONSISTENT set — all int8 or all fp32 — because
|
||||
// mixing (int8 encoder + fp32 decoder) is invalid. Prefer int8 (smaller,
|
||||
// faster) when a full int8 set is present.
|
||||
set := consistentQuantizedSet(files, "encoder", "decoder", "joiner")
|
||||
var encoder, decoder, joiner string
|
||||
if set != nil {
|
||||
encoder, decoder, joiner = set[0], set[1], set[2]
|
||||
}
|
||||
if tokens == "" || encoder == "" || decoder == "" || joiner == "" {
|
||||
return commandSpec{}, &SetupError{
|
||||
Tool: "local STT streaming model",
|
||||
Hint: fmt.Sprintf("streaming needs a transducer model (encoder/decoder/joiner .onnx + tokens.txt) in %q; see docs/dictation.md", cfg.ModelPath),
|
||||
}
|
||||
}
|
||||
args := []string{
|
||||
"--port=" + strconv.Itoa(cfg.Port),
|
||||
"--tokens=" + tokens,
|
||||
"--encoder=" + encoder,
|
||||
"--decoder=" + decoder,
|
||||
"--joiner=" + joiner,
|
||||
"--decoding-method=greedy_search",
|
||||
}
|
||||
if cfg.NumThreads > 0 {
|
||||
args = append(args, "--num-threads="+strconv.Itoa(cfg.NumThreads))
|
||||
}
|
||||
return commandSpec{name: cfg.Binary, args: args}, nil
|
||||
}
|
||||
|
||||
func onnxFilesIn(dir string) (map[string]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stt.localModelPath %q is not readable: %v", dir, err)
|
||||
}
|
||||
files := map[string]string{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
files[strings.ToLower(e.Name())] = dir + string(os.PathSeparator) + e.Name()
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// dialHealthCheck polls the server's TCP port until it accepts a connection or
|
||||
// the deadline passes.
|
||||
func dialHealthCheck(ctx context.Context, port int) error {
|
||||
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("port %d not listening after 5s", port)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func streamingModelDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, f := range []string{"encoder.onnx", "decoder.onnx", "joiner.onnx", "tokens.txt"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestServerManagerLazyStartAndReuse(t *testing.T) {
|
||||
var startCount int
|
||||
var mu sync.Mutex
|
||||
starter := func(spec commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
mu.Lock()
|
||||
startCount++
|
||||
mu.Unlock()
|
||||
if spec.name != "sherpa-onnx-online-websocket-server" {
|
||||
t.Errorf("binary = %q", spec.name)
|
||||
}
|
||||
return newFakeProcess(), nil, nil
|
||||
}
|
||||
alive := true
|
||||
m := NewServerManager(ServerConfig{
|
||||
ModelPath: streamingModelDir(t),
|
||||
Port: 6006,
|
||||
starter: starter,
|
||||
healthCheck: func(context.Context, int) error { return nil },
|
||||
aliveCheck: func(int) bool { return alive },
|
||||
})
|
||||
|
||||
url, err := m.EnsureRunning(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureRunning: %v", err)
|
||||
}
|
||||
if url != "ws://127.0.0.1:6006" {
|
||||
t.Errorf("url = %q", url)
|
||||
}
|
||||
// Second call reuses the warm server (alive) — no new start.
|
||||
if _, err := m.EnsureRunning(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureRunning reuse: %v", err)
|
||||
}
|
||||
if startCount != 1 {
|
||||
t.Errorf("expected 1 start (warm reuse), got %d", startCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerManagerRestartsCrashedServer(t *testing.T) {
|
||||
var startCount int
|
||||
starter := func(commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
startCount++
|
||||
return newFakeProcess(), nil, nil
|
||||
}
|
||||
alive := true
|
||||
m := NewServerManager(ServerConfig{
|
||||
ModelPath: streamingModelDir(t),
|
||||
starter: starter,
|
||||
healthCheck: func(context.Context, int) error { return nil },
|
||||
aliveCheck: func(int) bool { return alive },
|
||||
})
|
||||
if _, err := m.EnsureRunning(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alive = false // simulate a crashed server
|
||||
if _, err := m.EnsureRunning(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if startCount != 2 {
|
||||
t.Errorf("expected a restart (2 starts), got %d", startCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerManagerMissingBinaryIsSetupError(t *testing.T) {
|
||||
m := NewServerManager(ServerConfig{
|
||||
ModelPath: streamingModelDir(t),
|
||||
starter: func(commandSpec) (processHandle, io.ReadCloser, error) { return nil, nil, exec.ErrNotFound },
|
||||
})
|
||||
_, err := m.EnsureRunning(context.Background())
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerManagerMissingModelIsSetupError(t *testing.T) {
|
||||
m := NewServerManager(ServerConfig{
|
||||
ModelPath: t.TempDir(), // empty dir, no onnx/tokens
|
||||
starter: func(commandSpec) (processHandle, io.ReadCloser, error) { return newFakeProcess(), nil, nil },
|
||||
})
|
||||
_, err := m.EnsureRunning(context.Background())
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError for missing streaming model, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerManagerShutdown(t *testing.T) {
|
||||
proc := newFakeProcess()
|
||||
m := NewServerManager(ServerConfig{
|
||||
ModelPath: streamingModelDir(t),
|
||||
starter: func(commandSpec) (processHandle, io.ReadCloser, error) { return proc, nil, nil },
|
||||
healthCheck: func(context.Context, int) error { return nil },
|
||||
aliveCheck: func(int) bool { return true },
|
||||
})
|
||||
if _, err := m.EnsureRunning(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Shutdown(context.Background()); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
if !proc.stopped && !proc.killed {
|
||||
t.Error("Shutdown should stop the server process")
|
||||
}
|
||||
if m.URL() != "" {
|
||||
t.Error("URL should be empty after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlineServerCommandBuildsTransducerArgs(t *testing.T) {
|
||||
spec, err := onlineServerCommand(ServerConfig{Binary: "sherpa-onnx-online-websocket-server", ModelPath: streamingModelDir(t), Port: 6007})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := ""
|
||||
for _, a := range spec.args {
|
||||
joined += a + " "
|
||||
}
|
||||
for _, want := range []string{"--port=6007", "--tokens=", "--encoder=", "--decoder=", "--joiner=", "--decoding-method=greedy_search"} {
|
||||
if !contains(joined, want) {
|
||||
t.Errorf("missing %q in args: %s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCM16ToFloat32LE(t *testing.T) {
|
||||
// int16 max (32767) and min (-32768), little-endian.
|
||||
pcm := []byte{0xFF, 0x7F, 0x00, 0x80}
|
||||
out := pcm16ToFloat32LE(pcm)
|
||||
if len(out) != 8 {
|
||||
t.Fatalf("expected 8 bytes (2 float32), got %d", len(out))
|
||||
}
|
||||
f0 := math.Float32frombits(binary.LittleEndian.Uint32(out[0:4]))
|
||||
f1 := math.Float32frombits(binary.LittleEndian.Uint32(out[4:8]))
|
||||
if f0 < 0.99 || f0 > 1.0 {
|
||||
t.Errorf("32767 → %f, want ~1.0", f0)
|
||||
}
|
||||
if f1 != -1.0 {
|
||||
t.Errorf("-32768 → %f, want -1.0", f1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSherpaStreamResult(t *testing.T) {
|
||||
text, segment, final, ok := parseSherpaStreamResult([]byte(`{"text":" hello world ","segment":2,"is_final":true}`))
|
||||
if !ok || text != "hello world" || segment != 2 || !final {
|
||||
t.Errorf("parsed = (%q, %d, %v, %v)", text, segment, final, ok)
|
||||
}
|
||||
if _, _, _, ok := parseSherpaStreamResult([]byte("not json")); ok {
|
||||
t.Error("non-JSON should not parse")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(hay, needle string) bool {
|
||||
return len(needle) == 0 || (len(hay) >= len(needle) && indexOf(hay, needle) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(hay, needle string) int {
|
||||
for i := 0; i+len(needle) <= len(hay); i++ {
|
||||
if hay[i:i+len(needle)] == needle {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Recorded audio is staged in a hardened per-user temp directory, mirroring
|
||||
// internal/tools/spill.go (PR #506): uid-suffixed name so users on a shared
|
||||
// /tmp cannot collide, 0700 perms, and a pre-existing path is only accepted
|
||||
// when it is a real directory (not a symlink that would redirect recordings)
|
||||
// owned by the current user. Files are removed as soon as their bytes are
|
||||
// read back after Stop — audio never lingers on disk.
|
||||
|
||||
func audioSpillRoot() string {
|
||||
name := "zero-dictation"
|
||||
if uid := os.Getuid(); uid >= 0 {
|
||||
name = fmt.Sprintf("zero-dictation-%d", uid)
|
||||
}
|
||||
return filepath.Join(os.TempDir(), name)
|
||||
}
|
||||
|
||||
func audioSpillDir() (string, error) {
|
||||
dir := audioSpillRoot()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// MkdirAll follows symlinks and leaves an existing directory untouched, so
|
||||
// verify what is actually at the path.
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("dictation temp path %s is not a directory", dir)
|
||||
}
|
||||
// MkdirAll only applies 0o700 to dirs it creates; an existing directory
|
||||
// (e.g. one created under a loose umask in a prior run) can be group- or
|
||||
// world-readable. Tighten it so staged audio (PII) is never visible to
|
||||
// other users on a shared /tmp.
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("tightening dictation temp dir permissions: %w", err)
|
||||
}
|
||||
}
|
||||
if err := checkAudioDirOwner(info); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLocalStreamingRealServer drives the full local streaming path against a
|
||||
// REAL sherpa-onnx-online-websocket-server + streaming model: spawn the server,
|
||||
// connect, feed a wav's PCM in 50ms chunks, and check the transcript. Opt-in:
|
||||
//
|
||||
// ZERO_STT_STREAM_SERVER=/path/to/sherpa-onnx-online-websocket-server \
|
||||
// ZERO_STT_STREAM_MODEL=/path/to/streaming-model-dir \
|
||||
// ZERO_STT_STREAM_WAV=/path/to/16k-mono.wav \
|
||||
// go test ./internal/dictation/ -run RealServer -v -timeout 5m
|
||||
func TestLocalStreamingRealServer(t *testing.T) {
|
||||
server := os.Getenv("ZERO_STT_STREAM_SERVER")
|
||||
model := os.Getenv("ZERO_STT_STREAM_MODEL")
|
||||
wav := os.Getenv("ZERO_STT_STREAM_WAV")
|
||||
if server == "" || model == "" || wav == "" {
|
||||
t.Skip("set ZERO_STT_STREAM_SERVER, ZERO_STT_STREAM_MODEL, ZERO_STT_STREAM_WAV to run the streaming smoke test")
|
||||
}
|
||||
raw, err := os.ReadFile(wav)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pcm := stripWavHeader(raw)
|
||||
|
||||
mgr := NewServerManager(ServerConfig{Binary: server, ModelPath: model, Port: 6100})
|
||||
defer mgr.Shutdown(context.Background())
|
||||
tr := NewLocalStreamingTranscriber(mgr)
|
||||
|
||||
chunks := make(chan []byte)
|
||||
go func() {
|
||||
defer close(chunks)
|
||||
size := chunkSizeBytes(DefaultSampleRate) // 50ms
|
||||
for off := 0; off < len(pcm); off += size {
|
||||
end := off + size
|
||||
if end > len(pcm) {
|
||||
end = len(pcm)
|
||||
}
|
||||
chunks <- pcm[off:end]
|
||||
time.Sleep(10 * time.Millisecond) // pace it a little, like a mic
|
||||
}
|
||||
}()
|
||||
|
||||
var partials int
|
||||
final, err := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) { partials++ })
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTranscribe: %v", err)
|
||||
}
|
||||
if final == "" {
|
||||
t.Fatal("streaming returned empty transcript")
|
||||
}
|
||||
t.Logf("streaming transcript (%d partials): %q", partials, final)
|
||||
}
|
||||
|
||||
// stripWavHeader drops a standard 44-byte PCM WAV header, returning the raw
|
||||
// int16 samples. Good enough for the canonical 16kHz/mono/16-bit test wavs.
|
||||
func stripWavHeader(b []byte) []byte {
|
||||
if len(b) > 44 && string(b[:4]) == "RIFF" {
|
||||
return b[44:]
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dictation
|
||||
|
||||
import "context"
|
||||
|
||||
// Transcriber turns captured audio into text. Batch (Transcribe) and streaming
|
||||
// (StreamTranscribe) are one interface but two independent code paths — a
|
||||
// batch-only provider (Groq/OpenAI, sherpa-onnx-offline) leaves
|
||||
// StreamTranscribe returning ErrStreamingUnsupported, and a streaming provider
|
||||
// still implements Transcribe for the batch/Termux fallback.
|
||||
type Transcriber interface {
|
||||
// Transcribe returns the final text for one recorded clip.
|
||||
Transcribe(ctx context.Context, audio []byte) (string, error)
|
||||
|
||||
// StreamTranscribe consumes PCM16 chunks and reports incremental results
|
||||
// via onPartial(text, final) as decoding proceeds, returning the complete
|
||||
// final transcript when the chunk channel closes.
|
||||
//
|
||||
// onPartial's text is the CURRENT best transcript of the utterance so far
|
||||
// (cumulative, not a delta) so the composer can replace the live region
|
||||
// wholesale; final marks a segment the provider considers settled.
|
||||
//
|
||||
// On a mid-stream failure (network drop for cloud, subprocess crash for
|
||||
// local) StreamTranscribe returns the best-effort text accumulated from
|
||||
// onPartial calls made so far PLUS a non-nil error — already-streamed text
|
||||
// is never discarded, only the untranscribed tail after the failure is lost.
|
||||
StreamTranscribe(ctx context.Context, chunks <-chan []byte, onPartial func(text string, final bool)) (string, error)
|
||||
}
|
||||
|
||||
// SampleRate lets a Transcriber declare the capture rate its wire format
|
||||
// expects, so the recorder can be built to match (OpenAI Realtime wants 24kHz;
|
||||
// everything else uses 16kHz). A Transcriber that does not implement this uses
|
||||
// DefaultSampleRate.
|
||||
type SampleRate interface {
|
||||
SampleRate() int
|
||||
}
|
||||
|
||||
// RequiredSampleRate returns the capture rate a Transcriber needs.
|
||||
func RequiredSampleRate(t Transcriber) int {
|
||||
if sr, ok := t.(SampleRate); ok {
|
||||
if rate := sr.SampleRate(); rate > 0 {
|
||||
return rate
|
||||
}
|
||||
}
|
||||
return DefaultSampleRate
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
)
|
||||
|
||||
// CloudConfig configures a batch cloud transcriber. Groq and OpenAI share the
|
||||
// OpenAI-compatible /audio/transcriptions multipart endpoint — the design's
|
||||
// "each owns its endpoint/model specifics while sharing the request shape"
|
||||
// (§6). Key and base URL are resolved by the caller (TUI layer) from
|
||||
// config/credstore, keeping this type decoupled and unit-testable.
|
||||
type CloudConfig struct {
|
||||
Provider string // ProviderGroq or ProviderOpenAI, for error messages
|
||||
BaseURL string // e.g. https://api.groq.com/openai/v1
|
||||
APIKey string
|
||||
Model string // e.g. whisper-large-v3-turbo, whisper-1
|
||||
// Language optionally constrains recognition (ISO-639-1). Empty = auto.
|
||||
Language string
|
||||
|
||||
// HTTPClient is injectable for tests; nil uses providerio's shared,
|
||||
// stall-hardened client.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type cloudTranscriber struct {
|
||||
cfg CloudConfig
|
||||
}
|
||||
|
||||
// NewCloudTranscriber builds a batch transcriber for Groq or OpenAI.
|
||||
func NewCloudTranscriber(cfg CloudConfig) (Transcriber, error) {
|
||||
if strings.TrimSpace(cfg.APIKey) == "" {
|
||||
return nil, &SetupError{
|
||||
Tool: cfg.Provider + " API key",
|
||||
Hint: fmt.Sprintf("set a %s API key (run `zero auth`) to use cloud dictation", cfg.Provider),
|
||||
}
|
||||
}
|
||||
if cfg.Model == "" {
|
||||
return nil, fmt.Errorf("dictation: no model configured for provider %q", cfg.Provider)
|
||||
}
|
||||
base, err := providerio.NormalizeBaseURL(cfg.BaseURL, cfg.BaseURL, cfg.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.BaseURL = base
|
||||
return &cloudTranscriber{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (c *cloudTranscriber) Transcribe(ctx context.Context, audio []byte) (string, error) {
|
||||
if len(audio) == 0 {
|
||||
return "", errors.New("no audio to transcribe")
|
||||
}
|
||||
body, contentType, err := c.buildMultipart(audio)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
endpoint := c.cfg.BaseURL + "/audio/transcriptions"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
|
||||
|
||||
resp, err := providerio.HTTPClient(c.cfg.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s transcription request failed: %w", c.cfg.Provider, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
msg := providerio.ClassifiedError(resp.StatusCode, strings.TrimSpace(string(respBody)), c.cfg.APIKey)
|
||||
// A 401/403 is a fixable credential problem: surface it as a typed AuthError
|
||||
// so the TUI can offer an inline key prompt instead of a dead-end message.
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return "", &AuthError{Provider: c.cfg.Provider, Message: msg}
|
||||
}
|
||||
return "", errors.New(msg)
|
||||
}
|
||||
var parsed struct {
|
||||
Text string `json:"text"`
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return "", fmt.Errorf("%s transcription: could not parse response: %w", c.cfg.Provider, err)
|
||||
}
|
||||
if parsed.Error.Message != "" {
|
||||
return "", fmt.Errorf("%s transcription error: %s", c.cfg.Provider, providerio.Redact(parsed.Error.Message, c.cfg.APIKey))
|
||||
}
|
||||
return strings.TrimSpace(parsed.Text), nil
|
||||
}
|
||||
|
||||
// buildMultipart assembles the transcription upload. The file field name and
|
||||
// the "model"/"response_format" fields match the OpenAI audio API that Groq
|
||||
// mirrors. The filename's extension tells the server the container type —
|
||||
// SniffFormat keeps WAV/M4A honest so Termux's AAC uploads work.
|
||||
func (c *cloudTranscriber) buildMultipart(audio []byte) ([]byte, string, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", SniffFormat(audio).FileName())
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if _, err := fw.Write(audio); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := w.WriteField("model", c.cfg.Model); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := w.WriteField("response_format", "json"); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if c.cfg.Language != "" {
|
||||
if err := w.WriteField("language", c.cfg.Language); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return buf.Bytes(), w.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
// StreamTranscribe is not supported for the batch cloud providers (Groq has no
|
||||
// streaming product; OpenAI batch is one-shot). Streaming uses the dedicated
|
||||
// Deepgram/OpenAI-Realtime transcribers instead.
|
||||
func (c *cloudTranscriber) StreamTranscribe(context.Context, <-chan []byte, func(string, bool)) (string, error) {
|
||||
return "", ErrStreamingUnsupported
|
||||
}
|
||||
|
||||
// ErrStreamingUnsupported reports that a batch-only Transcriber cannot stream;
|
||||
// the caller falls back to the batch pipeline.
|
||||
var ErrStreamingUnsupported = errors.New("this transcription provider does not support streaming")
|
||||
@@ -0,0 +1,142 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCloudTranscribeBuildsMultipartAndParsesText(t *testing.T) {
|
||||
var gotModel, gotAuth, gotFilename string
|
||||
var gotFileBytes []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if r.URL.Path != "/audio/transcriptions" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || !strings.HasPrefix(mediaType, "multipart/") {
|
||||
t.Fatalf("bad content type %q: %v", r.Header.Get("Content-Type"), err)
|
||||
}
|
||||
mr := multipart.NewReader(r.Body, params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("multipart: %v", err)
|
||||
}
|
||||
switch part.FormName() {
|
||||
case "model":
|
||||
b, _ := io.ReadAll(part)
|
||||
gotModel = string(b)
|
||||
case "file":
|
||||
gotFilename = part.FileName()
|
||||
gotFileBytes, _ = io.ReadAll(part)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"text":" hello world "}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tr, err := NewCloudTranscriber(CloudConfig{
|
||||
Provider: ProviderGroq,
|
||||
BaseURL: srv.URL,
|
||||
APIKey: "secret-key",
|
||||
Model: "whisper-large-v3-turbo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCloudTranscriber: %v", err)
|
||||
}
|
||||
audio := wavFixture()
|
||||
text, err := tr.Transcribe(context.Background(), audio)
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if text != "hello world" {
|
||||
t.Errorf("text = %q, want trimmed 'hello world'", text)
|
||||
}
|
||||
if gotModel != "whisper-large-v3-turbo" {
|
||||
t.Errorf("model field = %q", gotModel)
|
||||
}
|
||||
if gotAuth != "Bearer secret-key" {
|
||||
t.Errorf("auth header = %q", gotAuth)
|
||||
}
|
||||
if gotFilename != "audio.wav" {
|
||||
t.Errorf("filename = %q, want audio.wav", gotFilename)
|
||||
}
|
||||
if len(gotFileBytes) != len(audio) {
|
||||
t.Errorf("uploaded %d file bytes, want %d", len(gotFileBytes), len(audio))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudTranscribeM4AFilename(t *testing.T) {
|
||||
var gotFilename string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, params, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
mr := multipart.NewReader(r.Body, params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if part.FormName() == "file" {
|
||||
gotFilename = part.FileName()
|
||||
}
|
||||
}
|
||||
io.WriteString(w, `{"text":"ok"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tr, _ := NewCloudTranscriber(CloudConfig{Provider: ProviderOpenAI, BaseURL: srv.URL, APIKey: "k", Model: "whisper-1"})
|
||||
if _, err := tr.Transcribe(context.Background(), m4aFixture()); err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if gotFilename != "audio.m4a" {
|
||||
t.Errorf("filename = %q, want audio.m4a", gotFilename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudTranscribeAuthErrorClassified(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
io.WriteString(w, `{"error":{"message":"invalid api key sk-secret-key"}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tr, _ := NewCloudTranscriber(CloudConfig{Provider: ProviderGroq, BaseURL: srv.URL, APIKey: "sk-secret-key", Model: "whisper-large-v3-turbo"})
|
||||
_, err := tr.Transcribe(context.Background(), wavFixture())
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 401")
|
||||
}
|
||||
if strings.Contains(err.Error(), "sk-secret-key") {
|
||||
t.Errorf("API key leaked into error: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "auth error") {
|
||||
t.Errorf("expected auth-error classification, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCloudTranscriberRequiresKey(t *testing.T) {
|
||||
_, err := NewCloudTranscriber(CloudConfig{Provider: ProviderGroq, BaseURL: "https://api.groq.com/openai/v1", Model: "m"})
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError for missing key, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudTranscribeStreamingUnsupported(t *testing.T) {
|
||||
tr, _ := NewCloudTranscriber(CloudConfig{Provider: ProviderGroq, BaseURL: "https://x", APIKey: "k", Model: "m"})
|
||||
_, err := tr.StreamTranscribe(context.Background(), nil, nil)
|
||||
if !errors.Is(err, ErrStreamingUnsupported) {
|
||||
t.Errorf("want ErrStreamingUnsupported, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// DeepgramConfig configures the Deepgram streaming transcriber (§6b, default
|
||||
// cloud streaming provider). The key is resolved by the caller.
|
||||
type DeepgramConfig struct {
|
||||
APIKey string
|
||||
Model string // default "nova-3"
|
||||
Language string
|
||||
SampleRate int // capture rate; default DefaultSampleRate (16kHz)
|
||||
// BaseURL overrides the wss endpoint (tests point it at a fake server).
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type deepgramTranscriber struct {
|
||||
cfg DeepgramConfig
|
||||
}
|
||||
|
||||
// NewDeepgramTranscriber builds the Deepgram streaming transcriber.
|
||||
func NewDeepgramTranscriber(cfg DeepgramConfig) (Transcriber, error) {
|
||||
if strings.TrimSpace(cfg.APIKey) == "" {
|
||||
return nil, &SetupError{
|
||||
Tool: "Deepgram API key",
|
||||
Hint: "set a Deepgram API key (DEEPGRAM_API_KEY, or `zero auth`) to use Deepgram streaming dictation",
|
||||
}
|
||||
}
|
||||
if cfg.Model == "" {
|
||||
cfg.Model = "nova-3"
|
||||
}
|
||||
if cfg.SampleRate <= 0 {
|
||||
cfg.SampleRate = DefaultSampleRate
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = "wss://api.deepgram.com/v1/listen"
|
||||
}
|
||||
return &deepgramTranscriber{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// Transcribe is unsupported — Deepgram is used only on the streaming path here.
|
||||
func (d *deepgramTranscriber) Transcribe(context.Context, []byte) (string, error) {
|
||||
return "", ErrStreamingUnsupported
|
||||
}
|
||||
|
||||
func (d *deepgramTranscriber) SampleRate() int { return d.cfg.SampleRate }
|
||||
|
||||
func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-chan []byte, onPartial func(string, bool)) (string, error) {
|
||||
endpoint := d.buildURL()
|
||||
conn, _, err := websocket.Dial(ctx, endpoint, &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connecting to Deepgram: %w", err)
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
// Deepgram consumes raw linear16 (int16) PCM — the recorder's native format,
|
||||
// so no conversion (unlike sherpa-onnx, which wants float32).
|
||||
writeErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
for chunk := range chunks {
|
||||
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
|
||||
writeErrCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
// CloseStream flushes any buffered audio and returns final results before
|
||||
// Deepgram closes the socket.
|
||||
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte(`{"type":"CloseStream"}`))
|
||||
}()
|
||||
|
||||
// Deepgram results are per-utterance segments, not cumulative: accumulate the
|
||||
// is_final segments and append the current interim to build the full text.
|
||||
var finals []string
|
||||
var interim string
|
||||
compose := func() string {
|
||||
return strings.TrimSpace(strings.Join(append(finals, interim), " "))
|
||||
}
|
||||
for {
|
||||
typ, data, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
// A clean close after CloseStream is success, not failure.
|
||||
if websocket.CloseStatus(err) == websocket.StatusNormalClosure {
|
||||
return compose(), nil
|
||||
}
|
||||
select {
|
||||
case werr := <-writeErrCh:
|
||||
if werr != nil {
|
||||
err = werr
|
||||
}
|
||||
default:
|
||||
}
|
||||
return compose(), fmt.Errorf("Deepgram stream error: %w", err)
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
transcript, isFinal, ok := parseDeepgramResult(data)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isFinal {
|
||||
if transcript != "" {
|
||||
finals = append(finals, transcript)
|
||||
}
|
||||
interim = ""
|
||||
} else {
|
||||
interim = transcript
|
||||
}
|
||||
if onPartial != nil {
|
||||
onPartial(compose(), isFinal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *deepgramTranscriber) buildURL() string {
|
||||
q := url.Values{}
|
||||
q.Set("encoding", "linear16")
|
||||
q.Set("sample_rate", strconv.Itoa(d.cfg.SampleRate))
|
||||
q.Set("channels", "1")
|
||||
q.Set("interim_results", "true")
|
||||
q.Set("punctuate", "true")
|
||||
q.Set("model", d.cfg.Model)
|
||||
if d.cfg.Language != "" {
|
||||
q.Set("language", d.cfg.Language)
|
||||
}
|
||||
return d.cfg.BaseURL + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// parseDeepgramResult extracts the transcript and is_final flag from a Deepgram
|
||||
// "Results" message: channel.alternatives[0].transcript.
|
||||
func parseDeepgramResult(data []byte) (transcript string, isFinal bool, ok bool) {
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
IsFinal bool `json:"is_final"`
|
||||
Channel struct {
|
||||
Alternatives []struct {
|
||||
Transcript string `json:"transcript"`
|
||||
} `json:"alternatives"`
|
||||
} `json:"channel"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
// Only "Results" messages carry transcripts; ignore Metadata/UtteranceEnd/etc.
|
||||
if msg.Type != "" && msg.Type != "Results" {
|
||||
return "", false, false
|
||||
}
|
||||
if len(msg.Channel.Alternatives) == 0 {
|
||||
return "", false, false
|
||||
}
|
||||
return strings.TrimSpace(msg.Channel.Alternatives[0].Transcript), msg.IsFinal, true
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LocalConfig configures the offline sherpa-onnx batch transcriber. Every tier
|
||||
// (default/standard/high-end, §7) is just a different ModelPath — there are no
|
||||
// tier-specific code paths; the model family is detected from the files present.
|
||||
type LocalConfig struct {
|
||||
// Binary is the sherpa-onnx-offline executable; looked up on PATH when a
|
||||
// bare name. Empty defaults to "sherpa-onnx-offline".
|
||||
Binary string
|
||||
// ModelPath is the directory holding the model's .onnx + tokens.txt files
|
||||
// (stt.localModelPath).
|
||||
ModelPath string
|
||||
NumThreads int
|
||||
|
||||
// Test seams.
|
||||
runOutput commandOutputRunner
|
||||
tempDir func() (string, error)
|
||||
}
|
||||
|
||||
type localTranscriber struct {
|
||||
cfg LocalConfig
|
||||
}
|
||||
|
||||
// NewLocalTranscriber builds the offline transcriber. It does not touch the
|
||||
// filesystem or PATH until Transcribe runs, so a missing binary/model degrades
|
||||
// to a clear setup message at use time (matching internal/lsp).
|
||||
func NewLocalTranscriber(cfg LocalConfig) (Transcriber, error) {
|
||||
if strings.TrimSpace(cfg.ModelPath) == "" {
|
||||
return nil, &SetupError{
|
||||
Tool: "local STT model",
|
||||
Hint: "set stt.localModelPath to a sherpa-onnx model directory (see docs/dictation.md)",
|
||||
}
|
||||
}
|
||||
if cfg.Binary == "" {
|
||||
cfg.Binary = "sherpa-onnx-offline"
|
||||
}
|
||||
if cfg.runOutput == nil {
|
||||
cfg.runOutput = runCommandOutput
|
||||
}
|
||||
if cfg.tempDir == nil {
|
||||
cfg.tempDir = audioSpillDir
|
||||
}
|
||||
return &localTranscriber{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (l *localTranscriber) Transcribe(ctx context.Context, audio []byte) (string, error) {
|
||||
if len(audio) == 0 {
|
||||
return "", errors.New("no audio to transcribe")
|
||||
}
|
||||
modelArgs, err := detectModelArgs(l.cfg.ModelPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dir, err := l.cfg.tempDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("preparing dictation temp dir: %w", err)
|
||||
}
|
||||
ext := ".wav"
|
||||
if SniffFormat(audio) == FormatM4A {
|
||||
ext = ".m4a"
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "stt-*"+ext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("staging audio for transcription: %w", err)
|
||||
}
|
||||
path := tmp.Name()
|
||||
defer os.Remove(path) // audio never lingers on disk (§12)
|
||||
if _, err := tmp.Write(audio); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
args := make([]string, 0, len(modelArgs)+3)
|
||||
args = append(args, modelArgs...)
|
||||
if l.cfg.NumThreads > 0 {
|
||||
args = append(args, "--num-threads="+strconv.Itoa(l.cfg.NumThreads))
|
||||
}
|
||||
args = append(args, path)
|
||||
|
||||
// Fail fast if already cancelled; ctx also propagates into the exec below so a
|
||||
// wedged sherpa-onnx-offline is killed on cancel/timeout rather than hanging.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := l.cfg.runOutput(ctx, l.cfg.Binary, args...)
|
||||
if err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", &SetupError{
|
||||
Tool: l.cfg.Binary,
|
||||
Hint: "install sherpa-onnx and put its offline binary on PATH (see docs/dictation.md)",
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("sherpa-onnx transcription failed: %w (%s)", err, lastLines(out, 3))
|
||||
}
|
||||
text := parseSherpaOutput(out)
|
||||
if text == "" {
|
||||
return "", fmt.Errorf("sherpa-onnx produced no transcript (output: %s)", lastLines(out, 3))
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// detectModelArgs inspects a sherpa-onnx model directory and builds the
|
||||
// family-specific --*=path flags. Supports the families sherpa-onnx-offline
|
||||
// accepts; Moonshine (the design's default/standard/high-end tiers) is the
|
||||
// primary target. tokens.txt is required by every family.
|
||||
func detectModelArgs(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, &SetupError{
|
||||
Tool: "local STT model",
|
||||
Hint: fmt.Sprintf("stt.localModelPath %q is not readable: %v", dir, err),
|
||||
}
|
||||
}
|
||||
files := map[string]string{} // lowercased basename -> absolute path
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
files[strings.ToLower(e.Name())] = filepath.Join(dir, e.Name())
|
||||
}
|
||||
|
||||
tokens := findTokensFile(files)
|
||||
if tokens == "" {
|
||||
return nil, &SetupError{
|
||||
Tool: "local STT model",
|
||||
Hint: fmt.Sprintf("no tokens.txt found in stt.localModelPath %q — is it a sherpa-onnx model directory?", dir),
|
||||
}
|
||||
}
|
||||
|
||||
dirName := strings.ToLower(filepath.Base(dir))
|
||||
|
||||
// Order matters — check the most specific signatures first so a model is
|
||||
// never mis-classified as a family it only partially resembles.
|
||||
|
||||
// Moonshine: preprocessor + encoder + cached/uncached decoders. Its files are
|
||||
// "encode"/"decode" (no trailing "r"), so they never collide with the
|
||||
// "encoder"/"decoder" families below.
|
||||
if pre := findContains(files, "preprocess"); pre != "" {
|
||||
enc := findContains(files, "encode")
|
||||
// "uncached_decode" CONTAINS "cached_decode", so match the cached decoder
|
||||
// as "cached" AND not "uncached" to disambiguate (map order is random).
|
||||
unc := findOnnxMatching(files, "uncached", "")
|
||||
cac := findOnnxMatching(files, "cached", "uncached")
|
||||
if enc != "" && unc != "" && cac != "" {
|
||||
return []string{
|
||||
"--moonshine-preprocessor=" + pre,
|
||||
"--moonshine-encoder=" + enc,
|
||||
"--moonshine-uncached-decoder=" + unc,
|
||||
"--moonshine-cached-decoder=" + cac,
|
||||
"--tokens=" + tokens,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Transducer (Zipformer/Conformer): encoder + decoder + joiner, matched as a
|
||||
// consistent int8-or-fp32 set (mixing quantizations is invalid).
|
||||
if set := consistentQuantizedSet(files, "encoder", "decoder", "joiner"); set != nil {
|
||||
return []string{"--encoder=" + set[0], "--decoder=" + set[1], "--joiner=" + set[2], "--tokens=" + tokens}, nil
|
||||
}
|
||||
|
||||
// Encoder+decoder families that share Whisper's file shape (encoder + decoder,
|
||||
// no joiner) but need their own flags — FireRedASR-AED, Canary, Cohere. They are
|
||||
// indistinguishable from Whisper by files alone, so disambiguate by name FIRST;
|
||||
// anything unnamed falls through to the Whisper default below.
|
||||
for _, fam := range encoderDecoderFamilies {
|
||||
if nameMatchesFamily(dirName, files, fam.match) {
|
||||
if set := consistentQuantizedSet(files, "encoder", "decoder"); set != nil {
|
||||
return []string{fam.encoderFlag + "=" + set[0], fam.decoderFlag + "=" + set[1], "--tokens=" + tokens}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whisper: encoder + decoder, NO joiner. Sherpa's Whisper files are named
|
||||
// "<model>-encoder.onnx" / "<model>-decoder.onnx" (NOT "whisper-encoder"), so
|
||||
// match on the encoder/decoder roles — a consistent pair — after transducer
|
||||
// (which owns the encoder/decoder/joiner shape) has already been ruled out.
|
||||
if set := consistentQuantizedSet(files, "encoder", "decoder"); set != nil {
|
||||
return []string{"--whisper-encoder=" + set[0], "--whisper-decoder=" + set[1], "--tokens=" + tokens}, nil
|
||||
}
|
||||
|
||||
// Single-model families: one generic model .onnx + tokens, indistinguishable by
|
||||
// file shape, so they are told apart by the model directory/file name. This
|
||||
// covers SenseVoice, Paraformer, and the CTC families (FireRedASR-CTC, NeMo,
|
||||
// TeleSpeech, WeNet, Zipformer-CTC, Dolphin, Omnilingual). Ordered most-specific
|
||||
// first so a compound name (e.g. "fire-red-asr2-ctc") matches its own family
|
||||
// before a generic token in it (e.g. bare "ctc") could steer it elsewhere.
|
||||
if m := singleModelOnnx(files); m != "" {
|
||||
for _, fam := range singleModelFamilies {
|
||||
if nameMatchesFamily(dirName, files, fam.match) {
|
||||
return []string{fam.flag + "=" + m, "--tokens=" + tokens}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, &SetupError{
|
||||
Tool: "local STT model",
|
||||
Hint: fmt.Sprintf("could not recognize the model family in %q (supported: Moonshine, Whisper, transducer/Zipformer, Paraformer, SenseVoice, and the CTC families FireRedASR/NeMo/TeleSpeech/WeNet/Zipformer-CTC/Dolphin/Omnilingual). See docs/dictation.md", dir),
|
||||
}
|
||||
}
|
||||
|
||||
// singleModelFamilies maps a model's directory/file-name signature to the
|
||||
// sherpa-onnx-offline flag for that single-file family. Each family ships exactly
|
||||
// one generic model .onnx (+ tokens), so only the name tells them apart. Ordered
|
||||
// so a specific compound name wins before a broader one.
|
||||
var singleModelFamilies = []struct {
|
||||
match []string // name substrings (any hit); most-specific spellings first
|
||||
flag string
|
||||
}{
|
||||
{[]string{"fire-red-asr2-ctc", "fire-red-asr-ctc", "fire-red-asr", "firered"}, "--fire-red-asr-ctc"},
|
||||
{[]string{"nemo-ctc", "nemo_ctc"}, "--nemo-ctc-model"},
|
||||
{[]string{"zipformer2-ctc", "zipformer-ctc"}, "--zipformer-ctc-model"},
|
||||
{[]string{"telespeech"}, "--telespeech-ctc"},
|
||||
{[]string{"wenet"}, "--wenet-ctc-model"},
|
||||
{[]string{"dolphin"}, "--dolphin-model"},
|
||||
{[]string{"omnilingual"}, "--omnilingual-asr-model"},
|
||||
{[]string{"medasr"}, "--medasr"},
|
||||
{[]string{"tdnn"}, "--tdnn-model"},
|
||||
{[]string{"sense-voice", "sensevoice"}, "--sense-voice-model"},
|
||||
{[]string{"paraformer"}, "--paraformer"},
|
||||
}
|
||||
|
||||
// encoderDecoderFamilies are offline families shaped like Whisper (an encoder +
|
||||
// decoder .onnx, no joiner) but needing their own flags; matched by name before
|
||||
// the Whisper fallback claims the shape.
|
||||
var encoderDecoderFamilies = []struct {
|
||||
match []string
|
||||
encoderFlag string
|
||||
decoderFlag string
|
||||
}{
|
||||
{[]string{"fire-red-asr"}, "--fire-red-asr-encoder", "--fire-red-asr-decoder"},
|
||||
{[]string{"canary"}, "--canary-encoder", "--canary-decoder"},
|
||||
{[]string{"cohere"}, "--cohere-transcribe-encoder", "--cohere-transcribe-decoder"},
|
||||
}
|
||||
|
||||
// nameMatchesFamily reports whether the model directory name or any of its .onnx
|
||||
// filenames contain one of the family's signature substrings.
|
||||
func nameMatchesFamily(dirName string, files map[string]string, subs []string) bool {
|
||||
for _, sub := range subs {
|
||||
if strings.Contains(dirName, sub) {
|
||||
return true
|
||||
}
|
||||
for name := range files {
|
||||
if strings.HasSuffix(name, ".onnx") && strings.Contains(name, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// consistentQuantizedSet returns one file per role (in order), preferring an
|
||||
// int8 build of each but falling back to fp32 when a role has no int8 file.
|
||||
// Returns nil when any role is entirely missing.
|
||||
//
|
||||
// Models legitimately SHIP mixed quantization — e.g. the x-asr streaming
|
||||
// zipformers ship an int8 encoder and joiner with an fp32 decoder — and
|
||||
// sherpa-onnx accepts that. So this must NOT require every role to share one
|
||||
// quantization; it just needs one file per role, favoring the smaller int8 when
|
||||
// a choice exists. (When a model ships both int8 and fp32 for every role, this
|
||||
// still picks all-int8, since int8 is available for each.)
|
||||
func consistentQuantizedSet(files map[string]string, roles ...string) []string {
|
||||
pick := func(role string, int8Only bool) string {
|
||||
for name, path := range files {
|
||||
if !strings.HasSuffix(name, ".onnx") || !strings.Contains(name, role) {
|
||||
continue
|
||||
}
|
||||
if int8Only && !strings.Contains(name, "int8") {
|
||||
continue
|
||||
}
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
out := make([]string, len(roles))
|
||||
for i, role := range roles {
|
||||
p := pick(role, true) // prefer int8 (smaller, faster)
|
||||
if p == "" {
|
||||
p = pick(role, false) // fall back to fp32 for this role
|
||||
}
|
||||
if p == "" {
|
||||
return nil // this role is genuinely absent
|
||||
}
|
||||
out[i] = p
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findTokensFile returns the model's tokens file. It is usually "tokens.txt",
|
||||
// but some families (e.g. Whisper) prefix it as "<model>-tokens.txt".
|
||||
func findTokensFile(files map[string]string) string {
|
||||
if p := files["tokens.txt"]; p != "" {
|
||||
return p
|
||||
}
|
||||
for name, path := range files {
|
||||
if strings.HasSuffix(name, "tokens.txt") {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// singleModelOnnx returns the model's single .onnx file (preferring an int8
|
||||
// build), for families like SenseVoice/Paraformer that ship exactly one.
|
||||
func singleModelOnnx(files map[string]string) string {
|
||||
best := ""
|
||||
for name, path := range files {
|
||||
if !strings.HasSuffix(name, ".onnx") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(name, "int8") {
|
||||
return path
|
||||
}
|
||||
best = path
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// findOnnxMatching returns an .onnx file whose name contains `must` and, when
|
||||
// `exclude` is non-empty, does NOT contain `exclude` — used to tell the cached
|
||||
// from the uncached Moonshine decoder.
|
||||
func findOnnxMatching(files map[string]string, must, exclude string) string {
|
||||
for name, path := range files {
|
||||
if !strings.HasSuffix(name, ".onnx") || !strings.Contains(name, must) {
|
||||
continue
|
||||
}
|
||||
if exclude != "" && strings.Contains(name, exclude) {
|
||||
continue
|
||||
}
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findContains(files map[string]string, substr string) string {
|
||||
for name, path := range files {
|
||||
if strings.HasSuffix(name, ".onnx") && strings.Contains(name, substr) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseSherpaOutput extracts the transcript from sherpa-onnx-offline's output.
|
||||
// The binary prints a JSON object with a "text" field (amid diagnostic lines);
|
||||
// scan for the first line that parses as such.
|
||||
func parseSherpaOutput(out []byte) string {
|
||||
for _, line := range bytes.Split(out, []byte("\n")) {
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if len(trimmed) == 0 || trimmed[0] != '{' {
|
||||
continue
|
||||
}
|
||||
var parsed struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(trimmed, &parsed); err == nil && strings.TrimSpace(parsed.Text) != "" {
|
||||
return strings.TrimSpace(parsed.Text)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func lastLines(out []byte, n int) string {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return strings.Join(lines, " | ")
|
||||
}
|
||||
|
||||
// StreamTranscribe is unsupported for the offline binary; local streaming uses
|
||||
// the sherpa-onnx websocket server transcriber instead.
|
||||
func (l *localTranscriber) StreamTranscribe(context.Context, <-chan []byte, func(string, bool)) (string, error) {
|
||||
return "", ErrStreamingUnsupported
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// localStreamingTranscriber streams audio to the warm sherpa-onnx websocket
|
||||
// server (§6a). The server keeps encoder state across chunks (a true streaming
|
||||
// transducer), so partials arrive as decoding proceeds — no redundant
|
||||
// re-inference.
|
||||
//
|
||||
// Wire protocol (verified against sherpa-onnx's reference client, NOT the
|
||||
// plan's "16-bit PCM" description, which was imprecise): the client sends
|
||||
// binary frames of little-endian float32 samples normalized to [-1, 1]
|
||||
// (int16/32768), then the text "Done" to end; the server replies with JSON
|
||||
// result messages and finally the text "Done!" to signal completion.
|
||||
type localStreamingTranscriber struct {
|
||||
manager *ServerManager
|
||||
}
|
||||
|
||||
// NewLocalStreamingTranscriber builds a streaming transcriber backed by the
|
||||
// shared, session-long sherpa-onnx server manager.
|
||||
func NewLocalStreamingTranscriber(manager *ServerManager) Transcriber {
|
||||
return &localStreamingTranscriber{manager: manager}
|
||||
}
|
||||
|
||||
// Transcribe is not this transcriber's job — the streaming path uses
|
||||
// StreamTranscribe; the batch/Termux fallback uses the offline transcriber.
|
||||
func (l *localStreamingTranscriber) Transcribe(context.Context, []byte) (string, error) {
|
||||
return "", errors.New("local streaming transcriber does not support batch transcription")
|
||||
}
|
||||
|
||||
func (l *localStreamingTranscriber) StreamTranscribe(ctx context.Context, chunks <-chan []byte, onPartial func(string, bool)) (string, error) {
|
||||
url, err := l.manager.EnsureRunning(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conn, err := dialWithOneRestart(ctx, l.manager, url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connecting to sherpa-onnx server: %w", err)
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
// Writer goroutine: forward converted chunks, then signal end. A write error
|
||||
// (server crashed) cancels the read side via writeErr.
|
||||
writeErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
for chunk := range chunks {
|
||||
if err := conn.Write(ctx, websocket.MessageBinary, pcm16ToFloat32LE(chunk)); err != nil {
|
||||
writeErrCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte("Done"))
|
||||
}()
|
||||
|
||||
// The server reports "text" PER SEGMENT, not cumulatively for the whole
|
||||
// utterance: when it detects an endpoint (a pause between phrases, or the
|
||||
// trailing silence when recording stops) it finalizes the current segment, bumps
|
||||
// "segment", and resets "text" to "" for the next one. So we accumulate the text
|
||||
// of every segment — otherwise a new segment's (often empty) result would
|
||||
// overwrite everything already transcribed, and the transcript would vanish on
|
||||
// stop. Displayed/returned text is all segments joined in order.
|
||||
var segmentTexts []string
|
||||
joinSegments := func() string {
|
||||
parts := make([]string, 0, len(segmentTexts))
|
||||
for _, s := range segmentTexts {
|
||||
if t := strings.TrimSpace(s); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
lastText := ""
|
||||
for {
|
||||
typ, data, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
// Mid-stream failure: return the best-effort text accumulated so far
|
||||
// plus the error — already-streamed partials are never discarded (§6).
|
||||
select {
|
||||
case werr := <-writeErrCh:
|
||||
if werr != nil {
|
||||
err = werr
|
||||
}
|
||||
default:
|
||||
}
|
||||
return lastText, fmt.Errorf("sherpa-onnx stream error: %w", err)
|
||||
}
|
||||
if typ == websocket.MessageText && strings.TrimSpace(string(data)) == "Done!" {
|
||||
break
|
||||
}
|
||||
text, segment, final, ok := parseSherpaStreamResult(data)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if segment < 0 {
|
||||
segment = 0
|
||||
}
|
||||
for len(segmentTexts) <= segment {
|
||||
segmentTexts = append(segmentTexts, "")
|
||||
}
|
||||
segmentTexts[segment] = text
|
||||
lastText = joinSegments()
|
||||
if onPartial != nil {
|
||||
onPartial(lastText, final)
|
||||
}
|
||||
}
|
||||
_ = conn.Close(websocket.StatusNormalClosure, "")
|
||||
return lastText, nil
|
||||
}
|
||||
|
||||
// dialWithOneRestart dials the server, and on failure restarts it once and
|
||||
// retries — mirroring the LSP manager's single crashed-server recovery (§6/§6a).
|
||||
func dialWithOneRestart(ctx context.Context, manager *ServerManager, url string) (*websocket.Conn, error) {
|
||||
conn, _, err := websocket.Dial(ctx, url, nil)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
// The warm server may have died between EnsureRunning and Dial; reap and
|
||||
// restart once.
|
||||
_ = manager.Shutdown(ctx)
|
||||
url, rerr := manager.EnsureRunning(ctx)
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
conn, _, err = websocket.Dial(ctx, url, nil)
|
||||
return conn, err
|
||||
}
|
||||
|
||||
// parseSherpaStreamResult extracts one server JSON result. "text" is the
|
||||
// hypothesis for the CURRENT segment (segment index in "segment"), which resets
|
||||
// each time the recognizer endpoints — so the caller must accumulate per segment,
|
||||
// not treat "text" as the whole-utterance transcript. "is_final" marks a settled
|
||||
// segment.
|
||||
func parseSherpaStreamResult(data []byte) (text string, segment int, final bool, ok bool) {
|
||||
var parsed struct {
|
||||
Text string `json:"text"`
|
||||
Segment int `json:"segment"`
|
||||
IsFinal bool `json:"is_final"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return "", 0, false, false
|
||||
}
|
||||
return strings.TrimSpace(parsed.Text), parsed.Segment, parsed.IsFinal, true
|
||||
}
|
||||
|
||||
// pcm16ToFloat32LE converts a little-endian int16 PCM chunk to the little-endian
|
||||
// float32 samples (normalized to [-1, 1]) the sherpa-onnx server expects.
|
||||
func pcm16ToFloat32LE(pcm []byte) []byte {
|
||||
n := len(pcm) / 2
|
||||
out := make([]byte, n*4)
|
||||
for i := 0; i < n; i++ {
|
||||
s := int16(uint16(pcm[2*i]) | uint16(pcm[2*i+1])<<8)
|
||||
bits := math.Float32bits(float32(s) / 32768.0)
|
||||
binary.LittleEndian.PutUint32(out[4*i:], bits)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeModelDir(t *testing.T, files ...string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, f := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestLocalTranscribeMoonshineArgs(t *testing.T) {
|
||||
dir := writeModelDir(t,
|
||||
"preprocess.onnx", "encode.onnx", "uncached_decode.onnx", "cached_decode.onnx", "tokens.txt")
|
||||
var gotBin string
|
||||
var gotArgs []string
|
||||
runOutput := func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
gotBin, gotArgs = name, args
|
||||
return []byte("Started\nDone!\n\n/tmp/a.wav\n{\"text\":\" transcribed speech \"}\n----"), nil
|
||||
}
|
||||
tr, err := NewLocalTranscriber(LocalConfig{ModelPath: dir, runOutput: runOutput, tempDir: func() (string, error) { return t.TempDir(), nil }})
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalTranscriber: %v", err)
|
||||
}
|
||||
text, err := tr.Transcribe(context.Background(), wavFixture())
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if text != "transcribed speech" {
|
||||
t.Errorf("text = %q", text)
|
||||
}
|
||||
if gotBin != "sherpa-onnx-offline" {
|
||||
t.Errorf("binary = %q", gotBin)
|
||||
}
|
||||
joined := strings.Join(gotArgs, " ")
|
||||
for _, want := range []string{"--moonshine-preprocessor=", "--moonshine-encoder=", "--moonshine-uncached-decoder=", "--moonshine-cached-decoder=", "--tokens="} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("missing flag %q in %q", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTranscribeTransducerArgs(t *testing.T) {
|
||||
dir := writeModelDir(t, "encoder-epoch-99.onnx", "decoder-epoch-99.onnx", "joiner-epoch-99.onnx", "tokens.txt")
|
||||
var gotArgs []string
|
||||
runOutput := func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
gotArgs = args
|
||||
return []byte(`{"text":"hi"}`), nil
|
||||
}
|
||||
tr, _ := NewLocalTranscriber(LocalConfig{ModelPath: dir, runOutput: runOutput, tempDir: func() (string, error) { return t.TempDir(), nil }})
|
||||
if _, err := tr.Transcribe(context.Background(), wavFixture()); err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
joined := strings.Join(gotArgs, " ")
|
||||
for _, want := range []string{"--encoder=", "--decoder=", "--joiner=", "--tokens="} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("missing flag %q in %q", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectWhisperArgs(t *testing.T) {
|
||||
// Sherpa Whisper models are named "<model>-encoder.onnx" (NOT "whisper-encoder")
|
||||
// and prefix the tokens file as "<model>-tokens.txt".
|
||||
dir := writeModelDir(t, "tiny.en-encoder.onnx", "tiny.en-encoder.int8.onnx",
|
||||
"tiny.en-decoder.onnx", "tiny.en-decoder.int8.onnx", "tiny.en-tokens.txt")
|
||||
args, err := detectModelArgs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Whisper detection failed: %v", err)
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "--whisper-encoder=") || !strings.Contains(joined, "--whisper-decoder=") {
|
||||
t.Errorf("expected whisper flags, got %q", joined)
|
||||
}
|
||||
// Must not emit transducer flags (Whisper has no joiner).
|
||||
if strings.Contains(joined, "--joiner=") || strings.Contains(joined, "--encoder=") {
|
||||
t.Errorf("Whisper must not be detected as a transducer: %q", joined)
|
||||
}
|
||||
// Consistent quantization: both int8 (preferred) or both fp32, never mixed.
|
||||
if strings.Contains(joined, "encoder.int8.onnx") != strings.Contains(joined, "decoder.int8.onnx") {
|
||||
t.Errorf("mixed encoder/decoder quantization: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTransducerPrefersConsistentInt8(t *testing.T) {
|
||||
dir := writeModelDir(t,
|
||||
"encoder-epoch-99.onnx", "encoder-epoch-99.int8.onnx",
|
||||
"decoder-epoch-99.onnx", "decoder-epoch-99.int8.onnx",
|
||||
"joiner-epoch-99.onnx", "joiner-epoch-99.int8.onnx", "tokens.txt")
|
||||
args, err := detectModelArgs(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
// All three int8 (preferred), consistently.
|
||||
for _, want := range []string{"--encoder=", "encoder-epoch-99.int8.onnx", "joiner-epoch-99.int8.onnx"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("missing %q in %q", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTransducerMixedQuantization(t *testing.T) {
|
||||
// Some models ship an int8 encoder+joiner with an fp32 decoder (e.g. the
|
||||
// x-asr streaming zipformers). Detection must accept that mix, not reject it.
|
||||
dir := writeModelDir(t, "encoder.int8.onnx", "decoder.onnx", "joiner.int8.onnx", "tokens.txt")
|
||||
args, err := detectModelArgs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("mixed-quantization transducer should be detected, got %v", err)
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "encoder.int8.onnx") || !strings.Contains(joined, "decoder.onnx") || !strings.Contains(joined, "joiner.int8.onnx") {
|
||||
t.Errorf("expected the actual mixed files, got %q", joined)
|
||||
}
|
||||
// The fp32 decoder must not be mistaken for an int8 one.
|
||||
if strings.Contains(joined, "decoder.int8") {
|
||||
t.Errorf("no int8 decoder exists; should use decoder.onnx: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectSenseVoiceByDirName(t *testing.T) {
|
||||
dir := t.TempDir() // rename won't help; use a dir whose base contains sense-voice
|
||||
sv := dir + "/sherpa-onnx-sense-voice-zh-en"
|
||||
if err := os.MkdirAll(sv, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range []string{"model.int8.onnx", "tokens.txt"} {
|
||||
os.WriteFile(sv+"/"+f, []byte("x"), 0o600)
|
||||
}
|
||||
args, err := detectModelArgs(sv)
|
||||
if err != nil {
|
||||
t.Fatalf("SenseVoice detection failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(args, " "), "--sense-voice-model=") {
|
||||
t.Errorf("expected sense-voice flag, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// writeNamedModelDir creates a model directory whose base name carries the family
|
||||
// signature (how sherpa-onnx distributes single-file / CTC models), with the given
|
||||
// files, and returns its path.
|
||||
func writeNamedModelDir(t *testing.T, base string, files ...string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir() + "/" + base
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := os.WriteFile(dir+"/"+f, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestDetectSingleFileCTCFamilies(t *testing.T) {
|
||||
// One generic model.onnx + tokens, family told apart by the directory name.
|
||||
cases := []struct{ base, wantFlag string }{
|
||||
{"sherpa-onnx-fire-red-asr2-ctc-zh_en-int8-2026-02-25", "--fire-red-asr-ctc="},
|
||||
{"sherpa-onnx-nemo-ctc-en-conformer-medium", "--nemo-ctc-model="},
|
||||
{"sherpa-onnx-zipformer-ctc-zh-2024", "--zipformer-ctc-model="},
|
||||
{"sherpa-onnx-telespeech-ctc-int8", "--telespeech-ctc="},
|
||||
{"sherpa-onnx-wenet-ctc-en", "--wenet-ctc-model="},
|
||||
{"sherpa-onnx-dolphin-base-ctc-multi-lang", "--dolphin-model="},
|
||||
{"sherpa-onnx-omnilingual-asr-ctc", "--omnilingual-asr-model="},
|
||||
{"sherpa-onnx-tdnn-yesno", "--tdnn-model="},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
dir := writeNamedModelDir(t, tc.base, "model.int8.onnx", "tokens.txt")
|
||||
args, err := detectModelArgs(dir)
|
||||
if err != nil {
|
||||
t.Errorf("%s: detection failed: %v", tc.base, err)
|
||||
continue
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, tc.wantFlag) || !strings.Contains(joined, "--tokens=") {
|
||||
t.Errorf("%s: got %q, want %s", tc.base, joined, tc.wantFlag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectEncoderDecoderFamiliesBeforeWhisper(t *testing.T) {
|
||||
// FireRedASR-AED / Canary share Whisper's encoder+decoder shape, so they must be
|
||||
// disambiguated by name — not fall through to --whisper-encoder.
|
||||
cases := []struct{ base, wantFlag string }{
|
||||
{"sherpa-onnx-fire-red-asr-large-zh_en", "--fire-red-asr-encoder="},
|
||||
{"sherpa-onnx-canary-180m-flash", "--canary-encoder="},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
dir := writeNamedModelDir(t, tc.base, "encoder.int8.onnx", "decoder.int8.onnx", "tokens.txt")
|
||||
args, err := detectModelArgs(dir)
|
||||
if err != nil {
|
||||
t.Errorf("%s: detection failed: %v", tc.base, err)
|
||||
continue
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, tc.wantFlag) {
|
||||
t.Errorf("%s: got %q, want %s", tc.base, joined, tc.wantFlag)
|
||||
}
|
||||
if strings.Contains(joined, "--whisper-") {
|
||||
t.Errorf("%s must not be detected as Whisper: %q", tc.base, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectUnknownFamilyFailsCleanly(t *testing.T) {
|
||||
// A model with tokens but no recognizable family → a clear SetupError, not a
|
||||
// crash or wrong flags.
|
||||
dir := writeModelDir(t, "some-random-model.onnx", "tokens.txt")
|
||||
_, err := detectModelArgs(dir)
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want a clean *SetupError, got %v", err)
|
||||
}
|
||||
if !strings.Contains(setupErr.Error(), "could not recognize") {
|
||||
t.Errorf("expected a recognizable failure message, got %q", setupErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTranscribeMissingTokens(t *testing.T) {
|
||||
dir := writeModelDir(t, "encoder.onnx", "decoder.onnx", "joiner.onnx") // no tokens.txt
|
||||
tr, _ := NewLocalTranscriber(LocalConfig{ModelPath: dir, runOutput: func(context.Context, string, ...string) ([]byte, error) { return nil, nil }, tempDir: func() (string, error) { return t.TempDir(), nil }})
|
||||
_, err := tr.Transcribe(context.Background(), wavFixture())
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError for missing tokens, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalTranscribeMissingBinary(t *testing.T) {
|
||||
dir := writeModelDir(t, "preprocess.onnx", "encode.onnx", "uncached_decode.onnx", "cached_decode.onnx", "tokens.txt")
|
||||
runOutput := func(context.Context, string, ...string) ([]byte, error) { return nil, exec.ErrNotFound }
|
||||
tr, _ := NewLocalTranscriber(LocalConfig{ModelPath: dir, runOutput: runOutput, tempDir: func() (string, error) { return t.TempDir(), nil }})
|
||||
_, err := tr.Transcribe(context.Background(), wavFixture())
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError for missing binary, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSherpaOutput(t *testing.T) {
|
||||
out := []byte("Started\nDone!\n\naudio.wav\n{\"lang\":\"\",\"text\":\" the quick brown fox \",\"tokens\":[]}\n----\nnum threads: 2")
|
||||
if got := parseSherpaOutput(out); got != "the quick brown fox" {
|
||||
t.Errorf("parsed %q", got)
|
||||
}
|
||||
if got := parseSherpaOutput([]byte("no json here")); got != "" {
|
||||
t.Errorf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLocalTranscriberRequiresModelPath(t *testing.T) {
|
||||
_, err := NewLocalTranscriber(LocalConfig{})
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// OpenAI Realtime transcription-only wants 24kHz PCM16 (§6b); the recorder is
|
||||
// built to match via the SampleRate hint.
|
||||
const openAIRealtimeSampleRate = 24000
|
||||
|
||||
// OpenAIRealtimeConfig configures the OpenAI Realtime transcription transcriber
|
||||
// (§6b, the credential-reuse streaming alternative). Reuses the OpenAI key.
|
||||
type OpenAIRealtimeConfig struct {
|
||||
APIKey string
|
||||
Model string // default "gpt-4o-transcribe"
|
||||
// BaseURL overrides the wss endpoint (tests point it at a fake server).
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type openAIRealtimeTranscriber struct {
|
||||
cfg OpenAIRealtimeConfig
|
||||
}
|
||||
|
||||
// NewOpenAIRealtimeTranscriber builds the OpenAI Realtime streaming transcriber.
|
||||
func NewOpenAIRealtimeTranscriber(cfg OpenAIRealtimeConfig) (Transcriber, error) {
|
||||
if strings.TrimSpace(cfg.APIKey) == "" {
|
||||
return nil, &SetupError{
|
||||
Tool: "OpenAI API key",
|
||||
Hint: "set an OpenAI API key (OPENAI_API_KEY, or `zero auth`) to use OpenAI Realtime streaming dictation",
|
||||
}
|
||||
}
|
||||
if cfg.Model == "" {
|
||||
cfg.Model = "gpt-4o-transcribe"
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = "wss://api.openai.com/v1/realtime?intent=transcription"
|
||||
}
|
||||
return &openAIRealtimeTranscriber{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (o *openAIRealtimeTranscriber) Transcribe(context.Context, []byte) (string, error) {
|
||||
return "", ErrStreamingUnsupported
|
||||
}
|
||||
|
||||
func (o *openAIRealtimeTranscriber) SampleRate() int { return openAIRealtimeSampleRate }
|
||||
|
||||
func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks <-chan []byte, onPartial func(string, bool)) (string, error) {
|
||||
conn, _, err := websocket.Dial(ctx, o.cfg.BaseURL, &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{
|
||||
"Authorization": {"Bearer " + o.cfg.APIKey},
|
||||
"OpenAI-Beta": {"realtime=v1"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connecting to OpenAI Realtime: %w", err)
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
// Configure a transcription-only session (pcm16 in, gpt-4o-transcribe).
|
||||
sessionUpdate := map[string]any{
|
||||
"type": "transcription_session.update",
|
||||
"session": map[string]any{
|
||||
"input_audio_format": "pcm16",
|
||||
"input_audio_transcription": map[string]any{
|
||||
"model": o.cfg.Model,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := writeJSON(ctx, conn, sessionUpdate); err != nil {
|
||||
return "", fmt.Errorf("configuring OpenAI Realtime session: %w", err)
|
||||
}
|
||||
|
||||
writeErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
for chunk := range chunks {
|
||||
appendMsg := map[string]any{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.StdEncoding.EncodeToString(chunk),
|
||||
}
|
||||
if err := writeJSON(ctx, conn, appendMsg); err != nil {
|
||||
writeErrCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
// Commit the buffered audio to force final transcription of the utterance.
|
||||
writeErrCh <- writeJSON(ctx, conn, map[string]any{"type": "input_audio_buffer.commit"})
|
||||
}()
|
||||
|
||||
// OpenAI deltas are incremental (append), unlike Deepgram/sherpa's cumulative
|
||||
// text. Accumulate completed segments plus the in-progress delta buffer.
|
||||
var finalized, current strings.Builder
|
||||
compose := func() string {
|
||||
return strings.TrimSpace(finalized.String() + current.String())
|
||||
}
|
||||
committed := false
|
||||
for {
|
||||
typ, data, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
if websocket.CloseStatus(err) == websocket.StatusNormalClosure {
|
||||
return compose(), nil
|
||||
}
|
||||
select {
|
||||
case werr := <-writeErrCh:
|
||||
if werr != nil {
|
||||
err = werr
|
||||
}
|
||||
default:
|
||||
}
|
||||
return compose(), fmt.Errorf("OpenAI Realtime stream error: %w", err)
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
evt := parseRealtimeEvent(data)
|
||||
switch evt.kind {
|
||||
case realtimeDelta:
|
||||
current.WriteString(evt.text)
|
||||
if onPartial != nil {
|
||||
onPartial(compose(), false)
|
||||
}
|
||||
case realtimeCompleted:
|
||||
// A completed item replaces the in-progress delta buffer with the
|
||||
// server's authoritative transcript for that segment.
|
||||
if evt.text != "" {
|
||||
if finalized.Len() > 0 {
|
||||
finalized.WriteString(" ")
|
||||
}
|
||||
finalized.WriteString(strings.TrimSpace(evt.text))
|
||||
}
|
||||
current.Reset()
|
||||
if onPartial != nil {
|
||||
onPartial(compose(), true)
|
||||
}
|
||||
// Once we've committed the buffer (user stopped) and the server has
|
||||
// returned a completed transcription, the utterance is done.
|
||||
if committed {
|
||||
_ = conn.Close(websocket.StatusNormalClosure, "")
|
||||
return compose(), nil
|
||||
}
|
||||
case realtimeCommitted:
|
||||
committed = true
|
||||
case realtimeError:
|
||||
return compose(), fmt.Errorf("OpenAI Realtime error: %s", evt.text)
|
||||
}
|
||||
// The writer signals commit completion out of band; observe it so a
|
||||
// stop with no further audio still flips `committed`.
|
||||
select {
|
||||
case werr := <-writeErrCh:
|
||||
if werr == nil {
|
||||
committed = true
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type realtimeEventKind int
|
||||
|
||||
const (
|
||||
realtimeOther realtimeEventKind = iota
|
||||
realtimeDelta
|
||||
realtimeCompleted
|
||||
realtimeCommitted
|
||||
realtimeError
|
||||
)
|
||||
|
||||
type realtimeEvent struct {
|
||||
kind realtimeEventKind
|
||||
text string
|
||||
}
|
||||
|
||||
func parseRealtimeEvent(data []byte) realtimeEvent {
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
Delta string `json:"delta"`
|
||||
Transcript string `json:"transcript"`
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return realtimeEvent{kind: realtimeOther}
|
||||
}
|
||||
switch msg.Type {
|
||||
case "conversation.item.input_audio_transcription.delta":
|
||||
return realtimeEvent{kind: realtimeDelta, text: msg.Delta}
|
||||
case "conversation.item.input_audio_transcription.completed":
|
||||
return realtimeEvent{kind: realtimeCompleted, text: msg.Transcript}
|
||||
case "input_audio_buffer.committed":
|
||||
return realtimeEvent{kind: realtimeCommitted}
|
||||
case "error":
|
||||
return realtimeEvent{kind: realtimeError, text: msg.Error.Message}
|
||||
}
|
||||
return realtimeEvent{kind: realtimeOther}
|
||||
}
|
||||
|
||||
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.Write(ctx, websocket.MessageText, data)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// wsTestServer runs handler over a real localhost websocket, returning a wss->ws
|
||||
// base URL the transcribers can dial. Exercising the real coder/websocket client
|
||||
// against a real server is the closest unit test to the wire protocol.
|
||||
func wsTestServer(t *testing.T, handler func(ctx context.Context, c *websocket.Conn)) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
handler(r.Context(), c)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
}
|
||||
|
||||
func TestDeepgramStreamTranscribe(t *testing.T) {
|
||||
var gotAuth string
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
defer c.Close(websocket.StatusNormalClosure, "")
|
||||
// Read audio frames until CloseStream, then emit interim + final results.
|
||||
for {
|
||||
typ, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"Results","is_final":false,"channel":{"alternatives":[{"transcript":"hello"}]}}`))
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"Results","is_final":true,"channel":{"alternatives":[{"transcript":"hello world"}]}}`))
|
||||
})
|
||||
|
||||
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "dg-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = gotAuth
|
||||
|
||||
var partials []string
|
||||
chunks := make(chan []byte, 2)
|
||||
chunks <- make([]byte, 320)
|
||||
close(chunks)
|
||||
final, err := tr.StreamTranscribe(context.Background(), chunks, func(text string, _ bool) {
|
||||
partials = append(partials, text)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTranscribe: %v", err)
|
||||
}
|
||||
if final != "hello world" {
|
||||
t.Errorf("final = %q, want 'hello world'", final)
|
||||
}
|
||||
if len(partials) == 0 || partials[0] != "hello" {
|
||||
t.Errorf("expected an interim 'hello' partial, got %v", partials)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepgramSampleRateDefault(t *testing.T) {
|
||||
tr, _ := NewDeepgramTranscriber(DeepgramConfig{APIKey: "k"})
|
||||
if RequiredSampleRate(tr) != DefaultSampleRate {
|
||||
t.Errorf("Deepgram sample rate = %d, want %d", RequiredSampleRate(tr), DefaultSampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepgramMissingKey(t *testing.T) {
|
||||
if _, err := NewDeepgramTranscriber(DeepgramConfig{}); err == nil {
|
||||
t.Fatal("expected setup error for missing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRealtimeStreamTranscribe(t *testing.T) {
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
defer c.Close(websocket.StatusNormalClosure, "")
|
||||
committed := false
|
||||
for {
|
||||
typ, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &msg)
|
||||
switch msg.Type {
|
||||
case "transcription_session.update":
|
||||
// Emit incremental deltas as if audio were flowing.
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"conversation.item.input_audio_transcription.delta","delta":"the "}`))
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"conversation.item.input_audio_transcription.delta","delta":"answer"}`))
|
||||
case "input_audio_buffer.commit":
|
||||
committed = true
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"input_audio_buffer.committed"}`))
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"conversation.item.input_audio_transcription.completed","transcript":"the answer"}`))
|
||||
}
|
||||
_ = committed
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-key", BaseURL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if RequiredSampleRate(tr) != openAIRealtimeSampleRate {
|
||||
t.Errorf("OpenAI Realtime sample rate = %d, want %d", RequiredSampleRate(tr), openAIRealtimeSampleRate)
|
||||
}
|
||||
|
||||
chunks := make(chan []byte, 1)
|
||||
chunks <- make([]byte, 480)
|
||||
close(chunks)
|
||||
done := make(chan struct{})
|
||||
var final string
|
||||
var ferr error
|
||||
go func() {
|
||||
final, ferr = tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("StreamTranscribe did not complete")
|
||||
}
|
||||
if ferr != nil {
|
||||
t.Fatalf("StreamTranscribe: %v", ferr)
|
||||
}
|
||||
if final != "the answer" {
|
||||
t.Errorf("final = %q, want 'the answer'", final)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalStreamingAccumulatesSegments is the regression for the "text vanishes
|
||||
// on stop" bug: the sherpa server reports text PER SEGMENT and resets it to "" when
|
||||
// it endpoints, so the transcriber must accumulate across segments rather than let
|
||||
// a new (empty) segment overwrite everything already recognized.
|
||||
func TestLocalStreamingAccumulatesSegments(t *testing.T) {
|
||||
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
|
||||
defer c.Close(websocket.StatusNormalClosure, "")
|
||||
go func() { // drain the client's audio + "Done"
|
||||
for {
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
for _, m := range []string{
|
||||
`{"text":" hello","segment":0,"is_final":false}`,
|
||||
`{"text":" hello world","segment":0,"is_final":true}`,
|
||||
`{"text":"","segment":1,"is_final":false}`, // endpoint reset — must NOT wipe seg 0
|
||||
`{"text":" foo bar","segment":1,"is_final":true}`,
|
||||
`{"text":"","segment":2,"is_final":true}`, // trailing empty segment at stop
|
||||
} {
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(m))
|
||||
}
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte("Done!"))
|
||||
})
|
||||
|
||||
colon := strings.LastIndex(url, ":")
|
||||
port, err := strconv.Atoi(url[colon+1:])
|
||||
if err != nil {
|
||||
t.Fatalf("parsing test server port from %q: %v", url, err)
|
||||
}
|
||||
mgr := NewServerManager(ServerConfig{
|
||||
ModelPath: streamingModelDir(t),
|
||||
Port: port,
|
||||
starter: func(commandSpec) (processHandle, io.ReadCloser, error) { return newFakeProcess(), nil, nil },
|
||||
healthCheck: func(context.Context, int) error { return nil },
|
||||
aliveCheck: func(int) bool { return false },
|
||||
})
|
||||
tr := NewLocalStreamingTranscriber(mgr)
|
||||
|
||||
chunks := make(chan []byte)
|
||||
close(chunks) // no audio; the writer immediately sends "Done"
|
||||
var partials []string
|
||||
text, err := tr.StreamTranscribe(context.Background(), chunks, func(s string, _ bool) {
|
||||
partials = append(partials, s)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTranscribe: %v", err)
|
||||
}
|
||||
if text != "hello world foo bar" {
|
||||
t.Errorf("final text = %q, want %q", text, "hello world foo bar")
|
||||
}
|
||||
// Once text has appeared, no later partial may go empty — that would be the
|
||||
// segment-reset wiping the transcript, the exact bug this guards.
|
||||
seen := false
|
||||
for _, p := range partials {
|
||||
if p != "" {
|
||||
seen = true
|
||||
}
|
||||
if seen && p == "" {
|
||||
t.Errorf("transcript went empty after text appeared; partials=%v", partials)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepgramResultIgnoresNonResults(t *testing.T) {
|
||||
if _, _, ok := parseDeepgramResult([]byte(`{"type":"Metadata"}`)); ok {
|
||||
t.Error("Metadata messages should be ignored")
|
||||
}
|
||||
tr, final, ok := parseDeepgramResult([]byte(`{"type":"Results","is_final":true,"channel":{"alternatives":[{"transcript":"hi"}]}}`))
|
||||
if !ok || tr != "hi" || !final {
|
||||
t.Errorf("parse = (%q,%v,%v)", tr, final, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRealtimeEvent(t *testing.T) {
|
||||
if e := parseRealtimeEvent([]byte(`{"type":"conversation.item.input_audio_transcription.delta","delta":"x"}`)); e.kind != realtimeDelta || e.text != "x" {
|
||||
t.Errorf("delta parse: %+v", e)
|
||||
}
|
||||
if e := parseRealtimeEvent([]byte(`{"type":"error","error":{"message":"boom"}}`)); e.kind != realtimeError || e.text != "boom" {
|
||||
t.Errorf("error parse: %+v", e)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
@@ -143,12 +144,19 @@ func providerConfigCheck(profile config.ProviderProfile) Check {
|
||||
// Report credential PRESENCE, never the value. Reported under a non-sensitive
|
||||
// key ("credentialConfigured"): the prior "apiKey" key was itself sensitive, so
|
||||
// check()'s redaction scrubbed the indicator to [REDACTED] — making "set"/"not
|
||||
// set" invisible. A profile can authenticate via a raw auth-header value instead
|
||||
// of APIKey, and both are trimmed so a whitespace-only value reads "not set"
|
||||
// (matching ProviderSnapshot.APIKeySet).
|
||||
// set" invisible. HasConfiguredCredential is the shared definition of
|
||||
// "key-authed" (inline key, raw auth header, or a key in the encrypted
|
||||
// credential store), matching ProviderSnapshot.APIKeySet — checking only the
|
||||
// inline fields made doctor and `zero providers list` disagree about the
|
||||
// same stored-key profile. A stored OAuth login counts too: a keyless
|
||||
// token-login provider (e.g. ChatGPT) is fully able to make requests and
|
||||
// must not fail the one tool meant to verify setup.
|
||||
credential := "not set"
|
||||
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
|
||||
switch {
|
||||
case profile.HasConfiguredCredential():
|
||||
credential = "set"
|
||||
case providerHasStoredOAuthLogin(profile):
|
||||
credential = "oauth login"
|
||||
}
|
||||
details := map[string]any{
|
||||
"name": profile.Name,
|
||||
@@ -169,6 +177,22 @@ func providerConfigCheck(profile config.ProviderProfile) Check {
|
||||
return check("provider.config", "Provider config", StatusPass, fmt.Sprintf("Provider config loaded for %s.", providerName(profile)), details)
|
||||
}
|
||||
|
||||
// providerHasStoredOAuthLogin reports whether the token store holds a login for
|
||||
// any of the profile's candidates — the same candidate set and normalized-key
|
||||
// matching the runtime resolver uses. Errors degrade to false.
|
||||
func providerHasStoredOAuthLogin(profile config.ProviderProfile) bool {
|
||||
candidates := profile.OAuthLoginCandidates()
|
||||
if len(candidates) == 0 {
|
||||
return false
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, _, ok := oauth.FirstStored(store, candidates)
|
||||
return ok
|
||||
}
|
||||
|
||||
// localProviderBaseURL reports whether the configured base_url is a loopback host
|
||||
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
|
||||
func localProviderBaseURL(baseURL string) bool {
|
||||
|
||||
@@ -122,17 +122,17 @@ func createSecretFile(path string) ([]byte, error) {
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
|
||||
}
|
||||
// Remember the lock-creation error itself rather than a subsequent
|
||||
// "secret file doesn't exist yet" read error -- otherwise a real
|
||||
// contention/ACL problem is masked by the expected-while-waiting
|
||||
// ErrNotExist once the retries are exhausted.
|
||||
lastErr = err
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
return data, nil
|
||||
} else {
|
||||
lastErr = rerr
|
||||
}
|
||||
time.Sleep(secretRetryDelay)
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret lock %s", lockPath)
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
|
||||
}
|
||||
|
||||
func writeNewSecretFile(path string) ([]byte, error) {
|
||||
|
||||
+10
-2
@@ -38,8 +38,16 @@ func ValidateKey(key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderKey builds the store key for a provider login.
|
||||
func ProviderKey(name string) string { return KeyPrefixProvider + name }
|
||||
// ProviderKey builds the store key for a provider login, normalizing the name
|
||||
// to lower case. Every write (Manager.Login, the ChatGPT flow) and every
|
||||
// lookup (FirstStored, GetFresh, logout, status filters) funnels through here,
|
||||
// so normalizing at this one choke point keeps them symmetric: without it,
|
||||
// `zero auth login xAI` stored "provider:xAI" while the profile scaffolded for
|
||||
// it looked up "provider:xai" case-sensitively — a fresh, successful login
|
||||
// that was invisible to the runtime.
|
||||
func ProviderKey(name string) string {
|
||||
return KeyPrefixProvider + strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
// FirstStored returns the token and its ProviderKey for the FIRST candidate name
|
||||
// that has a token in the store, with ok=false when none do. Callers pass
|
||||
|
||||
@@ -126,3 +126,20 @@ func TestResolveStorePathHonorsOverride(t *testing.T) {
|
||||
t.Fatalf("path = %q, want %q", path, override)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderKeyNormalizesCase: every provider-login write and lookup funnels
|
||||
// through ProviderKey, so `zero auth login xAI` and the scaffolded profile's
|
||||
// candidate "xai" must resolve to the SAME store entry — the raw-typed casing
|
||||
// previously produced an invisible login.
|
||||
func TestProviderKeyNormalizesCase(t *testing.T) {
|
||||
if got := ProviderKey(" xAI "); got != "provider:xai" {
|
||||
t.Fatalf("ProviderKey must trim and lower-case, got %q", got)
|
||||
}
|
||||
store, _ := newTestStore(t)
|
||||
if err := store.Save(ProviderKey("xAI"), Token{AccessToken: "tok"}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
if _, key, ok := FirstStored(store, []string{"xai"}); !ok || key != "provider:xai" {
|
||||
t.Fatalf("candidate \"xai\" must find the mixed-case login, ok=%v key=%q", ok, key)
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,8 @@ func RuntimeUnsupportedReason(descriptor Descriptor) string {
|
||||
var descriptors = []Descriptor{
|
||||
// GitLawb OpenGateway — the recommended default. An OpenAI-compatible gateway
|
||||
// that smart-routes by model id across upstream providers (xiaomi-mimo,
|
||||
// minimax, qwen, google, nvidia, z-ai). Flat /v1/chat/completions with a
|
||||
// Bearer ogw_live_… key; listed first and badged in every picker.
|
||||
// minimax, qwen, google, nvidia, tencent, z-ai). Flat /v1/chat/completions
|
||||
// with a Bearer ogw_live_… key; listed first and badged in every picker.
|
||||
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
|
||||
openAI("openai", "OpenAI", "https://api.openai.com/v1", "gpt-4.1", []string{"OPENAI_API_KEY"}),
|
||||
anthropic("anthropic", "Anthropic", "https://api.anthropic.com", "claude-sonnet-4.5", []string{"ANTHROPIC_API_KEY"}),
|
||||
|
||||
@@ -140,12 +140,13 @@ var curatedModels = map[string][]Model{
|
||||
"zai": zaiCuratedModels,
|
||||
"zai-cn": zaiCuratedModels,
|
||||
// OpenGateway smart-routes by model id across its upstream providers
|
||||
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). These are
|
||||
// the curated coding defaults; the gateway accepts any model its upstreams
|
||||
// expose, so users can also type an id the picker doesn't list.
|
||||
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, tencent, z-ai).
|
||||
// These are the curated coding defaults; the gateway accepts any model its
|
||||
// upstreams expose, so users can also type an id the picker doesn't list.
|
||||
"gitlawb-opengateway": {
|
||||
{ID: "mimo-v2.5-pro", Description: "catalog default (Xiaomi MiMo)"},
|
||||
{ID: "mimo-v2.5-pro-ultraspeed", Description: "fast model (Xiaomi MiMo)"},
|
||||
{ID: "tencent/hy3", Description: "free Tencent HY3 model"},
|
||||
{ID: "MiniMax-M3", Description: "MiniMax model"},
|
||||
{ID: "qwen-plus", Description: "Qwen model"},
|
||||
{ID: "gemini-2.5-pro", Description: "long-context model (Google)"},
|
||||
|
||||
@@ -47,6 +47,11 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
want: []string{"glm-4.5", "glm-4.6", "glm-4.5-air", "glm-z1-air"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
{
|
||||
provider: "gitlawb-opengateway",
|
||||
want: []string{"mimo-v2.5-pro", "tencent/hy3"},
|
||||
notWant: []string{"openai/gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -31,7 +31,7 @@ func LooksLikeCodingModelID(id string) bool {
|
||||
"mistral", "codestral", "devstral", "magistral", "ministral",
|
||||
"grok", "glm", "command", "nemotron", "mixtral", "coder",
|
||||
"code", "chat", "instruct", "reasoner", "reasoning", "mimo",
|
||||
"maverick", "scout", "bankr",
|
||||
"hy3", "tencent", "maverick", "scout", "bankr",
|
||||
} {
|
||||
if strings.Contains(normalized, term) {
|
||||
return true
|
||||
@@ -57,6 +57,67 @@ func IsKnownNonCodingModelID(id string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsSTTModel reports whether a model transcribes speech to text — the models
|
||||
// IsKnownNonCodingModelID filters OUT of the coding picker, filtered back IN for
|
||||
// the /stt-model picker.
|
||||
//
|
||||
// The reliable signal is the model's declared modalities, not its name: a
|
||||
// transcriber consumes audio and emits text. Anything that emits audio is
|
||||
// text-to-speech or speech-to-speech, never a transcriber, so audio output is a
|
||||
// hard disqualifier. Only when a catalog entry carries no usable modality
|
||||
// metadata (common for the Groq/OpenAI transcription models, which models.dev
|
||||
// lists sparsely) do we fall back to a name heuristic.
|
||||
func IsSTTModel(model Model) bool {
|
||||
audioIn := containsFold(model.InputModalities, "audio")
|
||||
audioOut := containsFold(model.OutputModalities, "audio")
|
||||
textOut := containsFold(model.OutputModalities, "text")
|
||||
|
||||
// Emits audio → TTS or speech-to-speech, regardless of a transcription-like
|
||||
// name. This is what stops gpt-4o-audio-preview / *-realtime from slipping in.
|
||||
if audioOut {
|
||||
return false
|
||||
}
|
||||
// Consumes audio and emits text (or emits nothing explicit) → transcriber.
|
||||
if audioIn && (textOut || len(model.OutputModalities) == 0) {
|
||||
return true
|
||||
}
|
||||
// Declares input modalities but none is audio → cannot be speech-to-text,
|
||||
// whatever it is called.
|
||||
if len(model.InputModalities) > 0 && !audioIn {
|
||||
return false
|
||||
}
|
||||
// No usable modality metadata: fall back to the name heuristic.
|
||||
return IsSTTModelID(model.ID)
|
||||
}
|
||||
|
||||
// IsSTTModelID is the name-only heuristic used when a catalog entry declares no
|
||||
// modalities. It matches the known transcription model families and explicitly
|
||||
// rejects text-to-speech names, so it never confuses a TTS model for a
|
||||
// transcriber. Weaker than the modality signal in IsSTTModel — a fallback, not
|
||||
// the primary test.
|
||||
func IsSTTModelID(id string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(id))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
// Text-to-speech / speech-synthesis names are never transcription.
|
||||
for _, tts := range []string{"tts", "text-to-speech"} {
|
||||
if strings.Contains(normalized, tts) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, term := range []string{
|
||||
"whisper", "transcribe", "transcription",
|
||||
"scribe", "parakeet", "canary", "moonshine",
|
||||
"sense-voice", "sensevoice", "wav2vec", "conformer",
|
||||
} {
|
||||
if strings.Contains(normalized, term) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasCodingTag(tags []string) bool {
|
||||
for _, tag := range tags {
|
||||
normalized := strings.ToLower(strings.TrimSpace(tag))
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func TestLooksLikeCodingModelID(t *testing.T) {
|
||||
coding := []string{"gpt-4o", "claude-sonnet-4.5", "o1", "o3-mini", "o4-mini", "qwen2.5-coder", "deepseek-chat", "kimi-k2", "codestral-latest", "grok-code"}
|
||||
coding := []string{"gpt-4o", "claude-sonnet-4.5", "o1", "o3-mini", "o4-mini", "qwen2.5-coder", "deepseek-chat", "kimi-k2", "codestral-latest", "grok-code", "tencent/hy3"}
|
||||
for _, id := range coding {
|
||||
if !LooksLikeCodingModelID(id) {
|
||||
t.Errorf("LooksLikeCodingModelID(%q) = false, want true", id)
|
||||
|
||||
@@ -80,6 +80,14 @@ func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
|
||||
"tags": ["coding", "free"],
|
||||
"cost": {"input": 0, "output": 0}
|
||||
},
|
||||
{
|
||||
"id": "tencent/hy3",
|
||||
"name": "Tencent HY3",
|
||||
"description": "free Tencent chat route",
|
||||
"context_window": 262144,
|
||||
"tools": true,
|
||||
"tags": ["free"]
|
||||
},
|
||||
{
|
||||
"id": "image-route",
|
||||
"name": "Image Route",
|
||||
@@ -92,8 +100,8 @@ func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ParseOpenGatewayCatalog returned error: %v", err)
|
||||
}
|
||||
if got := strings.Join(modelIDs(models), ","); got != "minimax-m3" {
|
||||
t.Fatalf("models = %#v, want one gateway coding model", got)
|
||||
if got := strings.Join(modelIDs(models), ","); got != "minimax-m3,tencent/hy3" {
|
||||
t.Fatalf("models = %#v, want gateway coding models", got)
|
||||
}
|
||||
model := models[0]
|
||||
if model.ID != "minimax-m3" || model.Description != "agentic coding route" {
|
||||
@@ -108,6 +116,9 @@ func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
|
||||
if model.Source != "opengateway" {
|
||||
t.Fatalf("gateway source = %q, want opengateway", model.Source)
|
||||
}
|
||||
if models[1].ID != "tencent/hy3" || models[1].ContextWindow != 262144 || !models[1].ToolCall {
|
||||
t.Fatalf("gateway HY3 model = %#v, want Tencent HY3 with context/tools", models[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package providermodelcatalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsSTTModelID(t *testing.T) {
|
||||
in := []string{"whisper-1", "whisper-large-v3-turbo", "distil-whisper-large-v3-en",
|
||||
"gpt-4o-transcribe", "gpt-4o-mini-transcribe", "nova-3-scribe", "parakeet-tdt-1.1b", "canary-1b"}
|
||||
for _, id := range in {
|
||||
if !IsSTTModelID(id) {
|
||||
t.Errorf("%q should be an STT model by name", id)
|
||||
}
|
||||
}
|
||||
out := []string{"gpt-4o", "claude-sonnet-4", "tts-1", "gpt-4o-mini-tts",
|
||||
"dall-e-3", "text-to-speech-1", ""}
|
||||
for _, id := range out {
|
||||
if IsSTTModelID(id) {
|
||||
t.Errorf("%q should NOT be an STT model by name", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSTTModelUsesModalities(t *testing.T) {
|
||||
// Audio in, text out → transcriber, even with an unfamiliar name.
|
||||
if !IsSTTModel(Model{ID: "acme-speech-v2", InputModalities: []string{"audio"}, OutputModalities: []string{"text"}}) {
|
||||
t.Error("audio-in/text-out model should be classified STT regardless of name")
|
||||
}
|
||||
// Audio out → TTS or speech-to-speech, never STT, even if the name says "transcribe".
|
||||
if IsSTTModel(Model{ID: "weird-transcribe-tts", InputModalities: []string{"text"}, OutputModalities: []string{"audio"}}) {
|
||||
t.Error("an audio-OUTPUT model must never be classified STT")
|
||||
}
|
||||
// Realtime speech-to-speech: audio in AND audio out → not a transcriber.
|
||||
if IsSTTModel(Model{ID: "gpt-4o-realtime", InputModalities: []string{"audio", "text"}, OutputModalities: []string{"audio", "text"}}) {
|
||||
t.Error("audio-in AND audio-out (speech-to-speech) must not be classified STT")
|
||||
}
|
||||
// Text-only LLM: input declared, no audio → not STT.
|
||||
if IsSTTModel(Model{ID: "gpt-4o", InputModalities: []string{"text", "image"}, OutputModalities: []string{"text"}}) {
|
||||
t.Error("a text/image-in model must not be classified STT")
|
||||
}
|
||||
// No modality metadata: fall back to the name heuristic.
|
||||
if !IsSTTModel(Model{ID: "whisper-large-v3-turbo"}) {
|
||||
t.Error("with no modalities, a whisper id should fall back to STT-true")
|
||||
}
|
||||
if IsSTTModel(Model{ID: "gpt-4o-mini-tts"}) {
|
||||
t.Error("with no modalities, a tts id should be STT-false")
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,56 @@ func TestNewCreatesOpenAIProviderWithFactoryOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPassesOpenGatewayHY3ModelThrough(t *testing.T) {
|
||||
transport := &captureTransport{
|
||||
responseBody: "data: [DONE]\n\n",
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "opengateway",
|
||||
CatalogID: "gitlawb-opengateway",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "ogw_live_test",
|
||||
Model: "tencent/hy3",
|
||||
}, Options{HTTPClient: client})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
for range stream {
|
||||
}
|
||||
|
||||
if transport.request.URL.String() != "https://opengateway.gitlawb.com/v1/chat/completions" {
|
||||
t.Fatalf("request URL = %q, want OpenGateway chat completions", transport.request.URL.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if body["model"] != "tencent/hy3" {
|
||||
t.Fatalf("model = %q, want tencent/hy3 passthrough", body["model"])
|
||||
}
|
||||
|
||||
metadata, err := ResolveRuntimeMetadata(config.ProviderProfile{
|
||||
Name: "opengateway",
|
||||
CatalogID: "gitlawb-opengateway",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
Model: "tencent/hy3",
|
||||
}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveRuntimeMetadata() error = %v", err)
|
||||
}
|
||||
if metadata.ProviderKind != config.ProviderKindOpenAICompatible || metadata.APIModel != "tencent/hy3" {
|
||||
t.Fatalf("runtime metadata = %#v, want OpenAI-compatible tencent/hy3", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewThreadsCustomProviderHeaders(t *testing.T) {
|
||||
transport := &captureTransport{
|
||||
responseBody: "data: [DONE]\n\n",
|
||||
|
||||
@@ -200,20 +200,23 @@ func normalizeHostScope(raw string) string {
|
||||
// directory itself or any descendant; a host grant matches its exact normalized
|
||||
// host. A narrower grant never covers a tool-wide request (reqScope == ""), so
|
||||
// such a request re-prompts (fail-safe).
|
||||
//
|
||||
// File and dir comparisons go through filepath.Rel (via scopePathEqual and the
|
||||
// shared pathWithinRoot helper) rather than a plain string comparison, so they
|
||||
// apply the same case-folding as the workspace-boundary check on Windows --
|
||||
// otherwise a persisted grant (including a deny) could be silently bypassed by
|
||||
// spelling the same path with different case.
|
||||
func grantCovers(grant Grant, reqScope string) bool {
|
||||
switch grant.ScopeKind {
|
||||
case ScopeToolWide:
|
||||
return true
|
||||
case ScopeFile:
|
||||
return reqScope != "" && reqScope == grant.Scope
|
||||
return reqScope != "" && scopePathEqual(grant.Scope, reqScope)
|
||||
case ScopeDir:
|
||||
if reqScope == "" || grant.Scope == "" {
|
||||
return false
|
||||
}
|
||||
if reqScope == grant.Scope {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(reqScope, grant.Scope+string(filepath.Separator))
|
||||
return pathWithinRoot(grant.Scope, reqScope)
|
||||
case ScopeHost:
|
||||
return reqScope != "" && normalizeHostScope(reqScope) == normalizeHostScope(grant.Scope)
|
||||
default:
|
||||
@@ -221,6 +224,14 @@ func grantCovers(grant Grant, reqScope string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// scopePathEqual reports whether two absolute, cleaned scope paths refer to
|
||||
// the same file, applying the same case-folding filepath.Rel already uses for
|
||||
// directory-boundary checks (case-insensitive path components on Windows).
|
||||
func scopePathEqual(a, b string) bool {
|
||||
rel, err := filepath.Rel(a, b)
|
||||
return err == nil && rel == "."
|
||||
}
|
||||
|
||||
// scopeSpecificity ranks scope kinds so the most precise covering allow wins when
|
||||
// several grants match the same request.
|
||||
func scopeSpecificity(kind ScopeKind) int {
|
||||
|
||||
@@ -2,6 +2,8 @@ package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -234,3 +236,25 @@ func TestGrantCovers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantCoversCaseInsensitiveOnWindows guards against a persisted grant
|
||||
// (including a deny) being silently bypassed by spelling the same path with
|
||||
// different case, since Windows path components are case-insensitive.
|
||||
func TestGrantCoversCaseInsensitiveOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("path case-insensitivity is a Windows-specific filesystem property")
|
||||
}
|
||||
dir := filepath.Join(string(filepath.Separator)+"proj", "src")
|
||||
file := filepath.Join(dir, "main.go")
|
||||
descendant := filepath.Join(dir, "api", "z.go")
|
||||
|
||||
fileGrant := Grant{Scope: file, ScopeKind: ScopeFile}
|
||||
dirGrant := Grant{Scope: dir, ScopeKind: ScopeDir}
|
||||
|
||||
if !grantCovers(fileGrant, strings.ToUpper(file)) {
|
||||
t.Fatalf("file grant %q should cover differently-cased request %q on Windows", file, strings.ToUpper(file))
|
||||
}
|
||||
if !grantCovers(dirGrant, strings.ToUpper(descendant)) {
|
||||
t.Fatalf("dir grant %q should cover differently-cased descendant %q on Windows", dir, strings.ToUpper(descendant))
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ const (
|
||||
secretRetryDelay = 2 * time.Millisecond
|
||||
)
|
||||
|
||||
var openSecretLockFile = os.OpenFile
|
||||
|
||||
// Crypter encrypts a blob at rest with AES-256-GCM under a per-user random secret
|
||||
// persisted (0600) at secretPath.
|
||||
type Crypter struct {
|
||||
@@ -107,7 +109,7 @@ func createSecretFile(path string) ([]byte, error) {
|
||||
lockPath := path + ".lock"
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
|
||||
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
lock, err := openSecretLockFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
_ = lock.Close()
|
||||
defer os.Remove(lockPath)
|
||||
@@ -118,20 +120,25 @@ func createSecretFile(path string) ([]byte, error) {
|
||||
}
|
||||
return writeNewSecretFile(path)
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
||||
// as contention and retry too -- mirroring oauth's createSecretFile --
|
||||
// otherwise concurrent secret creation spuriously fails on Windows.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
return nil, fmt.Errorf("securefile: create secret lock: %w", err)
|
||||
}
|
||||
// Remember the lock-creation error itself rather than a subsequent
|
||||
// "secret file doesn't exist yet" read error -- otherwise a real
|
||||
// contention/ACL problem is masked by the expected-while-waiting
|
||||
// ErrNotExist once the retries are exhausted.
|
||||
lastErr = err
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
return data, nil
|
||||
} else {
|
||||
lastErr = rerr
|
||||
}
|
||||
time.Sleep(secretRetryDelay)
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("securefile: timed out waiting for secret %s: %w", path, lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("securefile: timed out waiting for secret lock %s", lockPath)
|
||||
return nil, fmt.Errorf("securefile: timed out waiting for secret %s: %w", path, lastErr)
|
||||
}
|
||||
|
||||
func writeNewSecretFile(path string) ([]byte, error) {
|
||||
|
||||
@@ -7,6 +7,33 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateSecretFileRetriesOnPermissionContention(t *testing.T) {
|
||||
secretPath := filepath.Join(t.TempDir(), "k.secret")
|
||||
attempts := 0
|
||||
origOpen := openSecretLockFile
|
||||
openSecretLockFile = func(path string, flag int, perm os.FileMode) (*os.File, error) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
return os.OpenFile(path, flag, perm)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
openSecretLockFile = origOpen
|
||||
})
|
||||
|
||||
secret, err := createSecretFile(secretPath)
|
||||
if err != nil {
|
||||
t.Fatalf("createSecretFile should retry on permission contention: %v", err)
|
||||
}
|
||||
if len(secret) != secretBytes {
|
||||
t.Fatalf("secret length = %d, want %d", len(secret), secretBytes)
|
||||
}
|
||||
if attempts < 2 {
|
||||
t.Fatalf("expected at least 2 lock attempts, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSealOpenRoundTrip(t *testing.T) {
|
||||
secret := filepath.Join(t.TempDir(), "k.secret")
|
||||
c := NewCrypter(secret)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type ExecMode string
|
||||
@@ -221,11 +222,24 @@ func summarizePayload(payload any) string {
|
||||
text = string(data)
|
||||
}
|
||||
if len(text) > 500 {
|
||||
return text[:500]
|
||||
return truncateUTF8(text, 500)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// truncateUTF8 returns the longest prefix of s that is at most n bytes,
|
||||
// backing off to the nearest rune boundary so a multi-byte character isn't
|
||||
// split — a split rune here would embed invalid UTF-8 into the exec prompt.
|
||||
func truncateUTF8(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
for n > 0 && !utf8.RuneStart(s[n]) {
|
||||
n--
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
func extractText(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestStoreCreatesAppendsListsAndReadsEvents(t *testing.T) {
|
||||
@@ -640,6 +641,23 @@ func TestFormatExecPromptTruncatesConversationMessagesAfterFilteringNoise(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
// A truncation cut at a raw byte offset can land in the middle of a
|
||||
// multi-byte UTF-8 rune (e.g. CJK text), embedding invalid UTF-8 into the
|
||||
// exec prompt. summarizePayload must back off to a rune boundary instead.
|
||||
func TestSummarizePayloadTruncatesOnRuneBoundary(t *testing.T) {
|
||||
content := strings.Repeat("中文", 300) // 900 bytes of 3-byte runes
|
||||
payload := json.RawMessage(fmt.Sprintf(`{"role":"user","content":%q}`, content))
|
||||
|
||||
got := summarizePayload(payload)
|
||||
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("summarizePayload produced invalid UTF-8: %q", got)
|
||||
}
|
||||
if len(got) > 500 {
|
||||
t.Fatalf("expected summary to be at most 500 bytes, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareExecPersistsSpecialistMetadataForNewSession(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T14:30:00Z")})
|
||||
|
||||
|
||||
@@ -207,6 +207,31 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Operator/utility-name text that only appears inside a quoted argument must
|
||||
// not be mistaken for real shell syntax — e.g. `echo "foo; ls -la"` is a
|
||||
// harmless echo, not a bash-style `ls` invocation, and quoted `head`/`grep`
|
||||
// text isn't a piped POSIX utility.
|
||||
func TestDetectShellCommandIssueIgnoresQuotedContent(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`echo "foo; ls -la"`,
|
||||
`echo "run head over the file"`,
|
||||
`echo 'ls -la is a common bash command'`,
|
||||
`echo "grep for the pattern"`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected quoted content to be ignored for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the same utility names still get flagged when they're NOT quoted.
|
||||
// cmd.exe (unlike bash) does not treat ; as a statement separator, so
|
||||
// `echo "foo" ; ls -la` is a single echo invocation with literal
|
||||
// arguments, not a real ls invocation, and must not be flagged.
|
||||
if issue := detectShellCommandIssue(`git log | head`, "windows"); issue == nil {
|
||||
t.Fatalf("expected unquoted shell syntax to still be flagged for %q", `git log | head`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
|
||||
issue := detectShellOutputIssue("The syntax of the command is incorrect.", "windows")
|
||||
if issue == nil {
|
||||
|
||||
@@ -395,7 +395,7 @@ func TestSuggestionOverlayCapsRowsWithoutMoreText(t *testing.T) {
|
||||
if strings.Contains(plain, "more") {
|
||||
t.Fatalf("bare slash palette should not render a more-count row, got %q", plain)
|
||||
}
|
||||
if !strings.Contains(plain, "│ ❯ provider") || !strings.Contains(plain, "│ stop") {
|
||||
if !strings.Contains(plain, "│ ❯ provider") || !strings.Contains(plain, "│ ps") {
|
||||
t.Fatalf("top of palette should render first visible command window, got %q", plain)
|
||||
}
|
||||
if strings.Contains(plain, "compact") {
|
||||
|
||||
@@ -289,3 +289,98 @@ func TestDispatchOptionO(t *testing.T) {
|
||||
t.Errorf("model.keyMatch should NOT match ctrl+o when option+o is configured")
|
||||
}
|
||||
}
|
||||
|
||||
// A configured binding equal to a reserved hardcoded chord must be reverted
|
||||
// to default rather than silently making the hardcoded action unreachable
|
||||
// (e.g. configuring toggleDetailed to ctrl+f would otherwise swallow the
|
||||
// /model picker's "favorite" shortcut).
|
||||
func TestSanitizeKeyBindingsDropsReservedCollision(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "ctrl+f", // collides with the hardcoded favorite-model shortcut
|
||||
ToggleMouse: "ctrl+e", // unaffected, should survive untouched
|
||||
}
|
||||
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
|
||||
|
||||
if !sanitized.toggleDetailed.isZero() {
|
||||
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
|
||||
}
|
||||
if sanitized.toggleMouse.isZero() || sanitized.toggleMouse.Label() != "Ctrl+E" {
|
||||
t.Errorf("toggleMouse should be untouched, got %q", sanitized.toggleMouse.Label())
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// Two configurable actions bound to the same chord must not both fire —
|
||||
// sanitizeKeyBindings should keep the first and revert the rest to default.
|
||||
func TestSanitizeKeyBindingsDropsMutualCollision(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "ctrl+o",
|
||||
CycleReasoning: "ctrl+o", // collides with toggleDetailed above
|
||||
}
|
||||
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
|
||||
|
||||
if sanitized.toggleDetailed.isZero() || sanitized.toggleDetailed.Label() != "Ctrl+O" {
|
||||
t.Errorf("toggleDetailed (first claimant) should keep ctrl+o, got %q", sanitized.toggleDetailed.Label())
|
||||
}
|
||||
if !sanitized.cycleReasoning.isZero() {
|
||||
t.Errorf("cycleReasoning (second claimant) should be reverted to default, got %q", sanitized.cycleReasoning.Label())
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-colliding configured bindings must pass through unchanged with no
|
||||
// warnings.
|
||||
func TestSanitizeKeyBindingsNoCollisions(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "option+o",
|
||||
ToggleSidebar: "option+b",
|
||||
}
|
||||
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
|
||||
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("want no warnings, got %v", warnings)
|
||||
}
|
||||
if sanitized.toggleDetailed.Label() != "Alt+O" || sanitized.toggleSidebar.Label() != "Alt+B" {
|
||||
t.Fatalf("bindings should be unchanged: toggleDetailed=%q toggleSidebar=%q",
|
||||
sanitized.toggleDetailed.Label(), sanitized.toggleSidebar.Label())
|
||||
}
|
||||
}
|
||||
|
||||
// A configured binding must not shadow another action that is still on its
|
||||
// built-in default chord.
|
||||
func TestSanitizeKeyBindingsDropsCollisionWithOtherDefault(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "ctrl+t", // collides with cycleReasoning default
|
||||
}
|
||||
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
|
||||
|
||||
if !sanitized.toggleDetailed.isZero() {
|
||||
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
|
||||
}
|
||||
if !sanitized.cycleReasoning.isZero() {
|
||||
t.Errorf("cycleReasoning should remain default (zero), got %q", sanitized.cycleReasoning.Label())
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// Bare hardcoded keys handled directly in updateModel (e.g. Tab navigation)
|
||||
// are reserved too and must not be shadowed by configurable bindings.
|
||||
func TestSanitizeKeyBindingsDropsBareHardcodedCollision(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "tab",
|
||||
}
|
||||
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
|
||||
|
||||
if !sanitized.toggleDetailed.isZero() {
|
||||
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,8 +11,10 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/credstore"
|
||||
"github.com/Gitlawb/zero/internal/doctor"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
@@ -493,29 +496,37 @@ func (m model) handleModelCommand(args string) (model, string) {
|
||||
// picker calls this when a model from a non-active provider is chosen, so the
|
||||
// picker can list every saved provider and switch across them (like a unified
|
||||
// provider+model selector). The key is loaded from the encrypted store / env.
|
||||
func (m model) switchProviderModel(providerName, modelID string) (model, string, tea.Cmd) {
|
||||
// The returned bool reports whether the switch actually committed — callers
|
||||
// that branch on the outcome (the provider manager) must use it, never the
|
||||
// display text: UI copy is not a control-flow contract (a refusal quoting a
|
||||
// provider name could contain any substring, and rewording the success line
|
||||
// must not change behavior).
|
||||
func (m model) switchProviderModel(providerName, modelID string) (model, string, bool, tea.Cmd) {
|
||||
if m.pending {
|
||||
return m, "Model\nCannot switch providers while a run is active.", nil
|
||||
return m, "Model\nCannot switch providers while a run is active.", false, nil
|
||||
}
|
||||
if m.newProvider == nil {
|
||||
return m, "Model\nProvider rebuild is not available for this TUI session.", nil
|
||||
return m, "Model\nProvider rebuild is not available for this TUI session.", false, nil
|
||||
}
|
||||
target, ok := m.savedProviderByName(providerName)
|
||||
if !ok {
|
||||
return m, "Model\nunknown provider " + strconv.Quote(providerName), nil
|
||||
return m, "Model\nunknown provider " + strconv.Quote(providerName), false, nil
|
||||
}
|
||||
target = m.profileWithCredential(target)
|
||||
target.Model = strings.TrimSpace(modelID)
|
||||
descriptor, hasDescriptor := m.descriptorForProfile(target)
|
||||
// Gate on the resolved credential, not the APIKeyStored marker: if the stored key
|
||||
// was deleted/unreadable the marker can still be set, and building a keyless
|
||||
// provider would only fail later with a 401. Local/no-auth providers need no key.
|
||||
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" && !(hasDescriptor && descriptor.Local) {
|
||||
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", nil
|
||||
// provider would only fail later with a 401. Local/no-auth providers need no key,
|
||||
// and a stored OAuth login (e.g. ChatGPT) is a credential too — the profile stays
|
||||
// keyless on purpose so newProvider attaches the bearer resolver + login key.
|
||||
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" &&
|
||||
!(hasDescriptor && descriptor.Local) && !oauthLoginAvailable(target) {
|
||||
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", false, nil
|
||||
}
|
||||
next, err := m.newProvider(target)
|
||||
if err != nil {
|
||||
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), nil
|
||||
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), false, nil
|
||||
}
|
||||
m.provider = next
|
||||
m.providerProfile = target
|
||||
@@ -543,32 +554,80 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
|
||||
if warn := m.visionDropWarning(); warn != "" {
|
||||
status += "\n" + warn
|
||||
}
|
||||
return m, status, tea.Batch(cmds...)
|
||||
return m, status, true, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// profileWithCredential fills a profile's APIKey for provider construction the same
|
||||
// way the runtime resolves it: a stored key (encrypted credstore), then an env var,
|
||||
// then a stored OAuth bearer for token-login providers. The config resolver is pure
|
||||
// (no secret I/O), so a stored-key profile carries an empty APIKey until this runs —
|
||||
// every place that rebuilds the provider (model switch, provider switch) must call
|
||||
// this or the request goes out with no key.
|
||||
// way the runtime resolves it: a stored key (encrypted credstore), then an env var.
|
||||
// The config resolver is pure (no secret I/O), so a stored-key profile carries an
|
||||
// empty APIKey until this runs — every place that rebuilds the provider (model
|
||||
// switch, provider switch) must call this or the request goes out with no key.
|
||||
//
|
||||
// A stored OAuth bearer is deliberately NOT copied into APIKey: an inline key makes
|
||||
// HasConfiguredCredential true, which strips the OAuth login candidates at build
|
||||
// time, so newProvider would construct the client without the bearer resolver and
|
||||
// login key — for ChatGPT (Codex) that drops the `chatgpt-account-id` header and
|
||||
// token refresh, 401-ing every call. Keyless is the correct shape for a token-login
|
||||
// profile; newProvider resolves the login itself.
|
||||
func (m model) profileWithCredential(profile config.ProviderProfile) config.ProviderProfile {
|
||||
if strings.TrimSpace(profile.APIKey) == "" {
|
||||
if store, err := config.ProviderKeyStore(); err == nil {
|
||||
if store, err := m.providerKeyStore(); err == nil {
|
||||
profile = config.ApplyStoredAPIKey(profile, store)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.APIKeyEnv) != "" {
|
||||
profile.APIKey = strings.TrimSpace(os.Getenv(profile.APIKeyEnv))
|
||||
}
|
||||
if descriptor, ok := m.descriptorForProfile(profile); ok && strings.TrimSpace(profile.APIKey) == "" && descriptor.OAuth && !descriptor.OAuthMintsKey {
|
||||
if token := oauthStoredToken(m.ctx, descriptor.ID); token != "" {
|
||||
profile.APIKey = token
|
||||
}
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// providerKeyStore opens the credential store co-located with THIS session's
|
||||
// config path — the store every TUI write path (SecureProviderProfile capture,
|
||||
// rename migration, delete) uses. Reading from the default-path store instead
|
||||
// makes a stored key invisible whenever userConfigPath is non-default, so the
|
||||
// switch gate rejects a provider whose key is right there beside its config.
|
||||
// Ephemeral sessions without a config path fall back to the default store.
|
||||
func (m model) providerKeyStore() (*credstore.Store, error) {
|
||||
return providerKeyStoreForPath(m.userConfigPath)
|
||||
}
|
||||
|
||||
func providerKeyStoreForPath(configPath string) (*credstore.Store, error) {
|
||||
if strings.TrimSpace(configPath) != "" {
|
||||
return config.ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
}
|
||||
return config.ProviderKeyStore()
|
||||
}
|
||||
|
||||
// oauthLoginAvailable reports whether a stored OAuth login exists for any of the
|
||||
// profile's login candidates — the SAME candidate set the runtime bearer resolver
|
||||
// uses (profile name, then catalog id, only for keyless profiles), so the switch
|
||||
// gate and the runtime can never disagree about whether a login counts.
|
||||
func oauthLoginAvailable(profile config.ProviderProfile) bool {
|
||||
_, ok := oauthLoginName(profile)
|
||||
return ok
|
||||
}
|
||||
|
||||
// oauthLoginName resolves WHICH stored login serves this profile — the same
|
||||
// FirstStored selection the runtime makes. User-facing hints must name this
|
||||
// entry, not the profile: after a rename ({name:"codex", catalogID:"chatgpt"})
|
||||
// the token lives under the catalog id, and `zero auth logout codex` would
|
||||
// delete nothing while the real login stays behind.
|
||||
func oauthLoginName(profile config.ProviderProfile) (string, bool) {
|
||||
candidates := profile.OAuthLoginCandidates()
|
||||
if len(candidates) == 0 {
|
||||
return "", false
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
_, key, ok := oauth.FirstStored(store, candidates)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimPrefix(key, oauth.KeyPrefixProvider), true
|
||||
}
|
||||
|
||||
func (m model) savedProviderByName(name string) (config.ProviderProfile, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, profile := range m.savedProviders {
|
||||
|
||||
@@ -419,12 +419,23 @@ func (m model) providerText() string {
|
||||
}
|
||||
|
||||
snapshot := zerocommands.ProviderSnapshotFromProfile(m.providerProfile, true)
|
||||
// A keyless profile backed by a stored OAuth login (e.g. ChatGPT) is fully
|
||||
// authenticated — without this, /provider status showed a WORKING provider
|
||||
// as "api key: not set" with a warning, unlike `zero providers list` which
|
||||
// already fills OAuthLogin.
|
||||
if !snapshot.APIKeySet {
|
||||
snapshot.OAuthLogin = oauthLoginAvailable(m.providerProfile)
|
||||
}
|
||||
credentialState := apiKeyState(snapshot.APIKeySet)
|
||||
if !snapshot.APIKeySet && snapshot.OAuthLogin {
|
||||
credentialState = "oauth login"
|
||||
}
|
||||
profileLines = append(profileLines,
|
||||
"active: "+boolText(snapshot.Active),
|
||||
"kind: "+displayValue(snapshot.ProviderKind, "unknown"),
|
||||
"api model: "+displayValue(snapshot.APIModel, "unknown"),
|
||||
"base url: "+displayValue(snapshot.BaseURL, "default"),
|
||||
"api key: "+apiKeyState(snapshot.APIKeySet),
|
||||
"api key: "+credentialState,
|
||||
)
|
||||
if snapshot.Message != "" {
|
||||
profileLines = append(profileLines, "provider status: "+snapshot.Status+" - "+snapshot.Message)
|
||||
@@ -432,7 +443,7 @@ func (m model) providerText() string {
|
||||
|
||||
status := commandStatusOK
|
||||
actionLines := providerNextActionLines(m.providerProfile, snapshot, m.providerName)
|
||||
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) {
|
||||
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) && !snapshot.OAuthLogin {
|
||||
status = commandStatusWarning
|
||||
}
|
||||
return renderCommandOutput(commandOutput{
|
||||
|
||||
@@ -47,6 +47,9 @@ const (
|
||||
commandExport
|
||||
commandNew
|
||||
commandSkills
|
||||
commandLoop
|
||||
commandVoice
|
||||
commandSTTModel
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -78,9 +81,9 @@ type parsedCommand struct {
|
||||
var commandDefinitions = []commandDefinition{
|
||||
{
|
||||
name: "/provider",
|
||||
usage: "/provider [status]",
|
||||
usage: "/provider [add|status]",
|
||||
group: commandGroupModel,
|
||||
description: "Open provider setup.",
|
||||
description: "Manage providers: activate, add, edit, delete.",
|
||||
kind: commandProvider,
|
||||
},
|
||||
{
|
||||
@@ -90,6 +93,20 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Show or switch the active model.",
|
||||
kind: commandModel,
|
||||
},
|
||||
{
|
||||
name: "/stt-model",
|
||||
usage: "/stt-model",
|
||||
group: commandGroupModel,
|
||||
description: "Choose the speech-to-text (dictation) model.",
|
||||
kind: commandSTTModel,
|
||||
},
|
||||
{
|
||||
name: "/voice",
|
||||
usage: "/voice",
|
||||
group: commandGroupRuntime,
|
||||
description: "Toggle voice mode (hold Space to dictate).",
|
||||
kind: commandVoice,
|
||||
},
|
||||
{
|
||||
name: "/plan",
|
||||
usage: "/plan",
|
||||
@@ -297,6 +314,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Write the conversation transcript to a file.",
|
||||
kind: commandExport,
|
||||
},
|
||||
{
|
||||
name: "/loop",
|
||||
usage: "/loop [interval] <prompt|/command> | /loop list | /loop stop [id|all]",
|
||||
group: commandGroupSession,
|
||||
description: "Repeat a prompt or command on an interval (e.g. /loop 5m /babysit-prs), or self-paced when no interval is given.",
|
||||
kind: commandLoop,
|
||||
},
|
||||
{
|
||||
name: "/help",
|
||||
usage: "/help",
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestFormatCommandHelpLinesGroupsCommandsByStableOrder(t *testing.T) {
|
||||
|
||||
for _, want := range []string{
|
||||
"model:",
|
||||
" /provider [status] - Open provider setup.",
|
||||
" /provider [add|status] - Manage providers: activate, add, edit, delete.",
|
||||
" /model [list|id] - Show or switch the active model.",
|
||||
" /effort [list|low|medium|high|auto] - Show or set reasoning effort for supported models.",
|
||||
"session:",
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/dictation"
|
||||
)
|
||||
|
||||
// Transcriber and Recorder are re-exported so Options and the model can refer to
|
||||
// the dictation interfaces without importing the package everywhere.
|
||||
type (
|
||||
Transcriber = dictation.Transcriber
|
||||
Recorder = dictation.Recorder
|
||||
)
|
||||
|
||||
// dictationPhase is the recording state machine (§10): idle → recording →
|
||||
// transcribing → idle. Voice mode (§13.9) changes which key enters it, not the
|
||||
// machine itself.
|
||||
type dictationPhase int
|
||||
|
||||
const (
|
||||
dictIdle dictationPhase = iota
|
||||
dictStarting
|
||||
dictRecording
|
||||
dictTranscribing
|
||||
)
|
||||
|
||||
// dictationController holds the live dictation session. It is a value field on
|
||||
// the model (threaded through Update by value); the recorder/cancel it captures
|
||||
// are pointers, so they survive across frames and are shared with the async
|
||||
// commands that operate on them.
|
||||
type dictationController struct {
|
||||
cfg config.STTConfig
|
||||
build func(cfg config.STTConfig, preferStreaming bool) (Transcriber, bool, error)
|
||||
shutdownServer func(context.Context) error
|
||||
keyAvailable func(provider string) bool
|
||||
saveKey func(provider, key string) error
|
||||
phase dictationPhase
|
||||
platform dictation.Platform
|
||||
|
||||
// downloadRoot is where the auto-download stores the engine + model ("" =
|
||||
// auto-download disabled). userConfigPath is where the resolved paths persist.
|
||||
downloadRoot string
|
||||
userConfigPath string
|
||||
// downloading: a model download is in flight (started from the /stt-model
|
||||
// download chooser). downloadStatus is the live progress line shown in the
|
||||
// status bar while downloading. browseLoading: the full model list is being
|
||||
// fetched; browseVariants caches it once fetched.
|
||||
downloading bool
|
||||
downloadStatus string
|
||||
browseLoading bool
|
||||
browseVariants []dictation.ModelVariant
|
||||
|
||||
// in-flight session state
|
||||
streaming bool
|
||||
recorder Recorder
|
||||
transcriber Transcriber
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
streamStop func() error
|
||||
|
||||
// live streaming render region: [regionStart, regionEnd) in the composer's
|
||||
// rune space, replaced wholesale on each partial (§13.8). regionPrefix is a
|
||||
// separator space folded into the region so a cancel restores the original
|
||||
// text exactly.
|
||||
regionActive bool
|
||||
regionStart int
|
||||
regionEnd int
|
||||
regionPrefix string
|
||||
// regionAnchor snapshots the text BEFORE the live region, so the next
|
||||
// partial can detect external edits (typing, paste) and shift [start,end)
|
||||
// to stay aligned. Updated alongside regionStart on each render.
|
||||
regionAnchor string
|
||||
|
||||
// waveBars is the recording waveform's recent bar heights (a scrolling ring):
|
||||
// audio-reactive from live mic levels on the streaming path, synthetic on the
|
||||
// batch path. waveTick drives the batch synthetic animation.
|
||||
waveBars []int
|
||||
waveTick int
|
||||
|
||||
// voiceModeEnabled repurposes Space into hold-to-record (§13.9).
|
||||
voiceModeEnabled bool
|
||||
// eventTypesSupported records whether the terminal confirmed key-release
|
||||
// reporting (Kitty protocol); eventTypesKnown gates it until the terminal has
|
||||
// answered. Determines hold-to-record vs. press-to-toggle for voice mode.
|
||||
eventTypesKnown bool
|
||||
eventTypesSupported bool
|
||||
// spaceHeld tracks a held Space in hold-to-record mode; voiceStopPending
|
||||
// remembers a release that arrived before the recording finished starting.
|
||||
spaceHeld bool
|
||||
voiceStopPending bool
|
||||
}
|
||||
|
||||
// dictationStartedMsg reports the outcome of Recorder.Start().
|
||||
type dictationStartedMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// dictationTranscribedMsg carries the final transcript of a batch (or a
|
||||
// completed streaming) recording.
|
||||
type dictationTranscribedMsg struct {
|
||||
text string
|
||||
err error
|
||||
submit bool
|
||||
streaming bool
|
||||
}
|
||||
|
||||
// newDictationController builds the controller from resolved options.
|
||||
func newDictationController(opts Options) dictationController {
|
||||
return dictationController{
|
||||
cfg: opts.STT,
|
||||
build: opts.BuildDictationTranscriber,
|
||||
shutdownServer: opts.ShutdownDictationServer,
|
||||
keyAvailable: opts.STTKeyStatus,
|
||||
saveKey: opts.SaveSTTKey,
|
||||
platform: dictation.DetectPlatform(),
|
||||
downloadRoot: opts.STTDownloadRoot,
|
||||
userConfigPath: opts.UserConfigPath,
|
||||
}
|
||||
}
|
||||
|
||||
// available reports whether dictation is wired up (a transcriber factory exists).
|
||||
func (d dictationController) available() bool { return d.build != nil }
|
||||
|
||||
// currentModelLabel names the STT model currently configured, for status hints.
|
||||
func (d dictationController) currentModelLabel() string {
|
||||
cfg := d.cfg
|
||||
switch cfg.STTProvider() {
|
||||
case config.STTProviderLocal:
|
||||
if strings.TrimSpace(cfg.LocalModelPath) == "" {
|
||||
return "Local (no model set)"
|
||||
}
|
||||
// "Local" prefix mirrors the "Groq …"/"OpenAI …" cloud labels so the status
|
||||
// line always names the backend, not just the model.
|
||||
// Prefer a curated variant's friendly name when the path matches one.
|
||||
for _, v := range dictation.ModelVariants() {
|
||||
if v.DirName != "" && strings.Contains(cfg.LocalModelPath, v.DirName) {
|
||||
return "Local · " + v.Label
|
||||
}
|
||||
}
|
||||
return "Local · " + strings.TrimPrefix(filepath.Base(cfg.LocalModelPath), "sherpa-onnx-")
|
||||
case config.STTProviderGroq:
|
||||
return "Groq " + firstNonEmptyStr(cfg.Model, "whisper-large-v3-turbo")
|
||||
case config.STTProviderOpenAI:
|
||||
return "OpenAI " + firstNonEmptyStr(cfg.Model, "whisper-1")
|
||||
}
|
||||
return string(cfg.STTProvider())
|
||||
}
|
||||
|
||||
func firstNonEmptyStr(a, b string) string {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// active reports whether a recording/transcription is in flight.
|
||||
func (d dictationController) active() bool { return d.phase != dictIdle }
|
||||
|
||||
// toggleDictation starts a recording when idle and stops-and-transcribes when
|
||||
// recording. Invoked by the voice-mode Space gesture; a call during
|
||||
// startup/transcription is ignored (the machine is mid-transition).
|
||||
func (m model) toggleDictation() (model, tea.Cmd) {
|
||||
if !m.dictation.available() {
|
||||
return m.appendSystemNotice("Dictation is not configured. See docs/dictation.md to set up a local engine or a Groq/OpenAI key."), nil
|
||||
}
|
||||
if m.dictation.downloading {
|
||||
return m.appendDictationNotice("downloading", "Still setting up the dictation engine — one moment."), nil
|
||||
}
|
||||
switch m.dictation.phase {
|
||||
case dictIdle:
|
||||
return m.startDictation()
|
||||
case dictRecording:
|
||||
return m.stopDictation()
|
||||
default:
|
||||
// dictStarting / dictTranscribing: mid-transition, ignore.
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// toggleVoiceMode flips the /voice hold-to-record gesture — the dictation
|
||||
// trigger. While on, Space records; run /voice again to type spaces normally.
|
||||
func (m model) toggleVoiceMode() (model, tea.Cmd) {
|
||||
if !m.dictation.available() {
|
||||
return m.appendSystemNotice("Dictation is not configured. See docs/dictation.md to set up a local engine or a Groq/OpenAI key."), nil
|
||||
}
|
||||
m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled
|
||||
if m.dictation.voiceModeEnabled {
|
||||
return m.appendSystemNotice("Voice mode on (" + m.dictation.currentModelLabel() + ") — hold Space to dictate, release to transcribe. Run /voice again to turn it off (so Space types normally)."), nil
|
||||
}
|
||||
// Turning voice off is the "done dictating" signal, so release the warm sherpa
|
||||
// streaming server — otherwise a loaded model keeps idling in RAM (and holding
|
||||
// its port) until the app exits. It respawns lazily on the next streaming
|
||||
// recording. Skip while a recording is still in flight so we don't kill it.
|
||||
next := m.appendSystemNotice("Voice mode off.")
|
||||
if next.dictation.active() {
|
||||
return next, nil
|
||||
}
|
||||
return next, next.releaseDictationServerCmd()
|
||||
}
|
||||
|
||||
// releaseDictationServerCmd tears down the warm sherpa-onnx streaming server off
|
||||
// the UI goroutine (Shutdown waits on the process). No-op when no server backend
|
||||
// is wired or none is running.
|
||||
func (m model) releaseDictationServerCmd() tea.Cmd {
|
||||
shutdown := m.dictation.shutdownServer
|
||||
if shutdown == nil {
|
||||
return nil
|
||||
}
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = shutdown(ctx)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// startDictation builds the recorder + transcriber and kicks off capture.
|
||||
func (m model) startDictation() (model, tea.Cmd) {
|
||||
transcriber, streaming, err := m.dictation.build(m.dictation.cfg, m.dictation.wantStreaming())
|
||||
if err != nil {
|
||||
// A missing local engine on a platform we can auto-download for → point at
|
||||
// /stt-model, which offers the download-a-model chooser (a robust single
|
||||
// selection rather than a press-again-to-confirm dance).
|
||||
if m.dictation.canOfferDownload(err) {
|
||||
return m.appendDictationNotice("download-offer",
|
||||
"No local dictation model yet. Run /stt-model to choose and download one — or set stt.localModelPath yourself, or pick a cloud provider."), nil
|
||||
}
|
||||
return m.appendDictationNotice("setup:"+err.Error(), dictationErrorText(err)), nil
|
||||
}
|
||||
|
||||
rec := dictation.NewRecorder(dictation.RecorderOptions{
|
||||
Platform: m.dictation.platform,
|
||||
SampleRate: dictation.RequiredSampleRate(transcriber),
|
||||
MaxDuration: m.dictation.maxDuration(),
|
||||
SilenceAutoStop: m.dictation.cfg.SilenceAutoStopEnabled(),
|
||||
WindowsAudioDevice: m.dictation.cfg.WindowsAudioDevice,
|
||||
})
|
||||
|
||||
base := m.ctx
|
||||
if base == nil {
|
||||
base = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(base)
|
||||
|
||||
m.dictation.recorder = rec
|
||||
m.dictation.transcriber = transcriber
|
||||
m.dictation.ctx = ctx
|
||||
m.dictation.cancel = cancel
|
||||
m.dictation.streaming = streaming
|
||||
m.dictation.phase = dictStarting
|
||||
|
||||
if streaming {
|
||||
return m.startStreamingDictation()
|
||||
}
|
||||
return m, startBatchRecordingCmd(rec)
|
||||
}
|
||||
|
||||
// stopDictation ends a recording. For batch it triggers Stop()+Transcribe(); for
|
||||
// streaming it stops the recorder, which closes the chunk channel and lets the
|
||||
// in-flight StreamTranscribe return its final text.
|
||||
func (m model) stopDictation() (model, tea.Cmd) {
|
||||
m.dictation.phase = dictTranscribing
|
||||
if m.dictation.streaming {
|
||||
if m.dictation.streamStop != nil {
|
||||
_ = m.dictation.streamStop()
|
||||
}
|
||||
return m, nil // final text arrives via the streaming command already running
|
||||
}
|
||||
return m, transcribeBatchCmd(m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled())
|
||||
}
|
||||
|
||||
// cancelDictation aborts an in-flight recording without transcribing. Bound to
|
||||
// Esc while a session is active.
|
||||
func (m model) cancelDictation() (model, tea.Cmd) {
|
||||
if m.dictation.cancel != nil {
|
||||
m.dictation.cancel()
|
||||
}
|
||||
if m.dictation.streaming && m.dictation.streamStop != nil {
|
||||
_ = m.dictation.streamStop()
|
||||
} else if m.dictation.recorder != nil && m.dictation.phase != dictTranscribing {
|
||||
// Best-effort: stop the capture tool so the microphone is released.
|
||||
go func(r Recorder) { _, _ = r.Stop() }(m.dictation.recorder)
|
||||
}
|
||||
m = m.discardDictationRegion()
|
||||
m.dictation.reset()
|
||||
return m.appendSystemNotice("Dictation cancelled."), nil
|
||||
}
|
||||
|
||||
// wantStreaming decides whether this recording uses the streaming pipeline:
|
||||
// enabled in config and supported on this platform (desktop only — Termux's mic
|
||||
// tool has no stdout mode).
|
||||
func (d dictationController) wantStreaming() bool {
|
||||
return d.cfg.StreamingEnabled() && d.platform.StreamingSupported()
|
||||
}
|
||||
|
||||
func (d dictationController) maxDuration() time.Duration {
|
||||
if d.cfg.MaxDurationSeconds > 0 {
|
||||
return time.Duration(d.cfg.MaxDurationSeconds) * time.Second
|
||||
}
|
||||
return dictation.DefaultMaxDuration
|
||||
}
|
||||
|
||||
func (d *dictationController) reset() {
|
||||
d.phase = dictIdle
|
||||
d.recorder = nil
|
||||
d.transcriber = nil
|
||||
d.ctx = nil
|
||||
d.cancel = nil
|
||||
d.streaming = false
|
||||
d.streamStop = nil
|
||||
d.regionActive = false
|
||||
d.regionPrefix = ""
|
||||
d.waveBars = nil
|
||||
d.waveTick = 0
|
||||
}
|
||||
|
||||
// startBatchRecordingCmd starts the record-to-file capture off the UI goroutine
|
||||
// (Start spawns a subprocess and may briefly block or fail on a missing tool).
|
||||
func startBatchRecordingCmd(rec Recorder) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return dictationStartedMsg{err: rec.Start()}
|
||||
}
|
||||
}
|
||||
|
||||
// transcribeBatchCmd stops the recording, transcribes the audio, and reports the
|
||||
// final text. Runs off the UI goroutine — Stop waits on the capture tool and
|
||||
// Transcribe does a network round-trip or a local exec.
|
||||
func transcribeBatchCmd(ctx context.Context, rec Recorder, transcriber Transcriber, submit bool) tea.Cmd {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return func() tea.Msg {
|
||||
audio, err := rec.Stop()
|
||||
if err != nil {
|
||||
return dictationTranscribedMsg{err: err}
|
||||
}
|
||||
text, err := transcriber.Transcribe(ctx, audio)
|
||||
return dictationTranscribedMsg{text: text, err: err, submit: submit}
|
||||
}
|
||||
}
|
||||
|
||||
// handleDictationStarted transitions to recording (or reports a start failure).
|
||||
func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd) {
|
||||
if msg.err != nil {
|
||||
m = m.discardDictationRegion()
|
||||
m.dictation.reset()
|
||||
text := dictationErrorText(msg.err)
|
||||
return m.appendDictationNotice("dicterr:"+text, text), nil
|
||||
}
|
||||
// A stop or cancel may have already advanced the phase; only arm recording if
|
||||
// we are still starting.
|
||||
if m.dictation.phase == dictStarting {
|
||||
m.dictation.phase = dictRecording
|
||||
}
|
||||
// A voice-mode Space release that arrived mid-startup asked us to stop as soon
|
||||
// as recording began.
|
||||
if m.dictation.voiceStopPending && m.dictation.phase == dictRecording {
|
||||
m.dictation.voiceStopPending = false
|
||||
return m.stopDictation()
|
||||
}
|
||||
// Kick off the recording waveform animation.
|
||||
return m, recTickCmd()
|
||||
}
|
||||
|
||||
// handleDictationTranscribed inserts the final transcript into the composer (or
|
||||
// submits it when stt.autoSubmit is on), then returns to idle.
|
||||
func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Model, tea.Cmd) {
|
||||
streaming := m.dictation.streaming || msg.streaming
|
||||
m = m.commitDictationRegion()
|
||||
// A streaming session can end via a transcriber error (not just a user stop), so
|
||||
// tear the capture down here BEFORE reset() drops the handles: stop the recorder
|
||||
// (kills the mic subprocess) and cancel the context (unblocks the audio tap).
|
||||
// Without this, an error path leaves the mic running and the next attempt hits
|
||||
// "already recording". Both are idempotent, so this is safe on the happy path.
|
||||
if streaming {
|
||||
if m.dictation.streamStop != nil {
|
||||
_ = m.dictation.streamStop()
|
||||
}
|
||||
if m.dictation.cancel != nil {
|
||||
m.dictation.cancel()
|
||||
}
|
||||
}
|
||||
m.dictation.reset()
|
||||
|
||||
if msg.err != nil && !errors.Is(msg.err, context.Canceled) {
|
||||
// A cloud auth failure (missing/invalid key) is fixable in place: reopen the
|
||||
// API-key prompt for the current provider so the user can paste a key and
|
||||
// retry, instead of hitting a dead-end "run zero auth" line.
|
||||
if next, handled := m.maybeOfferKeyOnAuthError(msg.err); handled {
|
||||
return next, nil
|
||||
}
|
||||
// Partial text may still be worth keeping on a mid-stream failure (§6).
|
||||
if strings.TrimSpace(msg.text) != "" && !streaming {
|
||||
m = m.insertDictatedText(msg.text)
|
||||
return m.appendSystemNotice("Dictation interrupted; kept the partial transcript. " + dictationErrorText(msg.err)), nil
|
||||
}
|
||||
// Keyed so hammering the dictation gesture against the same unusable model doesn't spam the
|
||||
// identical error line over and over.
|
||||
text := dictationErrorText(msg.err)
|
||||
return m.appendDictationNotice("dicterr:"+text, text), nil
|
||||
}
|
||||
if strings.TrimSpace(msg.text) == "" {
|
||||
if streaming {
|
||||
return m, nil // streaming already rendered live; nothing to add
|
||||
}
|
||||
return m.appendSystemNotice("No speech detected."), nil
|
||||
}
|
||||
if !streaming {
|
||||
m = m.insertDictatedText(msg.text)
|
||||
}
|
||||
if msg.submit {
|
||||
// stt.autoSubmit fires the transcript straight at the agent (off by
|
||||
// default — insert-for-review is the safety net for a misheard prompt).
|
||||
return m.handleSubmit()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// insertDictatedText inserts transcribed text at the composer cursor, adding a
|
||||
// separating space when the existing text would otherwise run into it.
|
||||
func (m model) insertDictatedText(text string) model {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return m
|
||||
}
|
||||
state := m.currentComposerState()
|
||||
if needsLeadingSpace(state) {
|
||||
text = " " + text
|
||||
}
|
||||
m.setComposerState(insertComposerText(state, text))
|
||||
return m
|
||||
}
|
||||
|
||||
func needsLeadingSpace(state composerState) bool {
|
||||
runes := []rune(state.text)
|
||||
if state.cursor <= 0 || state.cursor > len(runes) {
|
||||
return false
|
||||
}
|
||||
prev := runes[state.cursor-1]
|
||||
return prev != ' ' && prev != '\n' && prev != '\t'
|
||||
}
|
||||
|
||||
// dictationStatusChip renders the active-recording indicator for the status
|
||||
// line: a live animated waveform plus how to stop. Empty when idle.
|
||||
func (m model) dictationStatusChip() string {
|
||||
switch m.dictation.phase {
|
||||
case dictStarting, dictRecording:
|
||||
stop := "release Space to stop"
|
||||
if !m.dictation.eventTypesSupported {
|
||||
stop = "press Space to stop" // press-to-toggle fallback
|
||||
}
|
||||
wave := zeroTheme.amber.Render("● " + renderWaveBars(m.dictation.waveBars) + " REC")
|
||||
return wave + zeroTheme.muted.Render(" · "+stop+", Esc to cancel")
|
||||
case dictTranscribing:
|
||||
return zeroTheme.accent.Render("●") + " " + zeroTheme.muted.Render("transcribing…")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const (
|
||||
waveBarCount = 14
|
||||
recTickInterval = 90 * time.Millisecond
|
||||
)
|
||||
|
||||
// waveRunes maps a 0..8 bar height to a block glyph.
|
||||
var waveRunes = []rune(" ▁▂▃▄▅▆▇█")
|
||||
|
||||
// renderWaveBars draws the scrolling bar heights as block glyphs.
|
||||
func renderWaveBars(bars []int) string {
|
||||
if len(bars) == 0 {
|
||||
return strings.Repeat(" ", waveBarCount)
|
||||
}
|
||||
out := make([]rune, len(bars))
|
||||
for i, h := range bars {
|
||||
if h < 0 {
|
||||
h = 0
|
||||
}
|
||||
if h > 8 {
|
||||
h = 8
|
||||
}
|
||||
out[i] = waveRunes[h]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// pushLevel appends a new bar height on the right and scrolls the ring left.
|
||||
func (d *dictationController) pushLevel(height int) {
|
||||
nb := make([]int, waveBarCount)
|
||||
if len(d.waveBars) == waveBarCount {
|
||||
copy(nb, d.waveBars[1:])
|
||||
}
|
||||
nb[waveBarCount-1] = height
|
||||
d.waveBars = nb
|
||||
}
|
||||
|
||||
// levelHeight maps a 0..1 audio level to a 0..8 bar height.
|
||||
func levelHeight(level float64) int {
|
||||
h := int(level*8 + 0.5)
|
||||
if h < 0 {
|
||||
h = 0
|
||||
}
|
||||
if h > 8 {
|
||||
h = 8
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// sttLevelMsg carries a live microphone level (0..1) from the streaming audio
|
||||
// tap, driving the real audio-reactive waveform.
|
||||
type sttLevelMsg struct{ level float64 }
|
||||
|
||||
// handleDictationLevel pushes a real mic level onto the waveform.
|
||||
func (m model) handleDictationLevel(msg sttLevelMsg) model {
|
||||
if m.dictation.active() {
|
||||
m.dictation.pushLevel(levelHeight(msg.level))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// recTickMsg drives the BATCH synthetic waveform (batch records to a file, so
|
||||
// there is no live audio to react to). The streaming path animates from real
|
||||
// levels instead (sttLevelMsg) and does not tick.
|
||||
type recTickMsg struct{}
|
||||
|
||||
func recTickCmd() tea.Cmd {
|
||||
return tea.Tick(recTickInterval, func(time.Time) tea.Msg { return recTickMsg{} })
|
||||
}
|
||||
|
||||
// syntheticWaveLevel synthesizes an organic 0..8 bar height for the batch
|
||||
// recording animation (batch records to a file, so there is no live signal to
|
||||
// react to). Rather than loop a fixed pulse — which reads as mechanical — it
|
||||
// layers a few incommensurate sinusoids so the envelope wanders and doesn't
|
||||
// obviously repeat, plus a hash-based per-frame jitter for texture and an
|
||||
// occasional near-silent dip so it breathes like real speech.
|
||||
func syntheticWaveLevel(tick int) int {
|
||||
t := float64(tick)
|
||||
amp := 0.5 +
|
||||
0.24*math.Sin(t*0.33) +
|
||||
0.15*math.Sin(t*0.12+1.7) +
|
||||
0.12*math.Sin(t*0.71+0.4) +
|
||||
0.08*math.Sin(t*1.30+2.2) // faster wiggle so it never flatlines
|
||||
// Deterministic jitter in ~[-0.09, 0.09] from a cheap integer hash of the tick.
|
||||
h := uint32(tick)*2654435761 + 1013904223
|
||||
amp += (float64(h>>16&0xffff)/0xffff - 0.5) * 0.18
|
||||
// A slow, shallow gate occasionally eases the level down (a spoken pause) without
|
||||
// ever flatlining, so the wave keeps breathing instead of humming a fixed shape.
|
||||
if gate := math.Sin(t*0.09 + 0.6); gate < -0.5 {
|
||||
amp *= 0.55
|
||||
}
|
||||
switch {
|
||||
case amp < 0:
|
||||
amp = 0
|
||||
case amp > 1:
|
||||
amp = 1
|
||||
}
|
||||
return levelHeight(amp)
|
||||
}
|
||||
|
||||
// handleRecTick advances the batch synthetic waveform and stops when recording ends.
|
||||
func (m model) handleRecTick() (model, tea.Cmd) {
|
||||
if m.dictation.phase == dictRecording || m.dictation.phase == dictStarting {
|
||||
m.dictation.waveTick++
|
||||
m.dictation.pushLevel(syntheticWaveLevel(m.dictation.waveTick))
|
||||
return m, recTickCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// voiceModeIndicator renders the persistent "voice mode on" hint shown while
|
||||
// idle so the user knows Space is repurposed to hold-to-record (§10).
|
||||
func (m model) voiceModeIndicator() string {
|
||||
if !m.dictation.voiceModeEnabled {
|
||||
return ""
|
||||
}
|
||||
return zeroTheme.accent.Render("🎙 voice") + zeroTheme.muted.Render(" · "+m.dictation.currentModelLabel())
|
||||
}
|
||||
|
||||
// dictationErrorText renders a dictation error for the transcript: a missing-
|
||||
// tool setup error becomes actionable guidance, everything else is shown plainly.
|
||||
func dictationErrorText(err error) string {
|
||||
var setupErr *dictation.SetupError
|
||||
if errors.As(err, &setupErr) {
|
||||
return "Dictation unavailable: " + setupErr.Error()
|
||||
}
|
||||
return fmt.Sprintf("Dictation failed: %v", err)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/dictation"
|
||||
)
|
||||
|
||||
// sttDownloadProgressMsg carries a live download-status line (with a
|
||||
// percentage) into the TUI, injected from the download goroutine via
|
||||
// runtimeMessageSink.
|
||||
type sttDownloadProgressMsg struct{ status string }
|
||||
|
||||
// dictationDownloadedMsg reports the outcome of an auto-download.
|
||||
type dictationDownloadedMsg struct {
|
||||
components dictation.EngineComponents
|
||||
err error
|
||||
// streaming is the chosen variant's pipeline (a streaming transducer vs a
|
||||
// batch model), applied to the config after a successful download.
|
||||
streaming bool
|
||||
}
|
||||
|
||||
// canOfferDownload reports whether a failed build should point the user at the
|
||||
// /stt-model download chooser: the failure is a missing-setup error, the user is
|
||||
// on the local provider, this platform has a prebuilt engine, and a download
|
||||
// root is configured.
|
||||
func (d dictationController) canOfferDownload(err error) bool {
|
||||
var setupErr *dictation.SetupError
|
||||
return d.downloadRoot != "" && !d.downloading &&
|
||||
d.cfg.STTProvider() == config.STTProviderLocal &&
|
||||
dictation.AutoDownloadSupported() &&
|
||||
errors.As(err, &setupErr)
|
||||
}
|
||||
|
||||
// startVariantDownload fetches the sherpa-onnx engine + the chosen model
|
||||
// variant, reporting progress live, then persists and applies the resolved paths.
|
||||
func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
|
||||
if m.dictation.downloading {
|
||||
return m.appendSystemNotice("A dictation download is already in progress — please wait for it to finish."), nil
|
||||
}
|
||||
m.dictation.downloading = true
|
||||
sink := m.runtimeMessageSink
|
||||
root := m.dictation.downloadRoot
|
||||
version := m.dictation.cfg.EngineVersion
|
||||
base := m.ctx
|
||||
if base == nil {
|
||||
base = context.Background()
|
||||
}
|
||||
m.dictation.downloadStatus = "Starting download…"
|
||||
m = m.appendSystemNotice(fmt.Sprintf("Setting up local dictation — downloading the offline engine and %s (~%d MB). This runs once; progress is shown in the status bar below.", v.Label, (v.Bytes>>20)+(engineDownloadBytes>>20)))
|
||||
return m, func() tea.Msg {
|
||||
comp, err := dictation.EnsureLocalEngine(base, dictation.DownloadOptions{
|
||||
DestRoot: root,
|
||||
EngineVersion: version,
|
||||
ModelAssetName: v.AssetName,
|
||||
ModelPinnedDigest: v.Digest,
|
||||
ModelDirName: v.DirName,
|
||||
ModelLabel: v.Label,
|
||||
Progress: func(status string) {
|
||||
if sink != nil {
|
||||
sink(sttDownloadProgressMsg{status: status})
|
||||
}
|
||||
},
|
||||
})
|
||||
return dictationDownloadedMsg{components: comp, err: err, streaming: v.Streaming}
|
||||
}
|
||||
}
|
||||
|
||||
// handleDictationDownloadProgress updates the single live status line (shown in
|
||||
// the status bar) — not a new transcript line per update.
|
||||
func (m model) handleDictationDownloadProgress(msg sttDownloadProgressMsg) model {
|
||||
if m.dictation.downloading {
|
||||
m.dictation.downloadStatus = msg.status
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// handleDictationDownloaded persists and applies the downloaded engine, or
|
||||
// reports the failure.
|
||||
func (m model) handleDictationDownloaded(msg dictationDownloadedMsg) (model, tea.Cmd) {
|
||||
m.dictation.downloading = false
|
||||
m.dictation.downloadStatus = ""
|
||||
if msg.err != nil {
|
||||
return m.appendSystemNotice("Dictation setup failed: " + dictationErrorText(msg.err)), nil
|
||||
}
|
||||
m, err := m.applyEngineComponents(msg.components, msg.streaming)
|
||||
if err != nil {
|
||||
return m.appendSystemNotice("Downloaded the engine, but couldn't save the config: " + err.Error()), nil
|
||||
}
|
||||
return m.appendSystemNotice("Local dictation is ready. Run /voice, then hold Space to dictate."), nil
|
||||
}
|
||||
|
||||
// applyEngineComponents persists and applies resolved engine/model paths to the
|
||||
// live config (shared by a fresh download and the already-installed fast path).
|
||||
func (m model) applyEngineComponents(comp dictation.EngineComponents, streaming bool) (model, error) {
|
||||
if path := m.dictation.userConfigPath; path != "" {
|
||||
if _, err := config.SetSTTLocalEngine(path, comp.BinaryPath, comp.ServerPath, comp.ModelPath, streaming); err != nil {
|
||||
return m, err
|
||||
}
|
||||
}
|
||||
m.dictation.cfg.Provider = config.STTProviderLocal
|
||||
m.dictation.cfg.LocalBinary = comp.BinaryPath
|
||||
m.dictation.cfg.LocalServerBinary = comp.ServerPath
|
||||
m.dictation.cfg.LocalModelPath = comp.ModelPath
|
||||
s := streaming
|
||||
m.dictation.cfg.Streaming = &s
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// appendDictationNotice appends a transcript notice, suppressing an immediate
|
||||
// repeat of the same message — the fix for hammering the dictation gesture against an unusable
|
||||
// model (or in voice mode) spamming the identical line on every keypress. It is
|
||||
// stateless: it simply skips the append when the message equals the most recent
|
||||
// transcript line, so an unrelated line in between lets it show again.
|
||||
func (m model) appendDictationNotice(key, text string) model {
|
||||
_ = key
|
||||
if n := len(m.transcript); n > 0 && strings.TrimSpace(m.transcript[n-1].text) == strings.TrimSpace(text) {
|
||||
return m
|
||||
}
|
||||
return m.appendSystemNotice(text)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/dictation"
|
||||
)
|
||||
|
||||
// buildFails returns a controller whose build always fails with a setup error,
|
||||
// as if the local engine/model were missing.
|
||||
func setupErrController(downloadRoot string) dictationController {
|
||||
return dictationController{
|
||||
cfg: config.STTConfig{Provider: config.STTProviderLocal},
|
||||
platform: dictation.DetectPlatform(),
|
||||
downloadRoot: downloadRoot,
|
||||
build: func(config.STTConfig, bool) (Transcriber, bool, error) {
|
||||
return nil, false, &dictation.SetupError{Tool: "local STT model", Hint: "set stt.localModelPath"}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestF9PointsToSTTModelWhenLocalMissing(t *testing.T) {
|
||||
if !dictation.AutoDownloadSupported() {
|
||||
t.Skip("no prebuilt engine for this platform")
|
||||
}
|
||||
m := model{dictation: setupErrController("/tmp/zero-stt")}
|
||||
next, _ := m.toggleDictation()
|
||||
if !transcriptHasText(next, "/stt-model") {
|
||||
t.Error("F9 with a missing local engine should point at /stt-model to download")
|
||||
}
|
||||
}
|
||||
|
||||
func TestF9PlainSetupErrorWithoutDownloadRoot(t *testing.T) {
|
||||
m := model{dictation: setupErrController("")} // no download root
|
||||
next, _ := m.toggleDictation()
|
||||
if !transcriptHasText(next, "set stt.localModelPath") {
|
||||
t.Error("without a download root, expected the plain setup error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationNoticeDedupe(t *testing.T) {
|
||||
m := model{}
|
||||
m = m.appendDictationNotice("k", "same message")
|
||||
m = m.appendDictationNotice("k", "same message")
|
||||
m = m.appendDictationNotice("k", "same message")
|
||||
n := 0
|
||||
for _, row := range m.transcript {
|
||||
if row.text == "same message" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("consecutive identical notices should dedupe to 1, got %d", n)
|
||||
}
|
||||
// A different key breaks the dedupe.
|
||||
m = m.appendDictationNotice("other", "another")
|
||||
if !transcriptHasText(m, "another") {
|
||||
t.Error("a different-keyed notice should still show")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadedBatchModelAppliedToConfig(t *testing.T) {
|
||||
m := model{dictation: dictationController{cfg: config.STTConfig{Provider: config.STTProviderLocal}}}
|
||||
m.dictation.downloading = true
|
||||
comp := dictation.EngineComponents{
|
||||
BinaryPath: "/x/bin/sherpa-onnx-offline",
|
||||
ServerPath: "/x/bin/sherpa-onnx-online-websocket-server",
|
||||
ModelPath: "/x/model",
|
||||
}
|
||||
got, _ := m.handleDictationDownloaded(dictationDownloadedMsg{components: comp, streaming: false})
|
||||
if got.dictation.downloading {
|
||||
t.Error("downloading flag should clear")
|
||||
}
|
||||
if got.dictation.cfg.LocalBinary != comp.BinaryPath || got.dictation.cfg.LocalModelPath != comp.ModelPath {
|
||||
t.Errorf("engine paths not applied: %+v", got.dictation.cfg)
|
||||
}
|
||||
if got.dictation.cfg.StreamingEnabled() {
|
||||
t.Error("a batch variant should set streaming off")
|
||||
}
|
||||
if !transcriptHasText(got, "ready") {
|
||||
t.Error("expected a ready notice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadedStreamingModelSetsStreamingOn(t *testing.T) {
|
||||
m := model{dictation: dictationController{cfg: config.STTConfig{Provider: config.STTProviderLocal}}}
|
||||
comp := dictation.EngineComponents{BinaryPath: "/x/bin/sherpa-onnx-offline", ModelPath: "/x/model"}
|
||||
got, _ := m.handleDictationDownloaded(dictationDownloadedMsg{components: comp, streaming: true})
|
||||
if !got.dictation.cfg.StreamingEnabled() {
|
||||
t.Error("a streaming variant should set streaming on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTTDownloadLoadingPickerThenFilled(t *testing.T) {
|
||||
// The chooser opens in a single loading state (no curated-then-swap flicker),
|
||||
// then handleSTTModelsFetched fills it with the fetched list.
|
||||
loading := newSTTDownloadLoadingPicker()
|
||||
if loading.kind != pickerSTTDownload || !loading.loading || len(loading.items) != 0 {
|
||||
t.Fatalf("loading picker = %+v, want an empty, loading STT-download picker", loading)
|
||||
}
|
||||
|
||||
m := model{dictation: dictationController{downloadRoot: "/tmp/x"}}
|
||||
m.picker = loading
|
||||
fetched := dictation.ModelVariants()
|
||||
m = m.handleSTTModelsFetched(sttModelsFetchedMsg{variants: fetched})
|
||||
if m.dictation.browseLoading {
|
||||
t.Error("browseLoading should clear once the list arrives")
|
||||
}
|
||||
if m.picker.loading {
|
||||
t.Error("the filled picker should no longer be loading")
|
||||
}
|
||||
if len(m.picker.items) != len(fetched) {
|
||||
t.Errorf("filled picker has %d items, want %d", len(m.picker.items), len(fetched))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTTModelsFetchFailureFallsBackToCurated(t *testing.T) {
|
||||
// A failed fetch with nothing cached falls back to the curated shortlist so the
|
||||
// picker still works offline (never a permanently-empty loading box).
|
||||
m := model{dictation: dictationController{downloadRoot: "/tmp/x"}}
|
||||
m.picker = newSTTDownloadLoadingPicker()
|
||||
m = m.handleSTTModelsFetched(sttModelsFetchedMsg{err: errors.New("offline")})
|
||||
if m.picker.loading {
|
||||
t.Error("picker should stop loading even on fetch failure")
|
||||
}
|
||||
if len(m.picker.items) != len(dictation.ModelVariants()) {
|
||||
t.Errorf("fallback picker has %d items, want the %d curated variants",
|
||||
len(m.picker.items), len(dictation.ModelVariants()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTTDownloadPickerListsVariants(t *testing.T) {
|
||||
m := model{dictation: dictationController{downloadRoot: "/tmp/x"}}
|
||||
p := m.newSTTDownloadPicker()
|
||||
if p == nil || p.kind != pickerSTTDownload {
|
||||
t.Fatal("expected an STT download picker")
|
||||
}
|
||||
// Exactly the curated download variants — no dead-end "manual" row.
|
||||
if len(p.items) != len(dictation.ModelVariants()) {
|
||||
t.Errorf("picker has %d items, want %d variants", len(p.items), len(dictation.ModelVariants()))
|
||||
}
|
||||
for _, it := range p.items {
|
||||
if it.Value == "manual" {
|
||||
t.Error("the dead-end manual row should be gone")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTTDownloadSelectionStartsDownload(t *testing.T) {
|
||||
m := model{dictation: dictationController{downloadRoot: "/tmp/x", cfg: config.STTConfig{Provider: config.STTProviderLocal}}}
|
||||
variant := dictation.ModelVariants()[0]
|
||||
next, cmd := m.handleSTTDownloadSelection(variant.ID)
|
||||
if !next.dictation.downloading {
|
||||
t.Error("selecting a variant should start a download")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected a download command")
|
||||
}
|
||||
if !transcriptHasText(next, variant.Label) {
|
||||
t.Error("expected the download-starting notice to name the variant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFailureReported(t *testing.T) {
|
||||
m := model{dictation: dictationController{}}
|
||||
m.dictation.downloading = true
|
||||
next, _ := m.handleDictationDownloaded(dictationDownloadedMsg{err: errors.New("network down")})
|
||||
got := next
|
||||
if got.dictation.downloading {
|
||||
t.Error("downloading flag should clear on failure")
|
||||
}
|
||||
if !transcriptHasText(got, "network down") {
|
||||
t.Error("expected the failure reported")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/dictation"
|
||||
)
|
||||
|
||||
// sttPartialMsg carries an incremental streaming transcript into the TUI. It is
|
||||
// injected from the streaming goroutine via runtimeMessageSink — the same
|
||||
// mechanism agent text deltas use (§6). text is the cumulative best transcript
|
||||
// so far (not a delta); final marks a settled segment.
|
||||
type sttPartialMsg struct {
|
||||
text string
|
||||
final bool
|
||||
}
|
||||
|
||||
// startStreamingDictation begins continuous capture and launches the streaming
|
||||
// transcription command. StartStreaming spawns the capture process
|
||||
// synchronously, so a failure here is immediate; on success we are already
|
||||
// recording (no separate "started" round-trip).
|
||||
func (m model) startStreamingDictation() (model, tea.Cmd) {
|
||||
chunks, stop, err := m.dictation.recorder.StartStreaming()
|
||||
if err != nil {
|
||||
m.dictation.reset()
|
||||
return m.appendSystemNotice(dictationErrorText(err)), nil
|
||||
}
|
||||
m.dictation.streamStop = stop
|
||||
m.dictation.phase = dictRecording
|
||||
|
||||
sink := m.runtimeMessageSink
|
||||
ctx := m.dictation.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
transcriber := m.dictation.transcriber
|
||||
submit := m.dictation.cfg.AutoSubmitEnabled()
|
||||
|
||||
// Tap the audio: compute a mic level per chunk for the live waveform, then
|
||||
// forward the chunk to the transcriber. A small buffer keeps the tap from
|
||||
// stalling capture if the transcriber briefly lags.
|
||||
tapped := make(chan []byte, 16)
|
||||
go func() {
|
||||
defer close(tapped)
|
||||
for chunk := range chunks {
|
||||
if sink != nil {
|
||||
sink(sttLevelMsg{level: dictation.ChunkLevel(chunk)})
|
||||
}
|
||||
// Also select on ctx.Done so this goroutine can't block forever on the
|
||||
// send if StreamTranscribe exits early (e.g. a sherpa startup failure) and
|
||||
// stops draining `tapped` — the session's cancel unblocks and drains it.
|
||||
select {
|
||||
case tapped <- chunk:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
streamCmd := func() tea.Msg {
|
||||
onPartial := func(text string, final bool) {
|
||||
if sink != nil {
|
||||
sink(sttPartialMsg{text: text, final: final})
|
||||
}
|
||||
}
|
||||
text, err := transcriber.StreamTranscribe(ctx, tapped, onPartial)
|
||||
return dictationTranscribedMsg{text: text, err: err, submit: submit, streaming: true}
|
||||
}
|
||||
// Streaming drives the waveform from real levels (no synthetic tick needed).
|
||||
return m, streamCmd
|
||||
}
|
||||
|
||||
// handleDictationPartial renders a cumulative partial transcript into the
|
||||
// composer, replacing the previously-rendered live region wholesale so the text
|
||||
// builds up in place as the user keeps talking.
|
||||
func (m model) handleDictationPartial(msg sttPartialMsg) model {
|
||||
// Ignore stragglers that arrive after the session ended (cancel/final).
|
||||
if m.dictation.phase != dictRecording && m.dictation.phase != dictTranscribing {
|
||||
return m
|
||||
}
|
||||
m.applyStreamingText(msg.text)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *model) applyStreamingText(text string) {
|
||||
state := m.currentComposerState()
|
||||
if !m.dictation.regionActive {
|
||||
m.dictation.regionActive = true
|
||||
m.dictation.regionStart = state.cursor
|
||||
m.dictation.regionEnd = state.cursor
|
||||
m.dictation.regionPrefix = ""
|
||||
// Anchor the prefix text BEFORE the live region. If the user types or
|
||||
// pastes outside the region, the next partial can detect the change in
|
||||
// this prefix and shift [start,end) to stay aligned.
|
||||
m.dictation.regionAnchor = state.text
|
||||
if needsLeadingSpace(state) {
|
||||
// Fold the separator into the region so a cancel removes it too.
|
||||
m.dictation.regionPrefix = " "
|
||||
}
|
||||
} else {
|
||||
// Compare the prefix before the live region. If it changed (the user
|
||||
// typed/pasted there) shift [start,end) by the length delta so the
|
||||
// next partial's slice targets the right span. If the user edited
|
||||
// INSIDE the region, drop the live region and re-anchor at the
|
||||
// current cursor — we can't tell the partial from the user's text
|
||||
// after that point.
|
||||
prefix := string([]rune(state.text)[:m.dictation.regionStart])
|
||||
anchor := m.dictation.regionAnchor
|
||||
switch {
|
||||
case prefix == anchor:
|
||||
// External edit at or after the region — no shift needed.
|
||||
case len(prefix) > len(anchor) && strings.HasPrefix(prefix, anchor):
|
||||
// External edit inserted text just before the region (i.e. at
|
||||
// position regionStart, pushing the region right).
|
||||
delta := len([]rune(prefix)) - len([]rune(anchor))
|
||||
m.dictation.regionStart += delta
|
||||
m.dictation.regionEnd += delta
|
||||
m.dictation.regionAnchor = prefix
|
||||
case len(anchor) > len(prefix) && strings.HasPrefix(anchor, prefix):
|
||||
// External delete just before the region.
|
||||
delta := len([]rune(anchor)) - len([]rune(prefix))
|
||||
m.dictation.regionStart -= delta
|
||||
m.dictation.regionEnd -= delta
|
||||
m.dictation.regionAnchor = prefix
|
||||
default:
|
||||
// User edited inside or across the region — can't safely
|
||||
// overwrite. Drop the live region and re-anchor at the cursor
|
||||
// so the next partial inserts as a fresh span.
|
||||
m.dictation.regionStart = state.cursor
|
||||
m.dictation.regionEnd = state.cursor
|
||||
m.dictation.regionPrefix = ""
|
||||
m.dictation.regionAnchor = string([]rune(state.text)[:state.cursor])
|
||||
}
|
||||
}
|
||||
// Replace [regionStart, regionEnd) with prefix + the new cumulative text.
|
||||
rendered := m.dictation.regionPrefix + text
|
||||
cleared := deleteComposerRange(state, m.dictation.regionStart, m.dictation.regionEnd)
|
||||
// Preserve the user's cursor if they moved it outside the live region
|
||||
// (e.g. to edit elsewhere in the composer) — yanking it to regionStart on
|
||||
// every partial would steal their focus mid-edit.
|
||||
if cleared.cursor < m.dictation.regionStart || cleared.cursor > m.dictation.regionEnd {
|
||||
// keep cleared.cursor
|
||||
} else {
|
||||
cleared.cursor = m.dictation.regionStart
|
||||
}
|
||||
updated := insertComposerText(cleared, rendered)
|
||||
m.dictation.regionEnd = m.dictation.regionStart + len([]rune(rendered))
|
||||
m.setComposerState(updated)
|
||||
}
|
||||
|
||||
// commitDictationRegion keeps the streamed text in the composer and stops
|
||||
// tracking it as a live region (used on successful completion — the final
|
||||
// transcript equals the last partial already rendered).
|
||||
func (m model) commitDictationRegion() model {
|
||||
m.dictation.regionActive = false
|
||||
return m
|
||||
}
|
||||
|
||||
// discardDictationRegion removes the live streamed text from the composer (used
|
||||
// on cancel — the user aborted, so the half-formed transcript is dropped).
|
||||
func (m model) discardDictationRegion() model {
|
||||
if m.dictation.regionActive {
|
||||
state := m.currentComposerState()
|
||||
m.setComposerState(deleteComposerRange(state, m.dictation.regionStart, m.dictation.regionEnd))
|
||||
m.dictation.regionActive = false
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/dictation"
|
||||
)
|
||||
|
||||
func TestDictationTranscribedInsertsIntoComposer(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "hello", cursor: 5})
|
||||
m.dictation.phase = dictTranscribing
|
||||
|
||||
next, _ := m.handleDictationTranscribed(dictationTranscribedMsg{text: "world"})
|
||||
got := next.(model)
|
||||
if got.composer.text != "hello world" {
|
||||
t.Errorf("composer = %q, want 'hello world'", got.composer.text)
|
||||
}
|
||||
if got.dictation.active() {
|
||||
t.Error("dictation should be idle after transcription")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationTranscribedEmptyIsNoticed(t *testing.T) {
|
||||
m := model{}
|
||||
m.dictation.phase = dictTranscribing
|
||||
next, _ := m.handleDictationTranscribed(dictationTranscribedMsg{text: " "})
|
||||
got := next.(model)
|
||||
if got.composer.text != "" {
|
||||
t.Errorf("composer should stay empty, got %q", got.composer.text)
|
||||
}
|
||||
if !transcriptHasText(got, "No speech detected") {
|
||||
t.Error("expected a 'no speech' notice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationTranscribedSetupErrorGuides(t *testing.T) {
|
||||
m := model{}
|
||||
m.dictation.phase = dictTranscribing
|
||||
err := &dictation.SetupError{Tool: "arecord", Hint: "install alsa-utils"}
|
||||
next, _ := m.handleDictationTranscribed(dictationTranscribedMsg{err: err})
|
||||
got := next.(model)
|
||||
if !transcriptHasText(got, "install alsa-utils") {
|
||||
t.Error("expected setup guidance in the transcript")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationAuthErrorReopensKeyPrompt(t *testing.T) {
|
||||
// A cloud auth failure should offer an inline key prompt for the current
|
||||
// provider (preserving its model), not dead-end on "run zero auth".
|
||||
m := model{}
|
||||
m.dictation.cfg = config.STTConfig{Provider: config.STTProviderGroq, Model: "whisper-large-v3-turbo"}
|
||||
m.dictation.saveKey = func(string, string) error { return nil }
|
||||
m.dictation.phase = dictTranscribing
|
||||
|
||||
err := &dictation.AuthError{Provider: "groq", Message: "auth error: your API key is missing or invalid"}
|
||||
next, _ := m.handleDictationTranscribed(dictationTranscribedMsg{err: err})
|
||||
got := next.(model)
|
||||
if got.sttKeyPrompt == nil {
|
||||
t.Fatal("an auth failure should reopen the API-key prompt")
|
||||
}
|
||||
if got.sttKeyPrompt.provider != "groq" || got.sttKeyPrompt.modelValue != "groq:whisper-large-v3-turbo" {
|
||||
t.Errorf("key prompt = %+v, want groq / groq:whisper-large-v3-turbo", got.sttKeyPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationNonAuthErrorDoesNotPrompt(t *testing.T) {
|
||||
m := model{}
|
||||
m.dictation.cfg = config.STTConfig{Provider: config.STTProviderGroq}
|
||||
m.dictation.saveKey = func(string, string) error { return nil }
|
||||
m.dictation.phase = dictTranscribing
|
||||
|
||||
next, _ := m.handleDictationTranscribed(dictationTranscribedMsg{err: errors.New("network unreachable")})
|
||||
got := next.(model)
|
||||
if got.sttKeyPrompt != nil {
|
||||
t.Error("a non-auth error must not open the key prompt")
|
||||
}
|
||||
if !transcriptHasText(got, "network unreachable") {
|
||||
t.Error("expected the raw error in the transcript")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationStartedFailureResets(t *testing.T) {
|
||||
m := model{}
|
||||
m.dictation.phase = dictStarting
|
||||
m.dictation.streaming = false
|
||||
got, _ := m.handleDictationStarted(dictationStartedMsg{err: errors.New("mic busy")})
|
||||
if got.dictation.active() {
|
||||
t.Error("a start failure should reset to idle")
|
||||
}
|
||||
if !transcriptHasText(got, "mic busy") {
|
||||
t.Error("expected the start error in the transcript")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationStartedArmsRecording(t *testing.T) {
|
||||
m := model{}
|
||||
m.dictation.phase = dictStarting
|
||||
got, _ := m.handleDictationStarted(dictationStartedMsg{})
|
||||
if got.dictation.phase != dictRecording {
|
||||
t.Error("a successful start should advance to recording")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationUnavailableShowsHint(t *testing.T) {
|
||||
m := model{} // no build factory
|
||||
next, _ := m.toggleDictation()
|
||||
if !transcriptHasText(next, "not configured") {
|
||||
t.Error("expected a 'not configured' hint when dictation is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationStreamingPartialReplacesRegion(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "note:", cursor: 5})
|
||||
m.dictation.phase = dictRecording
|
||||
m.dictation.streaming = true
|
||||
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "the quick"})
|
||||
if m.composer.text != "note: the quick" {
|
||||
t.Fatalf("after first partial: %q", m.composer.text)
|
||||
}
|
||||
// A longer cumulative partial replaces the previous region wholesale.
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "the quick brown fox"})
|
||||
if m.composer.text != "note: the quick brown fox" {
|
||||
t.Fatalf("after second partial: %q", m.composer.text)
|
||||
}
|
||||
// Cancel discards the streamed region.
|
||||
m2 := m.discardDictationRegion()
|
||||
if m2.composer.text != "note:" {
|
||||
t.Errorf("discard should restore original text, got %q", m2.composer.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationCommitKeepsStreamedText(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "", cursor: 0})
|
||||
m.dictation.phase = dictRecording
|
||||
m.dictation.streaming = true
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "final words"})
|
||||
got, _ := m.handleDictationTranscribed(dictationTranscribedMsg{text: "final words", streaming: true})
|
||||
if got.(model).composer.text != "final words" {
|
||||
t.Errorf("committed streamed text lost: %q", got.(model).composer.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceOffReleasesWarmServer(t *testing.T) {
|
||||
shutdownCalls := 0
|
||||
m := model{}
|
||||
m.dictation.build = func(config.STTConfig, bool) (Transcriber, bool, error) { return nil, false, nil } // makes available()
|
||||
m.dictation.shutdownServer = func(context.Context) error { shutdownCalls++; return nil }
|
||||
|
||||
on, _ := m.toggleVoiceMode()
|
||||
if !on.dictation.voiceModeEnabled {
|
||||
t.Fatal("voice should be on after first toggle")
|
||||
}
|
||||
off, cmd := on.toggleVoiceMode()
|
||||
if off.dictation.voiceModeEnabled {
|
||||
t.Fatal("voice should be off after second toggle")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("turning voice off should return a server-release command")
|
||||
}
|
||||
cmd() // run it
|
||||
if shutdownCalls != 1 {
|
||||
t.Errorf("shutdown called %d times, want 1", shutdownCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceOffMidRecordingKeepsServer(t *testing.T) {
|
||||
m := model{}
|
||||
m.dictation.build = func(config.STTConfig, bool) (Transcriber, bool, error) { return nil, false, nil }
|
||||
m.dictation.shutdownServer = func(context.Context) error {
|
||||
t.Error("must not tear down the server while a recording is in flight")
|
||||
return nil
|
||||
}
|
||||
m.dictation.voiceModeEnabled = true
|
||||
m.dictation.phase = dictRecording
|
||||
if _, cmd := m.toggleVoiceMode(); cmd != nil {
|
||||
t.Error("no server-release command should be issued mid-recording")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWantStreamingGatedByConfigAndPlatform(t *testing.T) {
|
||||
streamOff := false
|
||||
d := dictationController{cfg: config.STTConfig{Streaming: &streamOff}, platform: dictation.PlatformLinux}
|
||||
if d.wantStreaming() {
|
||||
t.Error("streaming:false in config should disable streaming")
|
||||
}
|
||||
d = dictationController{platform: dictation.PlatformTermux}
|
||||
if d.wantStreaming() {
|
||||
t.Error("Termux has no streaming capture, wantStreaming must be false")
|
||||
}
|
||||
d = dictationController{platform: dictation.PlatformLinux}
|
||||
if !d.wantStreaming() {
|
||||
t.Error("desktop default should want streaming")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsLeadingSpace(t *testing.T) {
|
||||
if needsLeadingSpace(composerState{text: "hi", cursor: 2}) != true {
|
||||
t.Error("cursor after non-space should need a leading space")
|
||||
}
|
||||
if needsLeadingSpace(composerState{text: "hi ", cursor: 3}) != false {
|
||||
t.Error("cursor after a space should not need one")
|
||||
}
|
||||
if needsLeadingSpace(composerState{text: "", cursor: 0}) != false {
|
||||
t.Error("empty composer should not need a leading space")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationBatchCtxNonNil(t *testing.T) {
|
||||
d := dictationController{}
|
||||
if got := transcribeBatchCtx(d.ctx); got == nil {
|
||||
t.Error("batch ctx should never be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// transcribeBatchCtx mirrors the nil-guard in transcribeBatchCmd for testing.
|
||||
func transcribeBatchCtx(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func transcriptHasText(m model, substr string) bool {
|
||||
for _, row := range m.transcript {
|
||||
if strings.Contains(row.text, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestWaveformRendersLevels(t *testing.T) {
|
||||
// Higher levels render taller bars; the ring scrolls left as levels arrive.
|
||||
var d dictationController
|
||||
d.pushLevel(levelHeight(1.0)) // loud → tall
|
||||
d.pushLevel(levelHeight(0.0)) // silent → short
|
||||
bars := renderWaveBars(d.waveBars)
|
||||
if len([]rune(bars)) != waveBarCount {
|
||||
t.Errorf("waveform width = %d, want %d", len([]rune(bars)), waveBarCount)
|
||||
}
|
||||
// The newest (rightmost) bar reflects the last pushed level (silent → space/low).
|
||||
runes := []rune(bars)
|
||||
if runes[waveBarCount-1] != waveRunes[0] {
|
||||
t.Errorf("last bar should be the silent level, got %q", string(runes[waveBarCount-1]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationLevelDrivesWave(t *testing.T) {
|
||||
m := model{}
|
||||
m.dictation.phase = dictRecording
|
||||
m = m.handleDictationLevel(sttLevelMsg{level: 0.9})
|
||||
if len(m.dictation.waveBars) != waveBarCount {
|
||||
t.Fatalf("a mic level should populate the waveform ring")
|
||||
}
|
||||
if m.dictation.waveBars[waveBarCount-1] != levelHeight(0.9) {
|
||||
t.Error("the newest bar should match the received mic level")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRecTickOnlyAnimatesWhileRecording(t *testing.T) {
|
||||
m := model{}
|
||||
m.dictation.phase = dictRecording
|
||||
next, cmd := m.handleRecTick()
|
||||
if next.dictation.waveTick != 1 || cmd == nil {
|
||||
t.Error("recording should advance the batch synthetic wave and reschedule")
|
||||
}
|
||||
m.dictation.phase = dictIdle
|
||||
_, cmd = m.handleRecTick()
|
||||
if cmd != nil {
|
||||
t.Error("idle should stop ticking")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentModelLabel(t *testing.T) {
|
||||
// Cloud provider + model.
|
||||
d := dictationController{cfg: config.STTConfig{Provider: config.STTProviderGroq, Model: "whisper-large-v3-turbo"}}
|
||||
if got := d.currentModelLabel(); got != "Groq whisper-large-v3-turbo" {
|
||||
t.Errorf("groq label = %q", got)
|
||||
}
|
||||
// Cloud provider with default model.
|
||||
d = dictationController{cfg: config.STTConfig{Provider: config.STTProviderOpenAI}}
|
||||
if got := d.currentModelLabel(); got != "OpenAI whisper-1" {
|
||||
t.Errorf("openai default label = %q", got)
|
||||
}
|
||||
// Local with a downloaded curated variant → "Local · <friendly name>".
|
||||
kroko := dictation.ModelVariants()[0] // Kroko streaming
|
||||
d = dictationController{cfg: config.STTConfig{Provider: config.STTProviderLocal, LocalModelPath: "/home/x/.config/zero/stt/" + kroko.DirName + "/sherpa-onnx-...-kroko"}}
|
||||
if got := d.currentModelLabel(); got != "Local · "+kroko.Label {
|
||||
t.Errorf("local kroko label = %q, want %q", got, "Local · "+kroko.Label)
|
||||
}
|
||||
// Local with no model.
|
||||
d = dictationController{cfg: config.STTConfig{Provider: config.STTProviderLocal}}
|
||||
if got := d.currentModelLabel(); got != "Local (no model set)" {
|
||||
t.Errorf("no-model label = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// Voice mode's Space-hold gesture — the (only) dictation trigger. Two terminal
|
||||
// tiers:
|
||||
//
|
||||
// 1. The terminal confirms key-release reporting (Ghostty/Kitty/WezTerm, …):
|
||||
// Space press starts recording, Space release stops it — true hold-to-record,
|
||||
// no ambiguity.
|
||||
// 2. The terminal does not confirm release events: Space falls back to
|
||||
// press-to-toggle (press to start, press again to stop) — a deliberately
|
||||
// simpler, robust fallback than inferring release from key-repeat timing
|
||||
// (racy in a terminal). This works on ANY terminal, so voice mode is always
|
||||
// usable once enabled.
|
||||
//
|
||||
// Only active while voice mode is on; the rest of dispatch is built on
|
||||
// KeyPressMsg and is untouched.
|
||||
|
||||
// handleKeyboardEnhancements records the terminal's confirmed keyboard
|
||||
// capabilities, so voice mode knows whether hold-to-record is available.
|
||||
func (m model) handleKeyboardEnhancements(msg tea.KeyboardEnhancementsMsg) model {
|
||||
m.dictation.eventTypesKnown = true
|
||||
m.dictation.eventTypesSupported = msg.SupportsEventTypes()
|
||||
return m
|
||||
}
|
||||
|
||||
// handleVoiceSpacePress handles a Space press while voice mode is on.
|
||||
func (m model) handleVoiceSpacePress(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
if !m.dictation.eventTypesSupported {
|
||||
// Tier 2: no release events — press-to-toggle.
|
||||
return m.toggleDictation()
|
||||
}
|
||||
// Tier 1: hold-to-record. Ignore auto-repeat presses while the key is held.
|
||||
if msg.Key().IsRepeat {
|
||||
return m, nil
|
||||
}
|
||||
if m.dictation.phase == dictIdle {
|
||||
m.dictation.spaceHeld = true
|
||||
m.dictation.voiceStopPending = false
|
||||
return m.startDictation()
|
||||
}
|
||||
return m, nil // already recording; the release will stop it
|
||||
}
|
||||
|
||||
// handleVoiceSpaceRelease stops a hold-to-record session when Space is released.
|
||||
func (m model) handleVoiceSpaceRelease() (model, tea.Cmd) {
|
||||
if !m.dictation.spaceHeld {
|
||||
return m, nil
|
||||
}
|
||||
m.dictation.spaceHeld = false
|
||||
switch m.dictation.phase {
|
||||
case dictRecording:
|
||||
return m.stopDictation()
|
||||
case dictStarting:
|
||||
// Released before the recording finished starting; stop as soon as it does.
|
||||
m.dictation.voiceStopPending = true
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
type fakeTUITranscriber struct{}
|
||||
|
||||
func (fakeTUITranscriber) Transcribe(context.Context, []byte) (string, error) { return "", nil }
|
||||
func (fakeTUITranscriber) StreamTranscribe(context.Context, <-chan []byte, func(string, bool)) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// batchOnlyController builds a controller whose recordings take the batch path
|
||||
// (streaming disabled), so startDictation returns an unexecuted command instead
|
||||
// of exec'ing a real capture tool.
|
||||
func batchOnlyController() dictationController {
|
||||
streamOff := false
|
||||
return dictationController{
|
||||
cfg: config.STTConfig{Streaming: &streamOff},
|
||||
platform: "linux",
|
||||
build: func(config.STTConfig, bool) (Transcriber, bool, error) {
|
||||
return fakeTUITranscriber{}, false, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleVoiceModeFlips(t *testing.T) {
|
||||
m := model{dictation: batchOnlyController()}
|
||||
next, _ := m.toggleVoiceMode()
|
||||
if !next.dictation.voiceModeEnabled {
|
||||
t.Fatal("first /voice should enable voice mode")
|
||||
}
|
||||
if !transcriptHasText(next, "Voice mode on") {
|
||||
t.Error("expected voice-mode-on notice")
|
||||
}
|
||||
off, _ := next.toggleVoiceMode()
|
||||
if off.dictation.voiceModeEnabled {
|
||||
t.Error("second /voice should disable voice mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceModeUnavailableWithoutFactory(t *testing.T) {
|
||||
m := model{} // no build factory
|
||||
next, _ := m.toggleVoiceMode()
|
||||
if next.dictation.voiceModeEnabled {
|
||||
t.Error("voice mode must not enable when dictation is unavailable")
|
||||
}
|
||||
if !transcriptHasText(next, "not configured") {
|
||||
t.Error("expected a not-configured hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyboardEnhancementsRecorded(t *testing.T) {
|
||||
m := model{dictation: batchOnlyController()}
|
||||
// Flags with the event-types bit unset → not supported.
|
||||
m = m.handleKeyboardEnhancements(tea.KeyboardEnhancementsMsg{Flags: 0})
|
||||
if !m.dictation.eventTypesKnown || m.dictation.eventTypesSupported {
|
||||
t.Error("Flags=0 should be known-but-unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceSpaceHoldStartsRecording(t *testing.T) {
|
||||
m := model{dictation: batchOnlyController()}
|
||||
m.dictation.voiceModeEnabled = true
|
||||
m.dictation.eventTypesSupported = true
|
||||
|
||||
press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace})
|
||||
next, cmd := m.handleVoiceSpacePress(press)
|
||||
if next.dictation.phase != dictStarting {
|
||||
t.Fatalf("Space press should start recording (phase=%d)", next.dictation.phase)
|
||||
}
|
||||
if !next.dictation.spaceHeld {
|
||||
t.Error("spaceHeld should be set in hold mode")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected a start command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceSpaceHoldIgnoresRepeat(t *testing.T) {
|
||||
m := model{dictation: batchOnlyController()}
|
||||
m.dictation.voiceModeEnabled = true
|
||||
m.dictation.eventTypesSupported = true
|
||||
m.dictation.phase = dictRecording
|
||||
m.dictation.spaceHeld = true
|
||||
|
||||
repeat := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, IsRepeat: true})
|
||||
next, _ := m.handleVoiceSpacePress(repeat)
|
||||
if next.dictation.phase != dictRecording {
|
||||
t.Error("auto-repeat while held must not restart or change phase")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceSpaceReleaseStops(t *testing.T) {
|
||||
m := model{dictation: batchOnlyController()}
|
||||
m.dictation.voiceModeEnabled = true
|
||||
m.dictation.eventTypesSupported = true
|
||||
m.dictation.phase = dictRecording
|
||||
m.dictation.spaceHeld = true
|
||||
|
||||
next, _ := m.handleVoiceSpaceRelease()
|
||||
if next.dictation.phase != dictTranscribing {
|
||||
t.Errorf("release should stop recording → transcribing (phase=%d)", next.dictation.phase)
|
||||
}
|
||||
if next.dictation.spaceHeld {
|
||||
t.Error("spaceHeld should clear on release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceReleaseDuringStartupDefersStop(t *testing.T) {
|
||||
m := model{dictation: batchOnlyController()}
|
||||
m.dictation.voiceModeEnabled = true
|
||||
m.dictation.eventTypesSupported = true
|
||||
m.dictation.phase = dictStarting
|
||||
m.dictation.spaceHeld = true
|
||||
|
||||
next, _ := m.handleVoiceSpaceRelease()
|
||||
if !next.dictation.voiceStopPending {
|
||||
t.Error("a release during startup should defer the stop")
|
||||
}
|
||||
// When the recording finishes starting, the deferred stop fires.
|
||||
after, _ := next.handleDictationStarted(dictationStartedMsg{})
|
||||
if after.dictation.phase != dictTranscribing {
|
||||
t.Errorf("deferred stop should transition to transcribing (phase=%d)", after.dictation.phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceSpaceToggleFallback(t *testing.T) {
|
||||
m := model{dictation: batchOnlyController()}
|
||||
m.dictation.voiceModeEnabled = true
|
||||
m.dictation.eventTypesSupported = false // no release events → toggle fallback
|
||||
|
||||
press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace})
|
||||
next, _ := m.handleVoiceSpacePress(press)
|
||||
if next.dictation.phase != dictStarting {
|
||||
t.Errorf("toggle-mode Space should start recording (phase=%d)", next.dictation.phase)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -77,6 +79,9 @@ func (p parsedBinding) Label() string {
|
||||
b.WriteString("PgUp")
|
||||
case tea.KeyPgDown:
|
||||
b.WriteString("PgDn")
|
||||
case tea.KeyF1, tea.KeyF2, tea.KeyF3, tea.KeyF4, tea.KeyF5, tea.KeyF6,
|
||||
tea.KeyF7, tea.KeyF8, tea.KeyF9, tea.KeyF10, tea.KeyF11, tea.KeyF12:
|
||||
b.WriteString(fKeyLabel(p.code))
|
||||
default:
|
||||
// Printable character — uppercase for display
|
||||
if p.code >= 'a' && p.code <= 'z' {
|
||||
@@ -206,6 +211,30 @@ func parseBinding(s string) parsedBinding {
|
||||
p.code = tea.KeyPgUp
|
||||
case "pgdown", "pagedown":
|
||||
p.code = tea.KeyPgDown
|
||||
case "f1":
|
||||
p.code = tea.KeyF1
|
||||
case "f2":
|
||||
p.code = tea.KeyF2
|
||||
case "f3":
|
||||
p.code = tea.KeyF3
|
||||
case "f4":
|
||||
p.code = tea.KeyF4
|
||||
case "f5":
|
||||
p.code = tea.KeyF5
|
||||
case "f6":
|
||||
p.code = tea.KeyF6
|
||||
case "f7":
|
||||
p.code = tea.KeyF7
|
||||
case "f8":
|
||||
p.code = tea.KeyF8
|
||||
case "f9":
|
||||
p.code = tea.KeyF9
|
||||
case "f10":
|
||||
p.code = tea.KeyF10
|
||||
case "f11":
|
||||
p.code = tea.KeyF11
|
||||
case "f12":
|
||||
p.code = tea.KeyF12
|
||||
case "?":
|
||||
p.text = "?"
|
||||
p.code = 0
|
||||
@@ -242,6 +271,13 @@ type keyBindings struct {
|
||||
toggleSidebar parsedBinding
|
||||
}
|
||||
|
||||
// fKeyLabel renders a function-key code as "F9" etc. tea.KeyF1..KeyF12 are
|
||||
// sequential, so the offset from KeyF1 gives the number.
|
||||
func fKeyLabel(code rune) string {
|
||||
n := int(code-tea.KeyF1) + 1
|
||||
return "F" + strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// resolveKeyBindings converts a user-facing KeyBindingsConfig into the
|
||||
// dispatch-ready parsed form, using empty-is-default semantics.
|
||||
func resolveKeyBindings(cfg config.KeyBindingsConfig) keyBindings {
|
||||
@@ -263,3 +299,96 @@ func (m model) keyMatch(b parsedBinding, msg tea.KeyMsg, defaultFn func(tea.KeyM
|
||||
}
|
||||
return defaultFn(msg)
|
||||
}
|
||||
|
||||
// reservedBindings lists hardcoded (non-configurable) chords handled directly in
|
||||
// model.go's key dispatch. If a configurable binding uses one of these chords,
|
||||
// one of the actions becomes unreachable (depending on switch order), so
|
||||
// sanitizeKeyBindings reverts the configurable binding back to its default.
|
||||
var reservedBindings = []struct {
|
||||
binding parsedBinding
|
||||
description string
|
||||
}{
|
||||
{parseBinding("ctrl+c"), "cancel / exit"},
|
||||
{parseBinding("esc"), "cancel / close"},
|
||||
{parseBinding("enter"), "submit"},
|
||||
{parseBinding("shift+tab"), "cycle permission mode"},
|
||||
{parseBinding("tab"), "navigation / completion"},
|
||||
{parseBinding("backspace"), "composer edit / attachment removal"},
|
||||
{parseBinding("up"), "history/navigation"},
|
||||
{parseBinding("down"), "history/navigation"},
|
||||
{parseBinding("pgup"), "transcript scroll"},
|
||||
{parseBinding("pgdown"), "transcript scroll"},
|
||||
{parseBinding("ctrl+f"), "favorite model (in the /model picker)"},
|
||||
{parseBinding("?"), "help overlay"},
|
||||
}
|
||||
|
||||
// sanitizeKeyBindings drops (reverts to default) any configured binding that
|
||||
// collides with a reserved hardcoded chord above, or with another
|
||||
// configured binding, since either collision would silently make one of the
|
||||
// two actions permanently unreachable. Returns the sanitized bindings plus a
|
||||
// human-readable warning for each dropped binding, for the caller to surface
|
||||
// as a startup notice.
|
||||
func sanitizeKeyBindings(b keyBindings) (keyBindings, []string) {
|
||||
entries := []struct {
|
||||
name string
|
||||
binding *parsedBinding
|
||||
defaultBinding parsedBinding
|
||||
}{
|
||||
{"toggleDetailed", &b.toggleDetailed, parseBinding("ctrl+o")},
|
||||
{"toggleMouse", &b.toggleMouse, parseBinding("ctrl+e")},
|
||||
{"cycleReasoning", &b.cycleReasoning, parseBinding("ctrl+t")},
|
||||
{"togglePlan", &b.togglePlan, parseBinding("ctrl+p")},
|
||||
{"toggleSidebar", &b.toggleSidebar, parseBinding("ctrl+b")},
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
for _, e := range entries {
|
||||
if e.binding.isZero() {
|
||||
continue
|
||||
}
|
||||
for _, other := range entries {
|
||||
if other.name == e.name || !other.binding.isZero() {
|
||||
continue
|
||||
}
|
||||
if *e.binding == other.defaultBinding {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"keybindings.%s (%s) conflicts with keybindings.%s default (%s); using the default instead.",
|
||||
e.name, e.binding.Label(), other.name, other.defaultBinding.Label()))
|
||||
*e.binding = parsedBinding{}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.binding.isZero() {
|
||||
continue
|
||||
}
|
||||
for _, reserved := range reservedBindings {
|
||||
if *e.binding == reserved.binding {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"keybindings.%s (%s) conflicts with the built-in %s shortcut; using the default instead.",
|
||||
e.name, e.binding.Label(), reserved.description))
|
||||
*e.binding = parsedBinding{}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
claimedBy := map[parsedBinding]string{}
|
||||
for _, e := range entries {
|
||||
if e.binding.isZero() {
|
||||
continue
|
||||
}
|
||||
if other, ok := claimedBy[*e.binding]; ok {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"keybindings.%s (%s) conflicts with keybindings.%s; using the default instead.",
|
||||
e.name, e.binding.Label(), other))
|
||||
*e.binding = parsedBinding{}
|
||||
continue
|
||||
}
|
||||
claimedBy[*e.binding] = e.name
|
||||
}
|
||||
|
||||
return b, warnings
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// The /loop command repeats a prompt or slash command in one of two modes:
|
||||
//
|
||||
// - Fixed interval — `/loop 5m <prompt>` re-fires on a wall-clock cadence.
|
||||
// - Self-paced — bare `/loop <prompt>` runs a maintenance playbook: the model
|
||||
// does one increment of work each iteration and ends its reply with a control
|
||||
// line (`LOOP: DONE` / `LOOP: CONTINUE [interval]`) that stops the loop or picks
|
||||
// the next wake. With no control line it falls back to an adaptive cadence.
|
||||
//
|
||||
// Loops are foreground and session-scoped: a tick only fires while the session is
|
||||
// idle between turns (it never interrupts a streaming turn), the loop lives with the
|
||||
// session that created it, and switching sessions (/new, /resume) stops it. Safety
|
||||
// rails cap iterations, age, consecutive failures, and no-progress repetition.
|
||||
// Everything here is pure/loop-local; the turn-lifecycle wiring (ticker, launch,
|
||||
// continuation) lives in model.go.
|
||||
|
||||
type loopMode int
|
||||
|
||||
const (
|
||||
loopModeFixed loopMode = iota // re-fire every fixed interval
|
||||
loopModeSelfPaced // model chooses the next wake each iteration
|
||||
)
|
||||
|
||||
// Safety bounds. A loop is foreground + session-scoped, but these keep a runaway
|
||||
// or forgotten loop from burning tokens forever.
|
||||
const (
|
||||
loopMinInterval = 30 * time.Second
|
||||
loopMaxInterval = 24 * time.Hour
|
||||
loopMaxIterations = 500 // hard ceiling; the loop auto-stops after this many
|
||||
|
||||
// loopMaxAge auto-expires a loop this long after it was created, so a forgotten
|
||||
// loop stops on its own even if it never reaches the iteration cap.
|
||||
loopMaxAge = 24 * time.Hour
|
||||
|
||||
loopSelfPaceMin = 1 * time.Minute
|
||||
loopSelfPaceMax = 1 * time.Hour
|
||||
loopSelfPaceDefault = 15 * time.Minute
|
||||
|
||||
// loopDoomThreshold ends a loop after this many consecutive identical final
|
||||
// answers — a self-paced or interval loop that gets stuck repeating the same
|
||||
// no-progress result should stop rather than churn.
|
||||
loopDoomThreshold = 5
|
||||
|
||||
// loopMaxFailures ends a loop after this many consecutive failed iterations, so
|
||||
// a loop whose iterations keep erroring stops instead of churning to the
|
||||
// iteration cap (a failed turn has no final answer, so the doom guard can't see
|
||||
// the lack of progress).
|
||||
loopMaxFailures = 3
|
||||
|
||||
// loopPollInterval is how often the controller checks whether a loop is due.
|
||||
// A loop only fires while idle, so this is a cheap between-turn poll.
|
||||
loopPollInterval = 1 * time.Second
|
||||
)
|
||||
|
||||
// loopState is one active loop owned by the current session.
|
||||
type loopState struct {
|
||||
id string
|
||||
mode loopMode
|
||||
prompt string // the prompt or "/command" re-run each iteration
|
||||
interval time.Duration
|
||||
iteration int
|
||||
maxIter int // 0 => loopMaxIterations
|
||||
createdAt time.Time
|
||||
nextRunAt time.Time
|
||||
// lastResult / repeatRun drive doom-loop detection: consecutive identical
|
||||
// final answers past loopDoomThreshold end the loop.
|
||||
lastResult string
|
||||
repeatRun int
|
||||
// failRun counts consecutive failed iterations; past loopMaxFailures the loop
|
||||
// stops (a failed turn has no final answer for the doom guard to compare).
|
||||
failRun int
|
||||
paused bool
|
||||
}
|
||||
|
||||
func (l *loopState) iterationCap() int {
|
||||
if l.maxIter > 0 {
|
||||
return l.maxIter
|
||||
}
|
||||
return loopMaxIterations
|
||||
}
|
||||
|
||||
// due reports whether the loop should fire at now (active, not paused, past its
|
||||
// next-run mark).
|
||||
func (l *loopState) due(now time.Time) bool {
|
||||
return !l.paused && !l.nextRunAt.IsZero() && !now.Before(l.nextRunAt)
|
||||
}
|
||||
|
||||
// cadenceText renders the loop's cadence for status lines.
|
||||
func (l *loopState) cadenceText() string {
|
||||
if l.mode == loopModeSelfPaced {
|
||||
return "self-paced"
|
||||
}
|
||||
return "every " + formatLoopDuration(l.interval)
|
||||
}
|
||||
|
||||
type loopAction int
|
||||
|
||||
const (
|
||||
loopActionUsage loopAction = iota // show usage / list when empty
|
||||
loopActionStart // start a new loop
|
||||
loopActionStop // stop one by id
|
||||
loopActionStopAll // stop every loop
|
||||
loopActionList // list active loops
|
||||
)
|
||||
|
||||
// loopCommand is the parsed form of a `/loop ...` invocation (the text after the
|
||||
// command name).
|
||||
type loopCommand struct {
|
||||
action loopAction
|
||||
targetID string
|
||||
mode loopMode
|
||||
prompt string
|
||||
interval time.Duration
|
||||
// note carries a non-fatal advisory (e.g. an interval that was clamped) to
|
||||
// surface back to the user.
|
||||
note string
|
||||
}
|
||||
|
||||
var loopIntervalRe = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
|
||||
|
||||
// loopControlRe matches a self-paced loop's control line at the start of a line,
|
||||
// tolerant of a leading markdown bullet/quote marker and any trailing note after
|
||||
// the keyword: "LOOP: DONE", "- loop: continue", "LOOP: CONTINUE 10m — still tidying".
|
||||
// The leading ^ anchor keeps a mid-sentence mention ("I'll emit LOOP: DONE when…")
|
||||
// from matching; parseLoopControl normalizes CRLF first so `$` can reach line end.
|
||||
var loopControlRe = regexp.MustCompile(`(?im)^[^\S\r\n]*(?:[-*>•][^\S\r\n]*)?loop:[^\S\r\n]*(done|continue)\b(?:[^\S\r\n]+(\d+[smhd])\b)?.*$`)
|
||||
|
||||
// parseLoopCommand interprets the arguments to `/loop`. Forms:
|
||||
//
|
||||
// (empty) -> usage/list
|
||||
// list | status -> list active loops
|
||||
// stop | stop all | stop <id>
|
||||
// <interval> <prompt> -> fixed-interval loop (e.g. "5m /babysit")
|
||||
// <prompt> every <interval>
|
||||
// <prompt> -> self-paced loop (no interval)
|
||||
func parseLoopCommand(args string) loopCommand {
|
||||
trimmed := strings.TrimSpace(args)
|
||||
if trimmed == "" {
|
||||
return loopCommand{action: loopActionUsage}
|
||||
}
|
||||
|
||||
first := strings.ToLower(strings.Fields(trimmed)[0])
|
||||
switch first {
|
||||
case "list", "status", "ls":
|
||||
return loopCommand{action: loopActionList}
|
||||
case "stop", "cancel", "kill":
|
||||
rest := strings.TrimSpace(trimmed[len(strings.Fields(trimmed)[0]):])
|
||||
if rest == "" || strings.EqualFold(rest, "all") {
|
||||
if strings.EqualFold(rest, "all") {
|
||||
return loopCommand{action: loopActionStopAll}
|
||||
}
|
||||
// bare "stop" stops all when ambiguous; the caller may special-case a
|
||||
// single active loop.
|
||||
return loopCommand{action: loopActionStop, targetID: ""}
|
||||
}
|
||||
return loopCommand{action: loopActionStop, targetID: strings.TrimSpace(rest)}
|
||||
}
|
||||
|
||||
// Leading interval token: "5m <prompt>".
|
||||
if fields := strings.Fields(trimmed); len(fields) >= 2 {
|
||||
if d, ok := parseLoopInterval(fields[0]); ok {
|
||||
prompt := strings.TrimSpace(trimmed[len(fields[0]):])
|
||||
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
|
||||
cmd.interval, cmd.note = clampLoopInterval(d)
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing "every <interval>".
|
||||
if prompt, d, ok := splitTrailingEvery(trimmed); ok {
|
||||
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
|
||||
cmd.interval, cmd.note = clampLoopInterval(d)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// No interval anywhere -> self-paced.
|
||||
return loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: trimmed}
|
||||
}
|
||||
|
||||
// splitTrailingEvery matches "<prompt> every <interval>" (the interval must be a
|
||||
// real duration token so "check every PR" is not mis-parsed as a cadence).
|
||||
func splitTrailingEvery(input string) (string, time.Duration, bool) {
|
||||
fields := strings.Fields(input)
|
||||
if len(fields) < 3 {
|
||||
return "", 0, false
|
||||
}
|
||||
last := fields[len(fields)-1]
|
||||
prev := fields[len(fields)-2]
|
||||
if !strings.EqualFold(prev, "every") {
|
||||
return "", 0, false
|
||||
}
|
||||
d, ok := parseLoopInterval(last)
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
prompt := strings.TrimSpace(strings.Join(fields[:len(fields)-2], " "))
|
||||
if prompt == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
return prompt, d, true
|
||||
}
|
||||
|
||||
// parseLoopInterval parses a bare "<N><unit>" token (s/m/h/d) into a duration.
|
||||
func parseLoopInterval(token string) (time.Duration, bool) {
|
||||
m := loopIntervalRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(token)))
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
unit := map[string]time.Duration{
|
||||
"s": time.Second,
|
||||
"m": time.Minute,
|
||||
"h": time.Hour,
|
||||
"d": 24 * time.Hour,
|
||||
}[m[2]]
|
||||
return time.Duration(n) * unit, true
|
||||
}
|
||||
|
||||
// clampLoopInterval bounds an interval to [loopMinInterval, loopMaxInterval] and
|
||||
// returns an advisory note when it was adjusted.
|
||||
func clampLoopInterval(d time.Duration) (time.Duration, string) {
|
||||
switch {
|
||||
case d < loopMinInterval:
|
||||
return loopMinInterval, fmt.Sprintf("interval raised to the %s minimum", formatLoopDuration(loopMinInterval))
|
||||
case d > loopMaxInterval:
|
||||
return loopMaxInterval, fmt.Sprintf("interval lowered to the %s maximum", formatLoopDuration(loopMaxInterval))
|
||||
default:
|
||||
return d, ""
|
||||
}
|
||||
}
|
||||
|
||||
// loopTickMsg is the between-turn poll that fires a due loop while the session is
|
||||
// idle. seq invalidates a tick left over from before the loop set changed.
|
||||
type loopTickMsg struct{ seq int }
|
||||
|
||||
// handleLoopCommand dispatches a parsed `/loop ...` invocation.
|
||||
func (m model) handleLoopCommand(args string) (model, tea.Cmd) {
|
||||
cmd := parseLoopCommand(args)
|
||||
switch cmd.action {
|
||||
case loopActionList:
|
||||
return m.appendLoopSystem(m.loopListText()), nil
|
||||
case loopActionUsage:
|
||||
if len(m.loops) > 0 {
|
||||
return m.appendLoopSystem(m.loopListText()), nil
|
||||
}
|
||||
return m.appendLoopSystem(loopUsageText()), nil
|
||||
case loopActionStop:
|
||||
return m.stopLoop(cmd.targetID)
|
||||
case loopActionStopAll:
|
||||
return m.stopAllLoops()
|
||||
case loopActionStart:
|
||||
return m.startLoop(cmd)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// startLoop registers a new loop and (re)starts the idle poll ticker. The first
|
||||
// iteration fires on the next idle tick (nextRunAt = now).
|
||||
func (m model) startLoop(cmd loopCommand) (model, tea.Cmd) {
|
||||
if reason, ok := m.validateLoopTarget(cmd.prompt); !ok {
|
||||
return m.appendLoopSystem(reason), nil
|
||||
}
|
||||
// A /command loop must be fixed-interval. Self-paced mode drives a free-form
|
||||
// goal through the playbook + LOOP: control line (see firePrompt), which a
|
||||
// custom command's expanded template never carries — so a bare self-paced
|
||||
// /command would silently degrade to adaptive polling with no way to report
|
||||
// done. Require an explicit interval for command loops instead.
|
||||
if cmd.mode == loopModeSelfPaced && strings.HasPrefix(strings.TrimSpace(cmd.prompt), "/") {
|
||||
return m.appendLoopSystem("A /command loop needs an interval — e.g. /loop 5m " + strings.Fields(cmd.prompt)[0] + ". Self-paced mode (no interval) is for a free-form goal."), nil
|
||||
}
|
||||
m.loopCounter++
|
||||
id := "L" + strconv.Itoa(m.loopCounter)
|
||||
loop := &loopState{
|
||||
id: id,
|
||||
mode: cmd.mode,
|
||||
prompt: cmd.prompt,
|
||||
interval: cmd.interval,
|
||||
createdAt: m.now(),
|
||||
nextRunAt: m.now(), // fire the first iteration on the next idle tick
|
||||
}
|
||||
m.loops = append(m.loops, loop)
|
||||
note := ""
|
||||
if cmd.note != "" {
|
||||
note = " (" + cmd.note + ")"
|
||||
}
|
||||
m = m.appendLoopSystem(fmt.Sprintf(
|
||||
"Loop %s started — %s%s. Runs while this session is idle; stop with /loop stop %s.",
|
||||
id, loop.cadenceText(), note, id))
|
||||
return m.ensureLoopTick()
|
||||
}
|
||||
|
||||
// stopLoop stops one loop by id, or the single active loop when the id is omitted.
|
||||
func (m model) stopLoop(id string) (model, tea.Cmd) {
|
||||
if len(m.loops) == 0 {
|
||||
return m.appendLoopSystem("No active loops."), nil
|
||||
}
|
||||
if id == "" {
|
||||
if len(m.loops) == 1 {
|
||||
return m.removeLoop(m.loops[0].id, "Loop "+m.loops[0].id+" stopped.").ensureLoopTick()
|
||||
}
|
||||
return m.appendLoopSystem("Multiple loops active — use /loop stop <id> or /loop stop all.\n" + m.loopListText()), nil
|
||||
}
|
||||
if m.findLoop(id) == nil {
|
||||
return m.appendLoopSystem("No loop " + id + ". " + m.loopListText()), nil
|
||||
}
|
||||
// removeLoop stops the poll ticker; re-arm it so any OTHER loops that remain
|
||||
// keep firing (ensureLoopTick no-ops when this was the last loop).
|
||||
return m.removeLoop(id, "Loop "+id+" stopped.").ensureLoopTick()
|
||||
}
|
||||
|
||||
func (m model) stopAllLoops() (model, tea.Cmd) {
|
||||
if len(m.loops) == 0 {
|
||||
return m.appendLoopSystem("No active loops."), nil
|
||||
}
|
||||
n := len(m.loops)
|
||||
m.loops = nil
|
||||
m.loopSeq++ // invalidate the pending poll tick
|
||||
m.loopTicking = false
|
||||
return m.appendLoopSystem(fmt.Sprintf("Stopped all %d loop(s).", n)), nil
|
||||
}
|
||||
|
||||
// fireDueLoopIfIdle fires the earliest due loop when the session is idle. Called
|
||||
// from the poll tick; a no-op while a turn, modal, or queued user message is
|
||||
// pending (the loop simply waits for the next idle tick).
|
||||
func (m model) fireDueLoopIfIdle() (model, tea.Cmd) {
|
||||
if m.loopBusy() || len(m.loops) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
now := m.now()
|
||||
var due *loopState
|
||||
for _, l := range m.loops {
|
||||
if l.due(now) && (due == nil || l.nextRunAt.Before(due.nextRunAt)) {
|
||||
due = l
|
||||
}
|
||||
}
|
||||
if due == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.activeLoopID = due.id
|
||||
due.nextRunAt = time.Time{} // mark running
|
||||
return m.fireLoopPrompt(due.firePrompt())
|
||||
}
|
||||
|
||||
// firePrompt is the text actually sent for this iteration. A self-paced plain
|
||||
// prompt is wrapped in the maintenance playbook so the model knows to do one
|
||||
// increment of work and end with a LOOP: control line; a /command or a
|
||||
// fixed-interval prompt is sent verbatim.
|
||||
func (l *loopState) firePrompt() string {
|
||||
if l.mode == loopModeSelfPaced && !strings.HasPrefix(strings.TrimSpace(l.prompt), "/") {
|
||||
return loopSelfPacePlaybook(l)
|
||||
}
|
||||
return l.prompt
|
||||
}
|
||||
|
||||
// fireLoopPrompt runs a loop's prompt as a turn — a custom /command if it resolves
|
||||
// to one, otherwise a plain prompt.
|
||||
func (m model) fireLoopPrompt(prompt string) (model, tea.Cmd) {
|
||||
if strings.HasPrefix(strings.TrimSpace(prompt), "/") {
|
||||
if next, teaCmd, ok := m.handleUserCommand(prompt); ok {
|
||||
return next, teaCmd
|
||||
}
|
||||
}
|
||||
return m.launchPrompt(prompt)
|
||||
}
|
||||
|
||||
// advanceLoop updates a loop after one of its iterations completes: it folds the
|
||||
// result into the failure/no-progress guards, honors a self-paced DONE/next-wake
|
||||
// control line, enforces the iteration and age caps, then schedules the next wake
|
||||
// (or stops the loop).
|
||||
func (m model) advanceLoop(id, finalAnswer string, runErr error) model {
|
||||
l := m.findLoop(id)
|
||||
if l == nil {
|
||||
return m // stopped mid-run
|
||||
}
|
||||
l.iteration++
|
||||
if runErr != nil {
|
||||
l.failRun++
|
||||
} else {
|
||||
l.failRun = 0
|
||||
}
|
||||
res := strings.TrimSpace(finalAnswer)
|
||||
if res != "" && res == l.lastResult {
|
||||
l.repeatRun++
|
||||
} else {
|
||||
l.repeatRun = 0
|
||||
}
|
||||
l.lastResult = res
|
||||
|
||||
// Only self-paced iterations run the playbook, so only they can carry a control
|
||||
// line; a failed turn has no trustworthy final answer to read one from.
|
||||
done, wake := false, time.Duration(0)
|
||||
if l.mode == loopModeSelfPaced && runErr == nil {
|
||||
done, wake = parseLoopControl(finalAnswer)
|
||||
}
|
||||
|
||||
switch {
|
||||
case done:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s finished — the task reported done after %d iteration(s).", id, l.iteration))
|
||||
case l.failRun >= loopMaxFailures:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped after %d consecutive failed iterations.", id, l.failRun))
|
||||
case l.iteration >= l.iterationCap():
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped at its %d-iteration cap.", id, l.iteration))
|
||||
case !l.createdAt.IsZero() && m.now().Sub(l.createdAt) >= loopMaxAge:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s expired after %s.", id, formatLoopDuration(loopMaxAge)))
|
||||
case l.repeatRun >= loopDoomThreshold:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped — %d identical results in a row (no progress).", id, l.repeatRun))
|
||||
}
|
||||
|
||||
if l.mode == loopModeSelfPaced {
|
||||
// A model-chosen next wake (from the control line) wins; otherwise fall back to
|
||||
// an adaptive cadence that checks back often while work is fresh and settles to
|
||||
// a slow heartbeat as iterations accrue.
|
||||
delay := wake
|
||||
if delay <= 0 {
|
||||
delay = adaptiveSelfPaceDelay(l.iteration)
|
||||
}
|
||||
l.nextRunAt = m.now().Add(clampSelfPaceDelay(delay))
|
||||
} else {
|
||||
l.nextRunAt = m.now().Add(l.interval)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseLoopControl reads a self-paced loop's control signal from the model's final
|
||||
// answer. The last control line wins (the model may reference the protocol before
|
||||
// emitting it). Returns done=true to stop the loop, or a next-wake hint (0 = none).
|
||||
func parseLoopControl(answer string) (done bool, wake time.Duration) {
|
||||
// Normalize CRLF/CR so the line-end anchor can reach `$` (Go's (?m)$ sits right
|
||||
// before `\n`, and the horizontal-whitespace class can't consume a stray `\r`).
|
||||
norm := strings.ReplaceAll(answer, "\r\n", "\n")
|
||||
norm = strings.ReplaceAll(norm, "\r", "\n")
|
||||
matches := loopControlRe.FindAllStringSubmatch(norm, -1)
|
||||
if len(matches) == 0 {
|
||||
return false, 0
|
||||
}
|
||||
last := matches[len(matches)-1]
|
||||
if strings.EqualFold(last[1], "done") {
|
||||
return true, 0
|
||||
}
|
||||
if last[2] != "" {
|
||||
if d, ok := parseLoopInterval(last[2]); ok {
|
||||
return false, d
|
||||
}
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// loopSelfPacePlaybook wraps a self-paced loop's goal in the maintenance-loop
|
||||
// instructions the model follows each iteration: do one real increment of work,
|
||||
// then emit a single control line so the harness knows whether to stop or when to
|
||||
// wake next — the self-paced maintenance playbook the feature ships with.
|
||||
func loopSelfPacePlaybook(l *loopState) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running in a self-paced maintenance loop (iteration ")
|
||||
b.WriteString(strconv.Itoa(l.iteration + 1))
|
||||
b.WriteString("). Do one concrete increment of work toward the goal below — actual work, not just a plan — then stop and report.\n\n")
|
||||
b.WriteString("Goal: ")
|
||||
b.WriteString(strings.TrimSpace(l.prompt))
|
||||
b.WriteString("\n\nEnd your reply with a control line — on its own line, starting with \"LOOP:\":\n")
|
||||
b.WriteString(" LOOP: DONE — the goal is fully met; the loop stops.\n")
|
||||
b.WriteString(" LOOP: CONTINUE — more to do; the loop wakes again automatically.\n")
|
||||
b.WriteString(" LOOP: CONTINUE 10m — more to do; wake after the delay you name (e.g. 5m, 1h).\n")
|
||||
b.WriteString("A short note after the keyword is fine. Report DONE only when the goal is genuinely complete — the loop trusts that signal to stop.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// adaptiveSelfPaceDelay picks a self-paced loop's next-wake by iteration: frequent
|
||||
// early (work is actively progressing), slowing to a heartbeat as it matures.
|
||||
func adaptiveSelfPaceDelay(iteration int) time.Duration {
|
||||
switch {
|
||||
case iteration < 2:
|
||||
return 2 * time.Minute
|
||||
case iteration < 5:
|
||||
return 5 * time.Minute
|
||||
case iteration < 10:
|
||||
return 15 * time.Minute
|
||||
default:
|
||||
return 30 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) findLoop(id string) *loopState {
|
||||
for _, l := range m.loops {
|
||||
if l.id == id {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) removeLoop(id, note string) model {
|
||||
kept := make([]*loopState, 0, len(m.loops))
|
||||
for _, l := range m.loops {
|
||||
if l.id != id {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
m.loops = kept
|
||||
m.loopSeq++ // invalidate the pending poll tick; a fresh one is scheduled if loops remain
|
||||
m.loopTicking = false
|
||||
if note != "" {
|
||||
m = m.appendLoopSystem(note)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ensureLoopTick starts the idle poll ticker if it is not already running.
|
||||
func (m model) ensureLoopTick() (model, tea.Cmd) {
|
||||
if m.loopTicking || len(m.loops) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.loopTicking = true
|
||||
return m, m.scheduleLoopTick()
|
||||
}
|
||||
|
||||
func (m model) scheduleLoopTick() tea.Cmd {
|
||||
seq := m.loopSeq
|
||||
return tea.Tick(loopPollInterval, func(time.Time) tea.Msg {
|
||||
return loopTickMsg{seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
// loopBusy reports whether the session is too busy for a loop to fire — a turn,
|
||||
// queued user message, compaction, or ANY blocking modal (permission/askUser/spec
|
||||
// review, provider or MCP wizard, MCP manager, or an open picker) is in flight.
|
||||
// The modal check is load-bearing: a loop must not launch a run behind an open
|
||||
// /resume picker, or that run would complete into whatever session the user then
|
||||
// switches to. Mirrors launchQueuedMessageIfReady so loops and queued prompts never
|
||||
// collide.
|
||||
func (m model) loopBusy() bool {
|
||||
return m.pending || m.exiting || m.compactInFlight || m.hasQueuedMessage() || !m.noBlockingModal()
|
||||
}
|
||||
|
||||
func (m model) appendLoopSystem(text string) model {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m
|
||||
}
|
||||
|
||||
// loopActive reports whether any loop is running (for the footer + leave warning).
|
||||
func (m model) loopActive() bool { return len(m.loops) > 0 }
|
||||
|
||||
// loopFooterSummary renders the persistent "N loops · next 3:05pm" footer segment,
|
||||
// or "" when no loops are active.
|
||||
func (m model) loopFooterSummary() string {
|
||||
if len(m.loops) == 0 {
|
||||
return ""
|
||||
}
|
||||
var next time.Time
|
||||
for _, l := range m.loops {
|
||||
if l.nextRunAt.IsZero() || l.paused {
|
||||
continue
|
||||
}
|
||||
if next.IsZero() || l.nextRunAt.Before(next) {
|
||||
next = l.nextRunAt
|
||||
}
|
||||
}
|
||||
label := fmt.Sprintf("%d loop", len(m.loops))
|
||||
if len(m.loops) != 1 {
|
||||
label += "s"
|
||||
}
|
||||
if next.IsZero() {
|
||||
return label
|
||||
}
|
||||
return label + " · next " + next.Format("3:04pm")
|
||||
}
|
||||
|
||||
func (m model) loopListText() string {
|
||||
if len(m.loops) == 0 {
|
||||
return "No active loops."
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Active loops:\n")
|
||||
now := m.now()
|
||||
for _, l := range m.loops {
|
||||
when := "running"
|
||||
if !l.nextRunAt.IsZero() {
|
||||
if d := l.nextRunAt.Sub(now); d > 0 {
|
||||
when = "next in " + formatLoopDuration(d.Round(time.Second))
|
||||
} else {
|
||||
when = "due"
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s · %s · iter %d · %s · %s\n",
|
||||
l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt)))
|
||||
}
|
||||
b.WriteString("Stop with /loop stop <id> or /loop stop all.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func loopUsageText() string {
|
||||
return "Repeat a prompt or command:\n" +
|
||||
" /loop 5m /babysit-prs — run a command every 5 minutes\n" +
|
||||
" /loop watch CI every 2m — trailing interval form\n" +
|
||||
" /loop keep tidying docs — self-paced (the model picks its own cadence and stops when done)\n" +
|
||||
" /loop list — show active loops\n" +
|
||||
" /loop stop [id|all] — stop a loop\n" +
|
||||
"Loops run while this session is idle and stop when you close it."
|
||||
}
|
||||
|
||||
// validateLoopTarget rejects looping a built-in command or an unresolved custom
|
||||
// command — only a prompt or an existing custom /command (.zero/commands/<name>.md)
|
||||
// may loop. Resolving custom commands up front stops a typo'd `/loop 5m /nope` from
|
||||
// being scheduled and then re-sent as literal text on every tick.
|
||||
func (m model) validateLoopTarget(prompt string) (string, bool) {
|
||||
p := strings.TrimSpace(prompt)
|
||||
if p == "" {
|
||||
return "Nothing to loop — give a prompt or a /command.", false
|
||||
}
|
||||
if strings.HasPrefix(p, "/") {
|
||||
switch parseCommand(p).kind {
|
||||
case commandPrompt:
|
||||
// A bare "/" or similar that isn't a command — treat as a plain prompt.
|
||||
case commandUnknown:
|
||||
name, _ := splitUserCommand(p)
|
||||
if _, ok := m.lookupUserCommand(name); !ok {
|
||||
return "No command /" + name + " — a loop can only run a prompt or an existing custom /command.", false
|
||||
}
|
||||
default:
|
||||
return "A loop can only run a prompt or a custom /command, not a built-in like " + strings.Fields(p)[0] + ".", false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// clearLoopsForSessionSwitch drops every active loop and its ticker. Loops belong to
|
||||
// the session that created them; carrying them across /new or /resume would fire the
|
||||
// old session's prompt into an unrelated conversation. Returns the count cleared so
|
||||
// the caller can note it. Pure state reset — no transcript writes.
|
||||
func (m model) clearLoopsForSessionSwitch() (model, int) {
|
||||
n := len(m.loops)
|
||||
if n == 0 {
|
||||
return m, 0
|
||||
}
|
||||
m.loops = nil
|
||||
m.activeLoopID = ""
|
||||
m.loopTicking = false
|
||||
m.loopSeq++ // invalidate any pending poll tick
|
||||
return m, n
|
||||
}
|
||||
|
||||
func truncateLoopPrompt(prompt string) string {
|
||||
const max = 48
|
||||
p := strings.TrimSpace(strings.ReplaceAll(prompt, "\n", " "))
|
||||
runes := []rune(p)
|
||||
if len(runes) <= max {
|
||||
return p
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:max])) + "…"
|
||||
}
|
||||
|
||||
// clampSelfPaceDelay bounds a model-chosen self-paced delay to the self-pace
|
||||
// band, defaulting an unset/invalid value.
|
||||
func clampSelfPaceDelay(d time.Duration) time.Duration {
|
||||
switch {
|
||||
case d <= 0:
|
||||
return loopSelfPaceDefault
|
||||
case d < loopSelfPaceMin:
|
||||
return loopSelfPaceMin
|
||||
case d > loopSelfPaceMax:
|
||||
return loopSelfPaceMax
|
||||
default:
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
// formatLoopDuration renders a duration compactly (90s, 5m, 2h, 1d) for status
|
||||
// text, preferring the largest whole unit.
|
||||
func formatLoopDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d%(24*time.Hour) == 0 && d >= 24*time.Hour:
|
||||
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
|
||||
case d%time.Hour == 0 && d >= time.Hour:
|
||||
return strconv.Itoa(int(d/time.Hour)) + "h"
|
||||
case d%time.Minute == 0 && d >= time.Minute:
|
||||
return strconv.Itoa(int(d/time.Minute)) + "m"
|
||||
default:
|
||||
return strconv.Itoa(int(d/time.Second)) + "s"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/usercommands"
|
||||
)
|
||||
|
||||
func loopTestModel(t *testing.T, now time.Time) model {
|
||||
t.Helper()
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.now = func() time.Time { return now }
|
||||
return m
|
||||
}
|
||||
|
||||
func startFixedLoop(m model, prompt string, interval time.Duration) model {
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt, interval: interval})
|
||||
return m
|
||||
}
|
||||
|
||||
func TestStartLoopRegistersAndSchedules(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "check the build", 5*time.Minute)
|
||||
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatalf("want 1 loop, got %d", len(m.loops))
|
||||
}
|
||||
l := m.loops[0]
|
||||
if l.mode != loopModeFixed || l.interval != 5*time.Minute || l.prompt != "check the build" {
|
||||
t.Fatalf("unexpected loop %+v", l)
|
||||
}
|
||||
// First iteration is due immediately (nextRunAt == now).
|
||||
if !l.due(now) {
|
||||
t.Errorf("first iteration should be due at creation")
|
||||
}
|
||||
if !m.loopTicking {
|
||||
t.Errorf("starting a loop should start the poll ticker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceLoopSchedulesNextWake(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "poll", 5*time.Minute)
|
||||
id := m.loops[0].id
|
||||
|
||||
m = m.advanceLoop(id, "did some work", nil)
|
||||
l := m.loops[0]
|
||||
if l.iteration != 1 {
|
||||
t.Fatalf("iteration = %d, want 1", l.iteration)
|
||||
}
|
||||
if !l.nextRunAt.Equal(now.Add(5 * time.Minute)) {
|
||||
t.Fatalf("nextRunAt = %v, want +5m", l.nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfPacedUsesModelDelay(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "keep tidying"})
|
||||
id := m.loops[0].id
|
||||
// The model carries its chosen next wake in the reply's control line.
|
||||
m = m.advanceLoop(id, "made progress\nLOOP: CONTINUE 10m", nil)
|
||||
if len(m.loops) == 0 {
|
||||
t.Fatal("a CONTINUE control line should keep the loop running")
|
||||
}
|
||||
if !m.loops[0].nextRunAt.Equal(now.Add(10 * time.Minute)) {
|
||||
t.Fatalf("self-paced nextRunAt = %v, want +10m", m.loops[0].nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDoneStops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "finish the task"})
|
||||
id := m.loops[0].id
|
||||
m = m.advanceLoop(id, "all done\nLOOP: DONE", nil)
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("loop should stop when the task reports done, %d remain", len(m.loops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedLoopIgnoresControlLine(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "watch CI", time.Minute)
|
||||
id := m.loops[0].id
|
||||
// A fixed-interval loop never runs the playbook, so a stray control line in the
|
||||
// output is not a completion signal — it keeps its wall-clock cadence.
|
||||
m = m.advanceLoop(id, "LOOP: DONE", nil)
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatal("a fixed-interval loop should ignore a control line and keep running")
|
||||
}
|
||||
if !m.loops[0].nextRunAt.Equal(now.Add(time.Minute)) {
|
||||
t.Fatalf("fixed nextRunAt = %v, want +1m", m.loops[0].nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopExpiresByAge(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "forgotten", time.Minute)
|
||||
id := m.loops[0].id
|
||||
m.loops[0].createdAt = now.Add(-loopMaxAge - time.Hour) // created past the age cap
|
||||
m = m.advanceLoop(id, "still going", nil)
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatal("a loop past its age cap should auto-expire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDoomGuardStops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "spin", time.Minute)
|
||||
id := m.loops[0].id
|
||||
for i := 0; i < loopDoomThreshold+1 && len(m.loops) > 0; i++ {
|
||||
m = m.advanceLoop(id, "identical answer", nil)
|
||||
}
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("doom guard should stop a loop repeating identical results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopIterationCapStops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "bounded", time.Minute)
|
||||
m.loops[0].maxIter = 3
|
||||
id := m.loops[0].id
|
||||
for i := 0; i < 3 && len(m.loops) > 0; i++ {
|
||||
m = m.advanceLoop(id, "answer "+string(rune('a'+i)), nil) // distinct answers so doom doesn't fire
|
||||
}
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("loop should stop at its iteration cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopLoopByID(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "a", time.Minute)
|
||||
m = startFixedLoop(m, "b", time.Minute)
|
||||
id := m.loops[0].id
|
||||
m, cmd := m.stopLoop(id)
|
||||
if len(m.loops) != 1 || m.findLoop(id) != nil {
|
||||
t.Fatalf("stopLoop(%s) should remove exactly that loop", id)
|
||||
}
|
||||
// Stopping one of several loops must keep the poll ticker alive for the rest:
|
||||
// removeLoop kills the ticker, so stopLoop has to re-arm it (jatmn).
|
||||
if cmd == nil || !m.loopTicking {
|
||||
t.Fatal("stopping one of several loops must leave the remaining loop scheduled (ticker re-armed)")
|
||||
}
|
||||
// Bare stop with a single active loop stops that sole loop (parseLoopCommand
|
||||
// leaves the target empty and stopLoop special-cases the one-loop case).
|
||||
soleID := m.loops[0].id
|
||||
m, _ = m.stopLoop("")
|
||||
if len(m.loops) != 0 || m.findLoop(soleID) != nil {
|
||||
t.Fatalf("bare stop should remove the sole active loop")
|
||||
}
|
||||
m = startFixedLoop(m, "c", time.Minute)
|
||||
m = startFixedLoop(m, "d", time.Minute)
|
||||
m, _ = m.stopAllLoops()
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("stopAllLoops should clear all loops")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireDueLoopSkipsWhenBusy(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
m.pending = true // a turn is in flight
|
||||
got, cmd := m.fireDueLoopIfIdle()
|
||||
if got.activeLoopID != "" || cmd != nil {
|
||||
t.Fatalf("a due loop must not fire while a turn is pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireDueLoopSkipsBehindModal(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "poll", time.Minute) // due immediately
|
||||
m.picker = &commandPicker{} // e.g. an open /resume session picker
|
||||
got, cmd := m.fireDueLoopIfIdle()
|
||||
if got.activeLoopID != "" || cmd != nil {
|
||||
t.Fatal("a due loop must not launch a run behind an open picker/modal — it would complete into whatever session the user switches to")
|
||||
}
|
||||
if got.loops[0].nextRunAt.IsZero() {
|
||||
t.Fatal("a loop skipped because a modal is open must stay scheduled, not have its next run cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptiveSelfPaceDelayWidensOverTime(t *testing.T) {
|
||||
if adaptiveSelfPaceDelay(0) >= adaptiveSelfPaceDelay(5) {
|
||||
t.Error("early iterations should check back sooner than later ones")
|
||||
}
|
||||
if adaptiveSelfPaceDelay(20) != 30*time.Minute {
|
||||
t.Errorf("a mature loop should settle to a 30m heartbeat, got %v", adaptiveSelfPaceDelay(20))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopFooterSummary(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 15, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
if m.loopFooterSummary() != "" {
|
||||
t.Error("no loops -> empty footer summary")
|
||||
}
|
||||
m = startFixedLoop(m, "a", 5*time.Minute)
|
||||
m.loops[0].nextRunAt = now.Add(5 * time.Minute)
|
||||
got := m.loopFooterSummary()
|
||||
wantWake := "next " + now.Add(5*time.Minute).Format("3:04pm")
|
||||
if !strings.HasPrefix(got, "1 loop") || !strings.Contains(got, wantWake) {
|
||||
t.Fatalf("footer summary = %q, want it to start with a loop count and contain %q", got, wantWake)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopCommandThroughSubmit(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m.input.SetValue("/loop 5m tidy the imports")
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if len(next.loops) != 1 {
|
||||
t.Fatalf("submitting /loop should create a loop, got %d", len(next.loops))
|
||||
}
|
||||
if next.loops[0].mode != loopModeFixed || next.loops[0].interval != 5*time.Minute {
|
||||
t.Fatalf("unexpected loop from submit: %+v", next.loops[0])
|
||||
}
|
||||
if !next.loopTicking {
|
||||
t.Fatalf("submitting /loop should start the poll ticker")
|
||||
}
|
||||
|
||||
// /loop stop all through the same path clears it.
|
||||
next.input.SetValue("/loop stop all")
|
||||
updated2, _ := next.Update(testKey(tea.KeyEnter))
|
||||
final := updated2.(model)
|
||||
if len(final.loops) != 0 {
|
||||
t.Fatalf("/loop stop all should clear loops, got %d", len(final.loops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleLoopTickIgnored(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
m.loopSeq = 7
|
||||
// A tick from before the seq changed must be dropped and stop the ticker.
|
||||
updated, cmd := m.Update(loopTickMsg{seq: 3})
|
||||
next := updated.(model)
|
||||
if next.loopTicking {
|
||||
t.Error("a stale loop tick should stop the ticker")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Error("a stale loop tick should not reschedule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopStopsAfterConsecutiveFailures(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "flaky", time.Minute)
|
||||
id := m.loops[0].id
|
||||
boom := errors.New("boom")
|
||||
for i := 0; i < loopMaxFailures && len(m.loops) > 0; i++ {
|
||||
m = m.advanceLoop(id, "", boom)
|
||||
}
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("loop should stop after %d consecutive failed iterations", loopMaxFailures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopFailureCounterResetsOnSuccess(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "recovers", time.Minute)
|
||||
id := m.loops[0].id
|
||||
m = m.advanceLoop(id, "", errors.New("x"))
|
||||
m = m.advanceLoop(id, "", errors.New("x"))
|
||||
m = m.advanceLoop(id, "back to healthy", nil) // a success resets the streak
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatalf("a recovered failure streak should not stop the loop")
|
||||
}
|
||||
if m.loops[0].failRun != 0 {
|
||||
t.Fatalf("a successful iteration should reset failRun, got %d", m.loops[0].failRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelRunClearsActiveLoopAndRearms(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", 5*time.Minute)
|
||||
m.activeLoopID = m.loops[0].id
|
||||
m.loops[0].nextRunAt = time.Time{} // as if the iteration is running
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
m.runCancel = func() {}
|
||||
|
||||
m.cancelRun()
|
||||
|
||||
if m.activeLoopID != "" {
|
||||
t.Fatal("cancel should clear the active-loop tag so the next turn isn't misattributed")
|
||||
}
|
||||
if m.loops[0].nextRunAt.IsZero() {
|
||||
t.Fatal("cancel should re-arm the interrupted loop, not leave it running forever")
|
||||
}
|
||||
if !m.loops[0].nextRunAt.Equal(now.Add(5 * time.Minute)) {
|
||||
t.Fatalf("re-armed nextRunAt = %v, want +5m", m.loops[0].nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLoopTargetRejectsBuiltin(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
if _, ok := m.validateLoopTarget("/model list"); ok {
|
||||
t.Error("looping a built-in command should be rejected")
|
||||
}
|
||||
if _, ok := m.validateLoopTarget("keep improving docs"); !ok {
|
||||
t.Error("a plain prompt should be a valid loop target")
|
||||
}
|
||||
if _, ok := m.validateLoopTarget(""); ok {
|
||||
t.Error("an empty target should be rejected")
|
||||
}
|
||||
// A custom command that does not exist is rejected up front, so it is never
|
||||
// scheduled and then re-sent as literal "/…" text on every tick.
|
||||
if _, ok := m.validateLoopTarget("/babysit-prs"); ok {
|
||||
t.Error("an unresolved custom command should be rejected")
|
||||
}
|
||||
// A registered custom command is accepted.
|
||||
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
|
||||
if _, ok := m.validateLoopTarget("/babysit-prs"); !ok {
|
||||
t.Error("an existing custom command should be a valid loop target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfPacedCommandLoopRejected(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
|
||||
// A /command with no interval parses as self-paced, but self-paced mode drives
|
||||
// a free-form goal via the playbook + LOOP: protocol, which a command's
|
||||
// expanded template never carries — so a bare self-paced /command is rejected.
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "/babysit-prs"})
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatal("a bare self-paced /command loop should be rejected (a command loop needs an interval)")
|
||||
}
|
||||
// The same command with an interval (fixed) is accepted.
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: "/babysit-prs", interval: 5 * time.Minute})
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatal("a fixed-interval /command loop should be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearLoopsForSessionSwitch(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "a", time.Minute)
|
||||
m = startFixedLoop(m, "b", time.Minute)
|
||||
m.activeLoopID = m.loops[0].id
|
||||
m, n := m.clearLoopsForSessionSwitch()
|
||||
if n != 2 || len(m.loops) != 0 || m.activeLoopID != "" || m.loopTicking {
|
||||
t.Fatalf("session switch should clear all loop state; got n=%d loops=%d active=%q ticking=%v",
|
||||
n, len(m.loops), m.activeLoopID, m.loopTicking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartNewSessionStopsLoops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
m = m.startNewSession()
|
||||
if len(m.loops) != 0 || m.loopTicking {
|
||||
t.Fatal("/new should stop loops tied to the previous session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopClearRequiresConfirm(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
|
||||
// First /clear with a loop active arms the confirm and does not clear yet.
|
||||
m.input.SetValue("/clear")
|
||||
u1, _ := m.Update(testKey(tea.KeyEnter))
|
||||
n1 := u1.(model)
|
||||
if n1.loopLeavePrompt != commandClear {
|
||||
t.Fatal("first /clear with active loops should arm the confirm, not clear")
|
||||
}
|
||||
if len(n1.loops) != 1 {
|
||||
t.Fatal("the loop should still be active after the warning")
|
||||
}
|
||||
|
||||
// A different command disarms the confirm.
|
||||
n1.input.SetValue("/loop list")
|
||||
u2, _ := n1.Update(testKey(tea.KeyEnter))
|
||||
n2 := u2.(model)
|
||||
if n2.loopLeavePrompt != commandEmpty {
|
||||
t.Fatal("an intervening command should disarm the leave confirm")
|
||||
}
|
||||
|
||||
// Re-arm, then a second consecutive /clear confirms (disarms + clears).
|
||||
n2.input.SetValue("/clear")
|
||||
u3, _ := n2.Update(testKey(tea.KeyEnter))
|
||||
n3 := u3.(model)
|
||||
n3.input.SetValue("/clear")
|
||||
u4, _ := n3.Update(testKey(tea.KeyEnter))
|
||||
n4 := u4.(model)
|
||||
if n4.loopLeavePrompt != commandEmpty {
|
||||
t.Fatal("a second consecutive /clear should confirm and disarm")
|
||||
}
|
||||
if len(n4.loops) != 1 {
|
||||
t.Fatal("/clear keeps loops running (it wipes the screen, not the session)")
|
||||
}
|
||||
// The confirmed clear must actually wipe the transcript, not silently no-op:
|
||||
// only the clear path appends the "Transcript cleared" note, and the pre-clear
|
||||
// loop-start row must be gone.
|
||||
clearedNote, staleRow := false, false
|
||||
for _, r := range n4.transcript {
|
||||
if strings.Contains(r.text, "Transcript cleared") {
|
||||
clearedNote = true
|
||||
}
|
||||
if strings.Contains(r.text, "Loop L1 started") {
|
||||
staleRow = true
|
||||
}
|
||||
}
|
||||
if !clearedNote || staleRow {
|
||||
t.Fatalf("the second /clear must wipe the transcript (clearedNote=%v staleRow=%v)", clearedNote, staleRow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveConfirmDisarmedByQueuedPrompt(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
|
||||
// Arm the /clear confirm.
|
||||
m.input.SetValue("/clear")
|
||||
u1, _ := m.Update(testKey(tea.KeyEnter))
|
||||
n1 := u1.(model)
|
||||
if n1.loopLeavePrompt != commandClear {
|
||||
t.Fatal("first /clear should arm the confirm")
|
||||
}
|
||||
|
||||
// A prompt submitted while a run streams is queued via an early return in
|
||||
// handleSubmit; it must still disarm the confirm (the disarm runs before those
|
||||
// returns). Otherwise a later /clear would be treated as the second press.
|
||||
n1.pending = true
|
||||
n1.input.SetValue("a follow-up question")
|
||||
u2, _ := n1.Update(testKey(tea.KeyEnter))
|
||||
n2 := u2.(model)
|
||||
if n2.loopLeavePrompt != commandEmpty {
|
||||
t.Fatal("a queued prompt should disarm the leave confirm; the early return must not skip the disarm")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseLoopCommandModes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
action loopAction
|
||||
mode loopMode
|
||||
prompt string
|
||||
interval time.Duration
|
||||
}{
|
||||
{"", loopActionUsage, 0, "", 0},
|
||||
{"list", loopActionList, 0, "", 0},
|
||||
{"status", loopActionList, 0, "", 0},
|
||||
{"stop all", loopActionStopAll, 0, "", 0},
|
||||
{"stop ab12", loopActionStop, 0, "", 0},
|
||||
{"5m /babysit-prs", loopActionStart, loopModeFixed, "/babysit-prs", 5 * time.Minute},
|
||||
{"90s check the build", loopActionStart, loopModeFixed, "check the build", 90 * time.Second},
|
||||
{"watch CI every 2m", loopActionStart, loopModeFixed, "watch CI", 2 * time.Minute},
|
||||
{"keep improving the docs", loopActionStart, loopModeSelfPaced, "keep improving the docs", 0},
|
||||
// "every PR" is not a cadence — the token after "every" isn't a duration.
|
||||
{"review every PR", loopActionStart, loopModeSelfPaced, "review every PR", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseLoopCommand(c.in)
|
||||
if got.action != c.action {
|
||||
t.Errorf("%q action = %v, want %v", c.in, got.action, c.action)
|
||||
}
|
||||
if c.action == loopActionStart {
|
||||
if got.mode != c.mode {
|
||||
t.Errorf("%q mode = %v, want %v", c.in, got.mode, c.mode)
|
||||
}
|
||||
if got.prompt != c.prompt {
|
||||
t.Errorf("%q prompt = %q, want %q", c.in, got.prompt, c.prompt)
|
||||
}
|
||||
if got.interval != c.interval {
|
||||
t.Errorf("%q interval = %v, want %v", c.in, got.interval, c.interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLoopCommandStopTarget(t *testing.T) {
|
||||
if got := parseLoopCommand("stop"); got.action != loopActionStop || got.targetID != "" {
|
||||
t.Fatalf("bare stop = %+v, want stop with empty target", got)
|
||||
}
|
||||
if got := parseLoopCommand("stop XyZ9"); got.targetID != "XyZ9" {
|
||||
t.Fatalf("stop target = %q, want XyZ9", got.targetID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampLoopInterval(t *testing.T) {
|
||||
if d, note := clampLoopInterval(5 * time.Second); d != loopMinInterval || note == "" {
|
||||
t.Fatalf("below-min = (%v,%q), want raised to min with note", d, note)
|
||||
}
|
||||
if d, note := clampLoopInterval(48 * time.Hour); d != loopMaxInterval || note == "" {
|
||||
t.Fatalf("above-max = (%v,%q), want lowered to max with note", d, note)
|
||||
}
|
||||
if d, note := clampLoopInterval(5 * time.Minute); d != 5*time.Minute || note != "" {
|
||||
t.Fatalf("in-range = (%v,%q), want unchanged, no note", d, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampSelfPaceDelay(t *testing.T) {
|
||||
if got := clampSelfPaceDelay(0); got != loopSelfPaceDefault {
|
||||
t.Errorf("zero delay = %v, want default %v", got, loopSelfPaceDefault)
|
||||
}
|
||||
if got := clampSelfPaceDelay(10 * time.Second); got != loopSelfPaceMin {
|
||||
t.Errorf("below-min = %v, want %v", got, loopSelfPaceMin)
|
||||
}
|
||||
if got := clampSelfPaceDelay(3 * time.Hour); got != loopSelfPaceMax {
|
||||
t.Errorf("above-max = %v, want %v", got, loopSelfPaceMax)
|
||||
}
|
||||
if got := clampSelfPaceDelay(10 * time.Minute); got != 10*time.Minute {
|
||||
t.Errorf("in-band = %v, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLoopDuration(t *testing.T) {
|
||||
cases := map[time.Duration]string{
|
||||
30 * time.Second: "30s",
|
||||
90 * time.Second: "90s",
|
||||
5 * time.Minute: "5m",
|
||||
2 * time.Hour: "2h",
|
||||
24 * time.Hour: "1d",
|
||||
}
|
||||
for d, want := range cases {
|
||||
if got := formatLoopDuration(d); got != want {
|
||||
t.Errorf("formatLoopDuration(%v) = %q, want %q", d, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLoopControl(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
done bool
|
||||
wake time.Duration
|
||||
}{
|
||||
{"did the work\nLOOP: DONE", true, 0},
|
||||
{"progress\nLOOP: CONTINUE", false, 0},
|
||||
{"progress\nLOOP: CONTINUE 10m", false, 10 * time.Minute},
|
||||
{"progress\n- loop: continue 1h", false, time.Hour}, // markdown bullet, lowercase
|
||||
{"LOOP: CONTINUE 5m\n...more...\nLOOP: DONE", true, 0}, // last control line wins
|
||||
{"no control line here", false, 0}, // absent -> adaptive
|
||||
{"LOOP: DONE — all tests pass", true, 0}, // a trailing note is fine
|
||||
{"progress\nLOOP: CONTINUE 5m — still tidying", false, 5 * time.Minute}, // note after the interval
|
||||
{"work done.\r\nLOOP: DONE\r\n", true, 0}, // CRLF endings still parse
|
||||
{"work\r\nLOOP: CONTINUE 10m\r\n", false, 10 * time.Minute}, // CRLF + interval
|
||||
{"I will emit LOOP: DONE when finished", false, 0}, // mid-sentence mention doesn't match
|
||||
{"LOOP: CONTINUE 99z", false, 0}, // bad unit -> continue, no wake hint
|
||||
}
|
||||
for _, c := range cases {
|
||||
done, wake := parseLoopControl(c.in)
|
||||
if done != c.done || wake != c.wake {
|
||||
t.Errorf("parseLoopControl(%q) = (%v,%v), want (%v,%v)", c.in, done, wake, c.done, c.wake)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirePromptWrapsSelfPacedOnly(t *testing.T) {
|
||||
self := &loopState{mode: loopModeSelfPaced, prompt: "tidy the docs"}
|
||||
got := self.firePrompt()
|
||||
if !strings.Contains(got, "LOOP: DONE") || !strings.Contains(got, "tidy the docs") {
|
||||
t.Errorf("self-paced firePrompt should embed the playbook and goal, got %q", got)
|
||||
}
|
||||
fixed := &loopState{mode: loopModeFixed, prompt: "check the build"}
|
||||
if fixed.firePrompt() != "check the build" {
|
||||
t.Errorf("fixed-interval firePrompt should be verbatim, got %q", fixed.firePrompt())
|
||||
}
|
||||
// A self-paced /command is a real command, not a goal — send it verbatim, no playbook.
|
||||
cmd := &loopState{mode: loopModeSelfPaced, prompt: "/babysit"}
|
||||
if cmd.firePrompt() != "/babysit" {
|
||||
t.Errorf("self-paced /command firePrompt should be verbatim, got %q", cmd.firePrompt())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopStateDue(t *testing.T) {
|
||||
base := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
l := &loopState{nextRunAt: base}
|
||||
if l.due(base.Add(-time.Second)) {
|
||||
t.Error("loop should not be due before nextRunAt")
|
||||
}
|
||||
if !l.due(base) {
|
||||
t.Error("loop should be due at nextRunAt")
|
||||
}
|
||||
l.paused = true
|
||||
if l.due(base.Add(time.Hour)) {
|
||||
t.Error("paused loop should never be due")
|
||||
}
|
||||
}
|
||||
+207
-18
@@ -62,6 +62,7 @@ const dragEdgeScrollStep = 1
|
||||
type model struct {
|
||||
ctx context.Context
|
||||
cwd string
|
||||
appVersion string
|
||||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||||
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
|
||||
userConfigPath string
|
||||
@@ -156,6 +157,8 @@ type model struct {
|
||||
composerCursorVisible bool
|
||||
composerPastePreviews []composerPastePreview
|
||||
composerSelection composerSelectionState
|
||||
dictation dictationController
|
||||
sttKeyPrompt *sttKeyPromptState
|
||||
// plan holds the sticky plan panel state (steps, expansion, timings)
|
||||
// synced from the update_plan tool. See plan_panel.go.
|
||||
plan planPanelState
|
||||
@@ -190,10 +193,22 @@ type model struct {
|
||||
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
|
||||
turnStartedAt time.Time
|
||||
queuedMessage string
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// loops holds the session's active /loop definitions (see loop.go). activeLoopID
|
||||
// tags the in-flight run when it is a loop iteration (empty = a user turn), so the
|
||||
// completion seam knows whether to advance a loop. loopSeq invalidates a stale
|
||||
// pending poll tick when loops are stopped; loopTicking guards against scheduling
|
||||
// a second poll ticker. loopLeavePrompt arms a one-shot confirm before /clear or
|
||||
// /quit while loops are active (see handleSubmit).
|
||||
loops []*loopState
|
||||
activeLoopID string
|
||||
loopSeq int
|
||||
loopCounter int
|
||||
loopTicking bool
|
||||
loopLeavePrompt commandKind
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
|
||||
// to the session they were recording into AT CANCEL TIME. Each cancelled
|
||||
// agent goroutine keeps running to completion and returns its accumulated
|
||||
@@ -732,9 +747,12 @@ func newModel(ctx context.Context, options Options) model {
|
||||
notify.MaybeAddWebhookSink(notifier, os.Getenv, nil)
|
||||
notifier.SetFocused(true)
|
||||
|
||||
resolvedKeyBindings, keyBindingWarnings := sanitizeKeyBindings(resolveKeyBindings(options.KeyBindings))
|
||||
|
||||
m := model{
|
||||
ctx: ctx,
|
||||
cwd: cwd,
|
||||
appVersion: strings.TrimSpace(options.Version),
|
||||
swarmDoneAt: map[string]time.Time{},
|
||||
userCommands: loadedUserCommands,
|
||||
loadSkills: options.LoadSkills,
|
||||
@@ -769,7 +787,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
permissionMode: permissionMode,
|
||||
reasoningEffort: options.ReasoningEffort,
|
||||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||||
keyBindings: resolveKeyBindings(options.KeyBindings),
|
||||
keyBindings: resolvedKeyBindings,
|
||||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
|
||||
hasDarkBg: true,
|
||||
userAgent: options.UserAgent,
|
||||
@@ -787,6 +805,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
swarmSessionMap: map[string]string{},
|
||||
setup: newSetupState(options.Setup),
|
||||
setupSave: options.Setup.Save,
|
||||
dictation: newDictationController(options),
|
||||
}
|
||||
// Apply an explicit theme immediately; auto stays on the dark default until
|
||||
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
|
||||
@@ -805,6 +824,9 @@ func newModel(ctx context.Context, options Options) model {
|
||||
m.lspManager = lsp.NewManager(cwd)
|
||||
}
|
||||
m.refreshMCPViewState()
|
||||
for _, warning := range keyBindingWarnings {
|
||||
m = m.appendSystemNotice(warning)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -927,7 +949,8 @@ func (m *model) stopPRWatcher() {
|
||||
// by every shortcut that should defer to whichever modal is focused.
|
||||
func (m model) noBlockingModal() bool {
|
||||
return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil &&
|
||||
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil
|
||||
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil &&
|
||||
m.sttKeyPrompt == nil
|
||||
}
|
||||
|
||||
func (m model) quit() (tea.Model, tea.Cmd) {
|
||||
@@ -941,12 +964,16 @@ func (m model) quit() (tea.Model, tea.Cmd) {
|
||||
// Best-effort with a short deadline so a slow server can't hang the quit; the
|
||||
// servers are our child processes and would be reaped on exit regardless.
|
||||
func (m model) shutdownLSPManager() {
|
||||
if m.lspManager == nil {
|
||||
return
|
||||
}
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = m.lspManager.Shutdown(shutdownCtx)
|
||||
if m.lspManager != nil {
|
||||
_ = m.lspManager.Shutdown(shutdownCtx)
|
||||
}
|
||||
// The warm sherpa-onnx streaming server is a session-long child process too;
|
||||
// tear it down alongside the language servers (§6a).
|
||||
if m.dictation.shutdownServer != nil {
|
||||
_ = m.dictation.shutdownServer(shutdownCtx)
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) handleCtrlC() (tea.Model, tea.Cmd) {
|
||||
@@ -1085,6 +1112,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.applyProviderWizardOAuth(msg)
|
||||
case providerWizardDeviceCodeMsg:
|
||||
return m.applyProviderWizardDeviceCode(msg)
|
||||
case providerManagerCredsMsg:
|
||||
return m.applyProviderManagerCreds(msg)
|
||||
case providerManagerCleanupMsg:
|
||||
return m.applyProviderManagerCleanup(msg)
|
||||
case clipboardReadMsg:
|
||||
// Result of a right-click paste. Insert on success; surface a brief
|
||||
// status if the clipboard couldn't be read (e.g. no clipboard utility on
|
||||
@@ -1111,11 +1142,47 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m.attachClipboardImage(msg.data, msg.mediaType), nil
|
||||
case tea.PasteMsg:
|
||||
// A paste into the cloud-STT key prompt fills the key (the common way to
|
||||
// enter an API key), not the composer.
|
||||
if m.sttKeyPrompt != nil {
|
||||
m.sttKeyPrompt.input += strings.TrimSpace(msg.Content)
|
||||
return m, nil
|
||||
}
|
||||
return m.routePaste(msg.Content)
|
||||
case dictationStartedMsg:
|
||||
return m.handleDictationStarted(msg)
|
||||
case dictationTranscribedMsg:
|
||||
return m.handleDictationTranscribed(msg)
|
||||
case sttPartialMsg:
|
||||
return m.handleDictationPartial(msg), nil
|
||||
case sttDownloadProgressMsg:
|
||||
return m.handleDictationDownloadProgress(msg), nil
|
||||
case dictationDownloadedMsg:
|
||||
return m.handleDictationDownloaded(msg)
|
||||
case sttModelsFetchedMsg:
|
||||
return m.handleSTTModelsFetched(msg), nil
|
||||
case recTickMsg:
|
||||
return m.handleRecTick()
|
||||
case sttLevelMsg:
|
||||
return m.handleDictationLevel(msg), nil
|
||||
case tea.KeyboardEnhancementsMsg:
|
||||
return m.handleKeyboardEnhancements(msg), nil
|
||||
case tea.KeyReleaseMsg:
|
||||
// Voice mode's hold-to-record ends on Space release; every other release
|
||||
// event is ignored (dispatch elsewhere is press-based).
|
||||
if m.dictation.voiceModeEnabled && keyIs(msg, tea.KeySpace) {
|
||||
return m.handleVoiceSpaceRelease()
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyPressMsg:
|
||||
if m.setup.visible {
|
||||
return m.handleSetupKey(msg)
|
||||
}
|
||||
// The cloud-STT API-key prompt is modal: it owns every keystroke (masked
|
||||
// input) until Enter saves or Esc cancels.
|
||||
if m.sttKeyPrompt != nil {
|
||||
return m.handleSTTKeyPromptKey(msg)
|
||||
}
|
||||
m.transcriptSelection = transcriptSelectionState{}
|
||||
m.composerSelection = composerSelectionState{}
|
||||
m.clearMouseSelection()
|
||||
@@ -1159,6 +1226,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.appendSystemNotice(fmt.Sprintf("Mouse released — drag to select and copy text. Press %s again to re-enable mouse interaction (clicks, right-click paste).", mouseKey)), nil
|
||||
}
|
||||
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
|
||||
case m.dictation.voiceModeEnabled && !m.transcriptDetailed && keyIs(msg, tea.KeySpace) && !keyHasMod(msg, tea.ModCtrl) && !keyAlt(msg) && m.noBlockingModal():
|
||||
// Voice mode (/voice) repurposes Space into the record gesture — the only
|
||||
// dictation trigger — so it must not also type a space. Turn voice mode
|
||||
// off (/voice) to type normally.
|
||||
return m.handleVoiceSpacePress(msg)
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
|
||||
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
|
||||
@@ -1169,6 +1241,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// armed for some later, unrelated Esc to silently act on.
|
||||
wasConfirmingCancel := m.pending && m.cancelConfirmActive
|
||||
m = m.disarmCancelConfirmation()
|
||||
// An active dictation recording cancels on Esc (releases the mic, drops
|
||||
// the audio) — but only if this Esc isn't a confirming run-cancel
|
||||
// press. Without this guard, a user mid-recording who double-Esc's
|
||||
// to kill the run finds the first Esc swallowed by dictation and
|
||||
// the run still going. The pending run cancel happens further
|
||||
// down at the bottom of the Esc branch.
|
||||
if m.dictation.active() && !wasConfirmingCancel {
|
||||
return m.cancelDictation()
|
||||
}
|
||||
// Subchat view exits on Esc (returns to main chat).
|
||||
if m.subchat.active {
|
||||
m.chatScrollOffset = m.subchat.exit()
|
||||
@@ -1211,8 +1292,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.resolvePermission(permissionDecisionDeny)
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
m.providerWizard = nil
|
||||
return m, nil
|
||||
// Delegate so multi-level surfaces (provider manager list → edit →
|
||||
// field, manage-key step) can walk BACK one level; the wizard's own
|
||||
// handler closes the overlay for the single-level steps.
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
m.mcpAddWizard = nil
|
||||
@@ -1896,6 +1979,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
return m, nil
|
||||
case loopTickMsg:
|
||||
// Idle poll for due loops. A stale tick (loops changed) or an empty loop set
|
||||
// ends the ticker; otherwise fire the earliest due loop if idle and reschedule.
|
||||
if msg.seq != m.loopSeq || len(m.loops) == 0 {
|
||||
m.loopTicking = false
|
||||
return m, nil
|
||||
}
|
||||
var fireCmd tea.Cmd
|
||||
m, fireCmd = m.fireDueLoopIfIdle()
|
||||
return m, tea.Batch(fireCmd, m.scheduleLoopTick())
|
||||
case agentResponseMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
// A run cancelled while in flight still finishes in its goroutine and
|
||||
@@ -2065,8 +2158,25 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// complete once the turn settles.
|
||||
var sweepCmd tea.Cmd
|
||||
m, sweepCmd = m.maybeGitSweep()
|
||||
// If this run was a loop iteration, advance that loop (schedule its next wake
|
||||
// or stop it). Done before launchQueuedMessageIfReady so a user's queued prompt
|
||||
// still wins the immediate re-launch; the loop fires on the next idle tick.
|
||||
var loopTickCmd tea.Cmd
|
||||
if loopID := m.activeLoopID; loopID != "" {
|
||||
m.activeLoopID = ""
|
||||
loopFinalAnswer := ""
|
||||
for _, row := range msg.rows {
|
||||
if row.kind == rowAssistant && row.final {
|
||||
loopFinalAnswer = row.text
|
||||
}
|
||||
}
|
||||
m = m.advanceLoop(loopID, loopFinalAnswer, msg.err)
|
||||
// advanceLoop -> removeLoop may have stopped the ticker; restart it if
|
||||
// other loops remain.
|
||||
m, loopTickCmd = m.ensureLoopTick()
|
||||
}
|
||||
next, queuedCmd := m.launchQueuedMessageIfReady()
|
||||
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
|
||||
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
|
||||
case sessionTitleGeneratedMsg:
|
||||
return m.handleSessionTitleGenerated(msg)
|
||||
case recapGeneratedMsg:
|
||||
@@ -2323,6 +2433,10 @@ func (m model) View() tea.View {
|
||||
view.BackgroundColor = zeroTheme.bgPanel
|
||||
}
|
||||
view.ReportFocus = m.notifier != nil
|
||||
// Voice mode's Space-hold gesture needs key-release events (Kitty protocol).
|
||||
// Request them only while voice mode is on — the renderer re-sends the request
|
||||
// only when the value changes, so gating this costs nothing (§10).
|
||||
view.KeyboardEnhancements.ReportEventTypes = m.dictation.voiceModeEnabled
|
||||
if m.wantsMouseCapture() {
|
||||
if isRunningUnderPRoot() {
|
||||
// Under PRoot the AllMotion (1003) sequence doesn't work
|
||||
@@ -2396,8 +2510,11 @@ func (m model) transcriptView() string {
|
||||
mcpAddOverlay := m.mcpAddWizardOverlay(width)
|
||||
mcpOverlay := m.mcpManagerOverlay(width)
|
||||
pickerOverlay := m.pickerOverlay(width)
|
||||
sttKeyOverlay := m.sttKeyPromptOverlay(width)
|
||||
viewportOverlay := ""
|
||||
switch {
|
||||
case sttKeyOverlay != "":
|
||||
viewportOverlay = sttKeyOverlay
|
||||
case helpOverlayContent != "":
|
||||
viewportOverlay = helpOverlayContent
|
||||
case providerOverlay != "":
|
||||
@@ -3716,7 +3833,7 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
text := ""
|
||||
if owner := strings.TrimSpace(item.OwnerProvider); owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) {
|
||||
// A model from another saved provider: switch provider + model together.
|
||||
m, text, cmd = m.switchProviderModel(owner, item.Value)
|
||||
m, text, _, cmd = m.switchProviderModel(owner, item.Value)
|
||||
} else {
|
||||
m, text = m.handleModelCommand(item.Value)
|
||||
}
|
||||
@@ -3738,6 +3855,19 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
// submitting (a bare second Enter runs it without one); names the slash
|
||||
// path cannot reach run immediately instead.
|
||||
m, cmd = m.chooseSkillFromPicker(item)
|
||||
case pickerSTTModel:
|
||||
// Selecting the local engine with no model (and auto-download available)
|
||||
// chains into the variant-download picker instead of finalizing.
|
||||
if next, fetchCmd, opened := m.maybeOpenSTTDownloadPicker(item.Value); opened {
|
||||
return next, fetchCmd
|
||||
}
|
||||
text := ""
|
||||
m, text = m.handleSTTModelSelection(item.Value)
|
||||
if text != "" { // empty when a key prompt opened instead of finalizing
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
}
|
||||
case pickerSTTDownload:
|
||||
return m.handleSTTDownloadSelection(item.Value)
|
||||
case pickerTheme:
|
||||
// The hovered palette is already live from the preview; handleThemeCommand
|
||||
// records the choice (m.themeMode) and re-applies it, and reports the switch.
|
||||
@@ -3789,6 +3919,13 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
command := parseCommand(input)
|
||||
// A pending /clear or /quit leave-confirmation is armed for the immediately-next
|
||||
// repeat of that same command only; any other submission — including one queued
|
||||
// or deferred by the early returns below — disarms it. Runs before those returns
|
||||
// so an interposed prompt can't be skipped over and leave the gate falsely armed.
|
||||
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
}
|
||||
// While exiting (Ctrl+C waiting on the cancelled run's checkpoint flush) a
|
||||
// new run must not start: the deferred tea.Quit would abort it mid-flight
|
||||
// and orphan its checkpoint blobs — the exact loss flushRunIDs prevents.
|
||||
@@ -3822,6 +3959,14 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: helpText()})
|
||||
return m, nil
|
||||
case commandClear:
|
||||
// A foreground loop keeps firing after /clear (it wipes the screen, not the
|
||||
// session), so warn once before clearing the context it will run into.
|
||||
if m.loopActive() && m.loopLeavePrompt != commandClear {
|
||||
m.loopLeavePrompt = commandClear
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /clear keeps them firing behind a cleared screen. Run /clear again to confirm, or /loop stop all first.")
|
||||
return m, nil
|
||||
}
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
|
||||
// Clearing wipes the visible transcript only — the session's context is
|
||||
// intact, so the next prompt still replays the full history. Say so, and
|
||||
@@ -3830,6 +3975,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
// Scrollback above can't be un-printed; a faint divider marks where the
|
||||
// cleared surface ended and the frontier restarts for the fresh transcript.
|
||||
m.resetFlushFrontier("· cleared ·")
|
||||
// /clear wipes the transcript but not the session, so loops keep running;
|
||||
// say so rather than let them silently fire into a "cleared" screen.
|
||||
if m.loopActive() {
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /loop stop all to end them.")
|
||||
}
|
||||
return m, nil
|
||||
case commandNew:
|
||||
// A fresh session mid-run would strand the in-flight turn's events; make the
|
||||
@@ -3840,7 +3990,17 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
return m.startNewSession(), nil
|
||||
case commandLoop:
|
||||
return m.handleLoopCommand(command.text)
|
||||
case commandExit:
|
||||
// Closing the session stops its foreground loops mid-task; warn once so a
|
||||
// token-spending loop isn't ended by reflex.
|
||||
if m.loopActive() && m.loopLeavePrompt != commandExit {
|
||||
m.loopLeavePrompt = commandExit
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " active — closing the session stops them. Run /exit again to confirm, or /loop stop all first.")
|
||||
return m, nil
|
||||
}
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
|
||||
// defer the quit until its checkpoint session events flush — quitting
|
||||
// immediately would orphan the blobs and break /rewind.
|
||||
@@ -3879,14 +4039,20 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
case commandSandboxSetup:
|
||||
return m.startSandboxSetupCommand(command.text)
|
||||
case commandProvider:
|
||||
if strings.TrimSpace(command.text) == "" {
|
||||
arg := strings.ToLower(strings.TrimSpace(command.text))
|
||||
if arg == "" || arg == "add" {
|
||||
if m.pending {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
|
||||
return m, nil
|
||||
}
|
||||
m.providerWizard = m.newProviderWizard()
|
||||
m.clearSuggestions()
|
||||
return m, nil
|
||||
// Bare /provider opens the list-first manager over the saved
|
||||
// providers; /provider add jumps straight into the add wizard.
|
||||
if arg == "add" {
|
||||
m.providerWizard = m.newProviderWizard()
|
||||
m.clearSuggestions()
|
||||
return m, nil
|
||||
}
|
||||
return m.openProviderManager()
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.providerText()})
|
||||
return m, nil
|
||||
@@ -3905,6 +4071,14 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m, text = m.handleModelCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandSTTModel:
|
||||
if m.pending {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
|
||||
return m, nil
|
||||
}
|
||||
return m.openSTTModelPicker()
|
||||
case commandVoice:
|
||||
return m.toggleVoiceMode()
|
||||
case commandContext:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.contextText()})
|
||||
return m, nil
|
||||
@@ -4333,6 +4507,21 @@ func (m *model) cancelRun() {
|
||||
m.runCancel()
|
||||
}
|
||||
m.clearStreamingToolCall() // a cancelled file-write must not linger into the next run
|
||||
// A cancelled loop iteration bypasses the agentResponseMsg completion seam (its
|
||||
// late message is drained through flushRunIDs, not advanceLoop), so clear the
|
||||
// loop tag here and re-arm the interrupted loop for its next cadence. Otherwise
|
||||
// the loop is left "running" forever (nextRunAt stays zero) and the NEXT
|
||||
// unrelated turn would be misattributed as this loop's completion.
|
||||
if m.activeLoopID != "" {
|
||||
if l := m.findLoop(m.activeLoopID); l != nil {
|
||||
if l.mode == loopModeSelfPaced {
|
||||
l.nextRunAt = m.now().Add(clampSelfPaceDelay(adaptiveSelfPaceDelay(l.iteration)))
|
||||
} else {
|
||||
l.nextRunAt = m.now().Add(l.interval)
|
||||
}
|
||||
}
|
||||
m.activeLoopID = ""
|
||||
}
|
||||
// Remember the in-flight run — and the session it was recording into — so
|
||||
// its final agentResponseMsg is still drained for session-event persistence
|
||||
// after activeRunID is cleared. Otherwise the checkpoint blobs it captured
|
||||
|
||||
@@ -467,7 +467,8 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
|
||||
selected := clampInt(m.picker.selected, 0, len(m.picker.items)-1)
|
||||
start := selectableListStart(len(m.picker.items), maxVisible, selected)
|
||||
visible := m.picker.items[start : start+maxVisible]
|
||||
line := 2
|
||||
// y=0 titled top border, y=1 search line, y=2 separator; rows begin at y=3.
|
||||
line := 3
|
||||
lastGroup := ""
|
||||
for offset, item := range visible {
|
||||
if item.Group != "" && item.Group != lastGroup {
|
||||
|
||||
@@ -62,7 +62,8 @@ func TestMouseClickSelectsThenAppliesPickerRow(t *testing.T) {
|
||||
|
||||
width := m.chatColumnWidth()
|
||||
top := m.overlayMouseTop(len(viewLines(m.pickerOverlay(width))), width)
|
||||
click := testMouseClick(tea.MouseLeft, width/2, top+3)
|
||||
// Rows: y=0 titled border, y=1 search, y=2 separator, y=3 "auto", y=4 "high".
|
||||
click := testMouseClick(tea.MouseLeft, width/2, top+4)
|
||||
updated, cmd := m.Update(click)
|
||||
next := updated.(model)
|
||||
if cmd != nil {
|
||||
|
||||
@@ -586,6 +586,13 @@ func (m model) applySetupOAuth(msg setupOAuthMsg) (tea.Model, tea.Cmd) {
|
||||
if msg.apiKey != "" {
|
||||
m.setup.apiKey.SetValue(msg.apiKey)
|
||||
}
|
||||
if msg.tokenLogin {
|
||||
// Early persist on the Update goroutine (see persistOAuthLoginProvider's
|
||||
// threading contract) so quitting setup after the login doesn't lose it;
|
||||
// completeSetup persists the full profile with the chosen model anyway,
|
||||
// so a failure here is recoverable and not fatal to setup.
|
||||
_ = persistOAuthLoginProvider(m.setup.configPath, msg.providerID)
|
||||
}
|
||||
m.setup.oauthErr = ""
|
||||
m.setup.err = ""
|
||||
m.setup.oauthDevice = false
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
// Options configures the reusable Zero terminal UI shell.
|
||||
type Options struct {
|
||||
Cwd string
|
||||
Version string // CLI build version, shown on the home screen; empty hides it
|
||||
UserConfigPath string
|
||||
DoctorUserConfigPath string
|
||||
ProjectConfigPath string
|
||||
@@ -75,6 +76,37 @@ type Options struct {
|
||||
// KeyBindingsConfig means "use built-in defaults" for each action.
|
||||
KeyBindings config.KeyBindingsConfig
|
||||
|
||||
// STT configures speech-to-text dictation (§ docs/dictation.md).
|
||||
STT config.STTConfig
|
||||
// BuildDictationTranscriber constructs the transcriber for the current STT
|
||||
// config. It lives in the CLI layer because it resolves provider API keys
|
||||
// (credstore + env) and base URLs; the TUI only calls it when a recording
|
||||
// starts. preferStreaming asks for the streaming backend; the returned
|
||||
// `streaming` bool reports whether streaming is actually available (false
|
||||
// falls the caller back to the batch pipeline). Nil disables dictation (the
|
||||
// keybinding shows a "not configured" hint). cfg is passed each call (not
|
||||
// captured) so a mid-session config change — e.g. the auto-download writing
|
||||
// localModelPath — takes effect on the next recording.
|
||||
BuildDictationTranscriber func(cfg config.STTConfig, preferStreaming bool) (t Transcriber, streaming bool, err error)
|
||||
|
||||
// ShutdownDictationServer tears down the warm sherpa-onnx streaming server (if
|
||||
// one was started), called alongside the LSP manager's shutdown on exit. Nil
|
||||
// when dictation is not wired.
|
||||
ShutdownDictationServer func(context.Context) error
|
||||
|
||||
// STTDownloadRoot is where the auto-download stores the sherpa-onnx engine
|
||||
// and model (e.g. ~/.config/zero/stt). Empty disables auto-download (the F9
|
||||
// setup message then only points at manual setup / cloud providers).
|
||||
STTDownloadRoot string
|
||||
|
||||
// STTKeyStatus reports whether an API key is already resolvable for a cloud
|
||||
// STT provider ("groq"/"openai"/"deepgram"). Nil disables the inline key
|
||||
// prompt (dictation then just shows the "run zero auth" setup error).
|
||||
STTKeyStatus func(provider string) bool
|
||||
// SaveSTTKey stores an API key for a cloud STT provider in the credential
|
||||
// store, so the inline prompt can capture and persist it.
|
||||
SaveSTTKey func(provider, key string) error
|
||||
|
||||
// AltScreen tells the model it is running inside Bubble Tea's alternate
|
||||
// screen. Run sets this for the interactive app; tests can leave it false
|
||||
// to exercise the native scrollback renderer.
|
||||
|
||||
@@ -25,6 +25,8 @@ const (
|
||||
pickerSession
|
||||
pickerTheme
|
||||
pickerSkill
|
||||
pickerSTTModel
|
||||
pickerSTTDownload
|
||||
)
|
||||
|
||||
// pickerItem is one selectable row: Label is shown, Value is passed to the
|
||||
@@ -56,6 +58,9 @@ type commandPicker struct {
|
||||
allItems []pickerItem
|
||||
query string
|
||||
selected int
|
||||
// loading marks a picker still fetching its rows (e.g. the STT model list from
|
||||
// GitHub): the overlay shows a "fetching…" line instead of "no matching items".
|
||||
loading bool
|
||||
}
|
||||
|
||||
func (p *commandPicker) move(delta int) {
|
||||
@@ -388,7 +393,7 @@ func (m model) modelPickerDiscoveryCmds() tea.Cmd {
|
||||
// OAuth bearer for token-login providers (e.g. xAI).
|
||||
func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descriptor, profile config.ProviderProfile) tea.Cmd {
|
||||
authed := profile
|
||||
if store, err := config.ProviderKeyStore(); err == nil {
|
||||
if store, err := m.providerKeyStore(); err == nil {
|
||||
authed = config.ApplyStoredAPIKey(authed, store)
|
||||
}
|
||||
key := strings.TrimSpace(authed.APIKey)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -965,9 +966,9 @@ func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
next, text, cmd := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
|
||||
if !strings.Contains(text, "Switched to ollama") {
|
||||
t.Fatalf("switch notice = %q, want it to confirm the switch", text)
|
||||
next, text, ok, cmd := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
|
||||
if !ok || !strings.Contains(text, "Switched to ollama") {
|
||||
t.Fatalf("switch notice = %q (ok=%v), want a committed switch", text, ok)
|
||||
}
|
||||
if next.modelName != "kimi-k2.7-code:cloud" || next.providerName != "ollama" {
|
||||
t.Fatalf("model/provider not switched: modelName=%q providerName=%q", next.modelName, next.providerName)
|
||||
@@ -1022,3 +1023,76 @@ func TestNormalizeProfileForProviderCanonicalizesPlaceholderName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchProviderModelUsesOAuthLoginWithoutInliningBearer: switching to a
|
||||
// token-login provider (ChatGPT) must pass the credential gate on the stored
|
||||
// OAuth login and hand newProvider a KEYLESS profile. Inlining the bearer as
|
||||
// APIKey makes HasConfiguredCredential true, which strips the OAuth login
|
||||
// candidates at build time — the client is then constructed without the bearer
|
||||
// resolver and login key, dropping token refresh and the Codex
|
||||
// `chatgpt-account-id` header, so every call 401s.
|
||||
func TestSwitchProviderModelUsesOAuthLoginWithoutInliningBearer(t *testing.T) {
|
||||
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("oauth store: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123", TokenType: "Bearer"}); err != nil {
|
||||
t.Fatalf("save token: %v", err)
|
||||
}
|
||||
|
||||
var built config.ProviderProfile
|
||||
m := newModel(context.Background(), Options{
|
||||
ProviderName: "opengateway",
|
||||
ModelName: "some-model",
|
||||
Provider: &fakeProvider{},
|
||||
ProviderProfile: config.ProviderProfile{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
|
||||
SavedProviders: []config.ProviderProfile{
|
||||
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
|
||||
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
|
||||
},
|
||||
NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
built = profile
|
||||
return &fakeProvider{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
next, text, ok, _ := m.switchProviderModel("chatgpt", "gpt-5.5")
|
||||
if !ok || !strings.Contains(text, "Switched to chatgpt") {
|
||||
t.Fatalf("switch should succeed on the stored OAuth login, got %q (ok=%v)", text, ok)
|
||||
}
|
||||
if next.providerName != "chatgpt" || next.modelName != "gpt-5.5" {
|
||||
t.Fatalf("switch did not commit: provider=%q model=%q", next.providerName, next.modelName)
|
||||
}
|
||||
if built.APIKey != "" {
|
||||
t.Fatalf("OAuth bearer must not be inlined as APIKey (suppresses the runtime resolver), got %q", built.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchProviderModelStillRejectsProviderWithNoCredential: with no key, no
|
||||
// header, no local runtime, and no OAuth login, the gate must keep refusing.
|
||||
func TestSwitchProviderModelStillRejectsProviderWithNoCredential(t *testing.T) {
|
||||
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
|
||||
|
||||
m := newModel(context.Background(), Options{
|
||||
ProviderName: "opengateway",
|
||||
ModelName: "some-model",
|
||||
Provider: &fakeProvider{},
|
||||
ProviderProfile: config.ProviderProfile{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
|
||||
SavedProviders: []config.ProviderProfile{
|
||||
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
|
||||
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
|
||||
},
|
||||
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider must not run for a credential-less provider")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
|
||||
_, text, ok, _ := m.switchProviderModel("chatgpt", "gpt-5.5")
|
||||
if ok || !strings.Contains(text, "no usable credential") {
|
||||
t.Fatalf("expected the credential gate to refuse, got %q (ok=%v)", text, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
// Provider manager: the list-first /provider surface. It shares
|
||||
// providerWizardState (steps providerWizardStepManage / EditMenu / EditValue)
|
||||
// so the existing overlay gating, key routing, and mouse plumbing that check
|
||||
// m.providerWizard cover it without touching those call sites.
|
||||
//
|
||||
// UX contract: the list IS the home screen — Enter activates the selected
|
||||
// provider, `a` opens the add wizard, `e` opens a field-level editor, `d`
|
||||
// asks inline and deletes. Esc walks back one level and closes from the list.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
const providerManagerMaxVisible = 10
|
||||
|
||||
// providerManagerRow is one saved provider in the list. cred is resolved
|
||||
// asynchronously (keychain reads shell out to `security` on macOS and must
|
||||
// never block the render loop); empty means "still checking".
|
||||
type providerManagerRow struct {
|
||||
profile config.ProviderProfile
|
||||
local bool
|
||||
cred string
|
||||
}
|
||||
|
||||
type providerEditField int
|
||||
|
||||
const (
|
||||
providerEditFieldName providerEditField = iota
|
||||
providerEditFieldEndpoint
|
||||
providerEditFieldModel
|
||||
providerEditFieldAPIKey
|
||||
providerEditFieldDescription
|
||||
providerEditFieldSave
|
||||
)
|
||||
|
||||
var providerEditFields = []struct {
|
||||
field providerEditField
|
||||
label string
|
||||
hint string
|
||||
}{
|
||||
{providerEditFieldName, "Name", "Renames the profile; a stored key and the active pointer follow automatically."},
|
||||
{providerEditFieldEndpoint, "Endpoint", "Base URL for the provider's API."},
|
||||
{providerEditFieldModel, "Model", "Default model for this provider."},
|
||||
{providerEditFieldAPIKey, "API key", "Enter a new key to replace the stored one; leave empty to keep it."},
|
||||
{providerEditFieldDescription, "Description", "Optional label shown in the provider list."},
|
||||
{providerEditFieldSave, "Save", "Persist the changes."},
|
||||
}
|
||||
|
||||
// providerManagerCredsMsg delivers the asynchronously resolved credential
|
||||
// states for the manager rows. gen guards against a stale probe finishing
|
||||
// after the manager was reopened with fresh rows.
|
||||
type providerManagerCredsMsg struct {
|
||||
gen int
|
||||
creds map[string]string
|
||||
}
|
||||
|
||||
// openProviderManager opens /provider on the list of saved providers. With
|
||||
// nothing saved yet there is nothing to manage — fall through to the add
|
||||
// wizard, which is also the first-run behavior users already know.
|
||||
func (m model) openProviderManager() (model, tea.Cmd) {
|
||||
if len(m.savedProviders) == 0 {
|
||||
m.providerWizard = m.newProviderWizard()
|
||||
m.clearSuggestions()
|
||||
return m, nil
|
||||
}
|
||||
wizard := &providerWizardState{
|
||||
step: providerWizardStepManage,
|
||||
manage: true,
|
||||
providers: providerWizardProviders(),
|
||||
}
|
||||
wizard.refreshModels()
|
||||
m.providerWizard = wizard
|
||||
m.clearSuggestions()
|
||||
return m.reloadProviderManagerRows()
|
||||
}
|
||||
|
||||
// reloadProviderManagerRows rebuilds the list from savedProviders and kicks off
|
||||
// the async credential probe for the new generation.
|
||||
func (m model) reloadProviderManagerRows() (model, tea.Cmd) {
|
||||
if m.providerWizard == nil {
|
||||
return m, nil
|
||||
}
|
||||
// Carry resolved credential states forward for names still present, so a
|
||||
// single-row mutation doesn't flash every other row back to "checking…"
|
||||
// while the async probe re-runs.
|
||||
previous := make(map[string]string, len(m.providerWizard.manageRows))
|
||||
for _, row := range m.providerWizard.manageRows {
|
||||
previous[row.profile.Name] = row.cred
|
||||
}
|
||||
rows := make([]providerManagerRow, 0, len(m.savedProviders))
|
||||
for _, profile := range m.savedProviders {
|
||||
row := providerManagerRow{profile: profile, cred: previous[profile.Name]}
|
||||
if descriptor, ok := m.descriptorForProfile(profile); ok {
|
||||
row.local = descriptor.Local
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
m.providerWizard.manageRows = rows
|
||||
m.providerWizard.manageCursor = clampInt(m.providerWizard.manageCursor, 0, maxInt(0, len(rows)-1))
|
||||
// The session's live provider is the truth the user cares about (config's
|
||||
// activeProvider follows it on every switch).
|
||||
m.providerWizard.manageActiveName = m.providerName
|
||||
m.providerWizard.manageCredGen++
|
||||
return m, providerManagerCredsCmd(m.providerWizard.manageCredGen, rows, m.userConfigPath)
|
||||
}
|
||||
|
||||
// providerManagerCredsCmd resolves each row's credential state off the UI
|
||||
// goroutine: encrypted-store reads (keychain subprocess on macOS), env lookups,
|
||||
// and the OAuth token store. Keys are read from the store BESIDE configPath —
|
||||
// the one the manager's write paths use.
|
||||
func providerManagerCredsCmd(gen int, rows []providerManagerRow, configPath string) tea.Cmd {
|
||||
profiles := make([]config.ProviderProfile, len(rows))
|
||||
locals := make([]bool, len(rows))
|
||||
for i, row := range rows {
|
||||
profiles[i] = row.profile
|
||||
locals[i] = row.local
|
||||
}
|
||||
return func() tea.Msg {
|
||||
store, storeErr := providerKeyStoreForPath(configPath)
|
||||
var getter config.APIKeyGetter
|
||||
if storeErr == nil {
|
||||
getter = store
|
||||
}
|
||||
// One token-store snapshot answers every row's OAuth question — a fresh
|
||||
// store open + full file read per row (×2 candidates) answered the same
|
||||
// question N times over.
|
||||
logins := storedOAuthLogins()
|
||||
creds := make(map[string]string, len(profiles))
|
||||
for i, profile := range profiles {
|
||||
creds[profile.Name] = providerManagerCredState(profile, locals[i], getter, logins)
|
||||
}
|
||||
return providerManagerCredsMsg{gen: gen, creds: creds}
|
||||
}
|
||||
}
|
||||
|
||||
// providerManagerCredState classifies how a profile authenticates, surfacing
|
||||
// broken states ("stored key missing") instead of hiding them — that exact
|
||||
// state is how a lost keychain entry shows up. A stale APIKeyStored marker
|
||||
// falls THROUGH to the env/OAuth checks first, matching the runtime's own
|
||||
// fallback order (profileWithCredential): a provider that will switch fine via
|
||||
// its env var must not render as broken.
|
||||
func providerManagerCredState(profile config.ProviderProfile, local bool, store config.APIKeyGetter, logins map[string]bool) string {
|
||||
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
|
||||
return "key set"
|
||||
}
|
||||
if profile.APIKeyStored && store != nil {
|
||||
if key, ok, err := store.Get(profile.Name); err == nil && ok && strings.TrimSpace(key) != "" {
|
||||
return "key stored"
|
||||
}
|
||||
}
|
||||
if env := strings.TrimSpace(profile.APIKeyEnv); env != "" && strings.TrimSpace(os.Getenv(env)) != "" {
|
||||
return "env " + env
|
||||
}
|
||||
// OAuthLoginCandidates is nil for key-authed profiles, so probe a marker-free
|
||||
// copy: a stale marker must not hide a usable login.
|
||||
keyless := profile
|
||||
keyless.APIKeyStored = false
|
||||
for _, candidate := range keyless.OAuthLoginCandidates() {
|
||||
// Store keys are normalized to lower case (oauth.ProviderKey).
|
||||
if logins[strings.ToLower(strings.TrimSpace(candidate))] {
|
||||
return "oauth login"
|
||||
}
|
||||
}
|
||||
if local {
|
||||
return "local"
|
||||
}
|
||||
if profile.APIKeyStored {
|
||||
return "stored key missing"
|
||||
}
|
||||
return "no credential"
|
||||
}
|
||||
|
||||
// storedOAuthLogins snapshots which provider logins exist in the token store —
|
||||
// the same one-read pattern as cli's oauthLoggedInProviders. Errors degrade to
|
||||
// an empty set.
|
||||
func storedOAuthLogins() map[string]bool {
|
||||
logins := map[string]bool{}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return logins
|
||||
}
|
||||
statuses, err := store.Status(oauth.KeyPrefixProvider)
|
||||
if err != nil {
|
||||
return logins
|
||||
}
|
||||
for _, status := range statuses {
|
||||
if status.HasToken {
|
||||
logins[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
|
||||
}
|
||||
}
|
||||
return logins
|
||||
}
|
||||
|
||||
func (m model) applyProviderManagerCreds(msg providerManagerCredsMsg) (model, tea.Cmd) {
|
||||
if m.providerWizard == nil || !m.providerWizard.manage || msg.gen != m.providerWizard.manageCredGen {
|
||||
return m, nil
|
||||
}
|
||||
for i := range m.providerWizard.manageRows {
|
||||
if cred, ok := msg.creds[m.providerWizard.manageRows[i].profile.Name]; ok {
|
||||
m.providerWizard.manageRows[i].cred = cred
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// providerWizardManagerStep reports whether the wizard is on one of the
|
||||
// manager-owned steps (list / edit menu / edit value).
|
||||
func (wizard *providerWizardState) managerStep() bool {
|
||||
if wizard == nil {
|
||||
return false
|
||||
}
|
||||
switch wizard.step {
|
||||
case providerWizardStepManage, providerWizardStepEditMenu, providerWizardStepEditValue:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) currentManagerRow() (providerManagerRow, bool) {
|
||||
if wizard == nil || len(wizard.manageRows) == 0 {
|
||||
return providerManagerRow{}, false
|
||||
}
|
||||
wizard.manageCursor = clampInt(wizard.manageCursor, 0, len(wizard.manageRows)-1)
|
||||
return wizard.manageRows[wizard.manageCursor], true
|
||||
}
|
||||
|
||||
func (m model) handleProviderManagerKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
switch wizard.step {
|
||||
case providerWizardStepManage:
|
||||
return m.handleProviderManageListKey(msg)
|
||||
case providerWizardStepEditMenu:
|
||||
return m.handleProviderEditMenuKey(msg)
|
||||
case providerWizardStepEditValue:
|
||||
return m.handleProviderEditValueKey(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) handleProviderManageListKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
if wizard.manageDeleting {
|
||||
switch {
|
||||
case keyIs(msg, tea.KeyEnter) || strings.EqualFold(keyText(msg), "y"):
|
||||
return m.deleteManagerSelection()
|
||||
case keyIs(msg, tea.KeyEsc) || strings.EqualFold(keyText(msg), "n"):
|
||||
wizard.manageDeleting = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
switch {
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
m.providerWizard = nil
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyUp):
|
||||
m.moveProviderManager(-1)
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
|
||||
m.moveProviderManager(1)
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyEnter):
|
||||
return m.activateManagerSelection()
|
||||
case strings.EqualFold(keyText(msg), "a"):
|
||||
// Add: hand over to the normal wizard flow; Esc from its first step
|
||||
// returns here (fromManage-style handling in handleProviderWizardKey).
|
||||
wizard.step = providerWizardStepMethod
|
||||
wizard.err = ""
|
||||
wizard.manageStatus = ""
|
||||
return m, nil
|
||||
case strings.EqualFold(keyText(msg), "e"):
|
||||
if row, ok := wizard.currentManagerRow(); ok {
|
||||
wizard.beginProviderEdit(row.profile)
|
||||
}
|
||||
return m, nil
|
||||
case strings.EqualFold(keyText(msg), "d"):
|
||||
if _, ok := wizard.currentManagerRow(); ok {
|
||||
wizard.manageDeleting = true
|
||||
wizard.manageStatus = ""
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) moveProviderManager(delta int) {
|
||||
wizard := m.providerWizard
|
||||
count := len(wizard.manageRows)
|
||||
if count == 0 {
|
||||
wizard.manageCursor = 0
|
||||
return
|
||||
}
|
||||
wizard.manageCursor = ((wizard.manageCursor+delta)%count + count) % count
|
||||
wizard.manageStatus = ""
|
||||
}
|
||||
|
||||
// activateManagerSelection makes the selected provider active via the shared
|
||||
// switch path (persists activeProvider+model, rebuilds the client OAuth-aware,
|
||||
// exports ZERO_PROVIDER, warms discovery). On success the manager closes and
|
||||
// the switch notice lands in the transcript; a refusal (busy run, missing
|
||||
// credential) stays inline so the user keeps their place in the list.
|
||||
func (m model) activateManagerSelection() (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
row, ok := wizard.currentManagerRow()
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
if m.pending {
|
||||
wizard.manageStatus = "Cannot switch providers while a run is active."
|
||||
return m, nil
|
||||
}
|
||||
next, text, switched, cmd := m.switchProviderModel(row.profile.Name, row.profile.Model)
|
||||
if switched {
|
||||
next.providerWizard = nil
|
||||
next.transcript = reduceTranscript(next.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return next, cmd
|
||||
}
|
||||
if next.providerWizard != nil {
|
||||
next.providerWizard.manageStatus = strings.TrimPrefix(text, "Model\n")
|
||||
}
|
||||
return next, cmd
|
||||
}
|
||||
|
||||
// deleteManagerSelection removes the confirmed provider: the config write runs
|
||||
// synchronously (the list must reflect the config the instant the confirm
|
||||
// resolves), while the stored-key delete and the OAuth-login lookup — a
|
||||
// keychain subprocess and a token-store read — run in a follow-up tea.Cmd so
|
||||
// the confirm keypress never stalls the render loop. The OAuth token is
|
||||
// deliberately kept — logins outlive profiles so re-adding the provider
|
||||
// doesn't force a browser round-trip; zero auth logout removes it.
|
||||
func (m model) deleteManagerSelection() (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
wizard.manageDeleting = false
|
||||
row, ok := wizard.currentManagerRow()
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
name := row.profile.Name
|
||||
if strings.TrimSpace(m.userConfigPath) == "" {
|
||||
wizard.manageStatus = "No user config path — cannot delete."
|
||||
return m, nil
|
||||
}
|
||||
cfg, err := config.RemoveProvider(m.userConfigPath, name)
|
||||
if err != nil {
|
||||
wizard.manageStatus = "Delete failed: " + err.Error()
|
||||
return m, nil
|
||||
}
|
||||
// Surgical removal — see saveManagerEdit for why the raw cfg.Providers list
|
||||
// must not replace the resolved/filtered savedProviders wholesale.
|
||||
m.savedProviders = removeSavedProvider(m.savedProviders, name)
|
||||
|
||||
notes := []string{"Deleted " + name + "."}
|
||||
if strings.EqualFold(strings.TrimSpace(m.providerName), strings.TrimSpace(name)) {
|
||||
notes = append(notes, "This session keeps running on it until you switch.")
|
||||
} else if active := strings.TrimSpace(cfg.ActiveProvider); active != "" && !strings.EqualFold(active, name) {
|
||||
notes = append(notes, "Active provider: "+active+".")
|
||||
}
|
||||
cleanup := providerManagerCleanupCmd(m.userConfigPath, row.profile)
|
||||
|
||||
if len(m.savedProviders) == 0 {
|
||||
m.providerWizard = nil
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Provider\n" + strings.Join(notes, " ")})
|
||||
return m, cleanup
|
||||
}
|
||||
next, cmd := m.reloadProviderManagerRows()
|
||||
next.providerWizard.manageStatus = strings.Join(notes, " ")
|
||||
return next, tea.Batch(cmd, cleanup)
|
||||
}
|
||||
|
||||
// removeSavedProvider drops one profile from the in-memory saved list.
|
||||
func removeSavedProvider(saved []config.ProviderProfile, name string) []config.ProviderProfile {
|
||||
kept := saved[:0]
|
||||
for _, profile := range saved {
|
||||
if strings.EqualFold(strings.TrimSpace(profile.Name), strings.TrimSpace(name)) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, profile)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// providerManagerCleanupMsg reports the off-thread half of a delete: the
|
||||
// stored-key removal outcome and the OAuth-login hint.
|
||||
type providerManagerCleanupMsg struct {
|
||||
notes []string
|
||||
}
|
||||
|
||||
// providerManagerCleanupCmd finishes a delete off the UI goroutine: the
|
||||
// keychain delete shells out to `security` on macOS and the OAuth-login lookup
|
||||
// reads the token store — blocking work the confirm keypress must not wait on.
|
||||
// A failed key delete is surfaced rather than letting a lingering secret read
|
||||
// as a clean removal.
|
||||
func providerManagerCleanupCmd(configPath string, profile config.ProviderProfile) tea.Cmd {
|
||||
name := profile.Name
|
||||
catalogID := profile.CatalogID
|
||||
return func() tea.Msg {
|
||||
notes := []string{}
|
||||
keyStore, storeErr := providerKeyStoreForPath(configPath)
|
||||
if storeErr == nil {
|
||||
_, storeErr = keyStore.Delete(name)
|
||||
}
|
||||
if storeErr != nil {
|
||||
notes = append(notes, "Warning: its stored API key could not be deleted ("+storeErr.Error()+").")
|
||||
}
|
||||
if login, ok := oauthLoginName(config.ProviderProfile{Name: name, CatalogID: catalogID}); ok {
|
||||
notes = append(notes, "OAuth login kept — remove with `zero auth logout "+login+"`.")
|
||||
}
|
||||
return providerManagerCleanupMsg{notes: notes}
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) applyProviderManagerCleanup(msg providerManagerCleanupMsg) (model, tea.Cmd) {
|
||||
if len(msg.notes) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
extra := strings.Join(msg.notes, " ")
|
||||
if m.providerWizard != nil && m.providerWizard.manage {
|
||||
if m.providerWizard.manageStatus != "" {
|
||||
m.providerWizard.manageStatus += " " + extra
|
||||
} else {
|
||||
m.providerWizard.manageStatus = extra
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Provider\n" + extra})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// --- edit -------------------------------------------------------------------
|
||||
|
||||
func (wizard *providerWizardState) beginProviderEdit(profile config.ProviderProfile) {
|
||||
wizard.editOriginal = profile
|
||||
wizard.editDraft = profile
|
||||
wizard.editDraft.APIKey = "" // key field is enter-to-replace, never prefilled
|
||||
wizard.editCursor = 0
|
||||
wizard.err = ""
|
||||
wizard.manageStatus = ""
|
||||
wizard.step = providerWizardStepEditMenu
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) editFieldValue(field providerEditField, draft config.ProviderProfile) string {
|
||||
switch field {
|
||||
case providerEditFieldName:
|
||||
return draft.Name
|
||||
case providerEditFieldEndpoint:
|
||||
return draft.BaseURL
|
||||
case providerEditFieldModel:
|
||||
return draft.Model
|
||||
case providerEditFieldAPIKey:
|
||||
return draft.APIKey
|
||||
case providerEditFieldDescription:
|
||||
return draft.Description
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m model) handleProviderEditMenuKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
switch {
|
||||
case keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft):
|
||||
wizard.step = providerWizardStepManage
|
||||
wizard.err = ""
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyUp):
|
||||
wizard.editCursor = ((wizard.editCursor-1)%len(providerEditFields) + len(providerEditFields)) % len(providerEditFields)
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
|
||||
wizard.editCursor = (wizard.editCursor + 1) % len(providerEditFields)
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyEnter):
|
||||
entry := providerEditFields[clampInt(wizard.editCursor, 0, len(providerEditFields)-1)]
|
||||
if entry.field == providerEditFieldSave {
|
||||
return m.saveManagerEdit()
|
||||
}
|
||||
wizard.editField = entry.field
|
||||
wizard.editBuffer = wizard.editFieldValue(entry.field, wizard.editDraft)
|
||||
wizard.err = ""
|
||||
wizard.step = providerWizardStepEditValue
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) handleProviderEditValueKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
switch {
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
wizard.step = providerWizardStepEditMenu
|
||||
wizard.err = ""
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyEnter):
|
||||
if err := wizard.commitEditBuffer(); err != "" {
|
||||
wizard.err = err
|
||||
return m, nil
|
||||
}
|
||||
wizard.err = ""
|
||||
wizard.step = providerWizardStepEditMenu
|
||||
return m, nil
|
||||
case keyCtrl(msg, 'u'):
|
||||
wizard.editBuffer = ""
|
||||
wizard.err = ""
|
||||
return m, nil
|
||||
case keyBackspace(msg):
|
||||
if wizard.editBuffer != "" {
|
||||
runes := []rune(wizard.editBuffer)
|
||||
wizard.editBuffer = string(runes[:len(runes)-1])
|
||||
}
|
||||
wizard.err = ""
|
||||
return m, nil
|
||||
case keyText(msg) != "":
|
||||
for _, r := range keyRunes(msg) {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
continue
|
||||
}
|
||||
wizard.editBuffer += string(r)
|
||||
}
|
||||
wizard.err = ""
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// commitEditBuffer validates and folds the edited value into the draft.
|
||||
// Returns a non-empty error string to keep the user on the field.
|
||||
func (wizard *providerWizardState) commitEditBuffer() string {
|
||||
value := strings.TrimSpace(wizard.editBuffer)
|
||||
switch wizard.editField {
|
||||
case providerEditFieldName:
|
||||
if value == "" {
|
||||
return "name cannot be empty"
|
||||
}
|
||||
wizard.editDraft.Name = value
|
||||
case providerEditFieldEndpoint:
|
||||
if err := providerWizardEndpointError(value); err != "" {
|
||||
return err
|
||||
}
|
||||
wizard.editDraft.BaseURL = value
|
||||
case providerEditFieldModel:
|
||||
if value == "" {
|
||||
return "model cannot be empty"
|
||||
}
|
||||
wizard.editDraft.Model = value
|
||||
case providerEditFieldAPIKey:
|
||||
wizard.editDraft.APIKey = strings.TrimSpace(wizard.editBuffer)
|
||||
case providerEditFieldDescription:
|
||||
wizard.editDraft.Description = value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// saveManagerEdit persists the draft in ONE atomic config write
|
||||
// (config.EditProvider handles rename — case-only included — field merge,
|
||||
// verbatim description, the stored-key marker, and key migration together, so
|
||||
// a failure leaves nothing half-applied). A freshly entered key is captured
|
||||
// into the encrypted store under the CURRENT name first (EditProvider's rename
|
||||
// migration then moves it), so config.json never holds it in cleartext. The
|
||||
// live session follows a rename so ZERO_PROVIDER and the status line never
|
||||
// point at a name that no longer exists.
|
||||
func (m model) saveManagerEdit() (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
if strings.TrimSpace(m.userConfigPath) == "" {
|
||||
wizard.err = "no user config path — cannot save"
|
||||
return m, nil
|
||||
}
|
||||
oldName := strings.TrimSpace(wizard.editOriginal.Name)
|
||||
newName := strings.TrimSpace(wizard.editDraft.Name)
|
||||
if newName == "" {
|
||||
wizard.err = "name cannot be empty"
|
||||
return m, nil
|
||||
}
|
||||
edit := config.ProviderEdit{
|
||||
Name: oldName,
|
||||
NewName: newName,
|
||||
BaseURL: strings.TrimSpace(wizard.editDraft.BaseURL),
|
||||
Model: strings.TrimSpace(wizard.editDraft.Model),
|
||||
Description: wizard.editDraft.Description,
|
||||
}
|
||||
if key := strings.TrimSpace(wizard.editDraft.APIKey); key != "" {
|
||||
captured := config.SecureProviderProfile(config.ProviderProfile{Name: oldName, APIKey: key}, m.userConfigPath)
|
||||
// On a store failure SecureProviderProfile keeps the inline key, which
|
||||
// EditProvider then persists (the startup migration re-captures later) —
|
||||
// the same fail-soft posture as every other capture path.
|
||||
edit.APIKey = captured.APIKey
|
||||
edit.APIKeyStored = captured.APIKeyStored
|
||||
}
|
||||
if _, err := config.EditProvider(m.userConfigPath, edit); err != nil {
|
||||
wizard.err = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
// Mirror the edit into the in-memory list surgically. savedProviders was
|
||||
// seeded from the RESOLVED (project-config layered) and usability-FILTERED
|
||||
// provider set — substituting the raw user-file list here would drop
|
||||
// project-contributed providers and resurrect filtered stubs for the session.
|
||||
m.savedProviders = applySavedProviderEdit(m.savedProviders, oldName, edit)
|
||||
|
||||
// Keep the live session's identity in sync with a rename of the provider it
|
||||
// is running on: the exported ZERO_PROVIDER must resolve for spawned children.
|
||||
if strings.EqualFold(strings.TrimSpace(m.providerName), oldName) {
|
||||
m.providerName = newName
|
||||
m.providerProfile.Name = newName
|
||||
config.SetActiveProviderEnv(newName)
|
||||
}
|
||||
|
||||
wizard.step = providerWizardStepManage
|
||||
next, cmd := m.reloadProviderManagerRows()
|
||||
next.providerWizard.manageStatus = "Updated " + newName + "." + providerEditRestartNote(next.providerName, newName)
|
||||
return next, cmd
|
||||
}
|
||||
|
||||
// providerEditRestartNote reminds the user when the edited provider is the one
|
||||
// this session is running on — endpoint/model/key changes only apply to the
|
||||
// built client after a switch (Enter on the row re-activates and rebuilds).
|
||||
// liveName is the session's provider AFTER any rename sync, so a single
|
||||
// comparison against the edited profile's final name suffices.
|
||||
func providerEditRestartNote(liveName string, editedName string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(liveName), strings.TrimSpace(editedName)) {
|
||||
return " Press Enter on it to apply the changes to this session."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// applySavedProviderEdit mirrors a persisted config.EditProvider into the
|
||||
// in-memory saved list without wholesale replacement (see saveManagerEdit).
|
||||
func applySavedProviderEdit(saved []config.ProviderProfile, oldName string, edit config.ProviderEdit) []config.ProviderProfile {
|
||||
for index := range saved {
|
||||
if !strings.EqualFold(strings.TrimSpace(saved[index].Name), strings.TrimSpace(oldName)) {
|
||||
continue
|
||||
}
|
||||
profile := &saved[index]
|
||||
if newName := strings.TrimSpace(edit.NewName); newName != "" {
|
||||
profile.Name = newName
|
||||
}
|
||||
if baseURL := strings.TrimSpace(edit.BaseURL); baseURL != "" {
|
||||
profile.BaseURL = baseURL
|
||||
}
|
||||
if model := strings.TrimSpace(edit.Model); model != "" {
|
||||
profile.Model = model
|
||||
}
|
||||
if apiKey := strings.TrimSpace(edit.APIKey); apiKey != "" {
|
||||
profile.APIKey = apiKey
|
||||
}
|
||||
if edit.APIKeyStored {
|
||||
profile.APIKeyStored = true
|
||||
profile.APIKey = ""
|
||||
}
|
||||
profile.Description = strings.TrimSpace(edit.Description)
|
||||
break
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
// --- rendering ---------------------------------------------------------------
|
||||
|
||||
// upsertSavedProviderProfile mirrors a freshly persisted profile into the
|
||||
// in-memory saved list (replace by name, else append).
|
||||
func upsertSavedProviderProfile(saved []config.ProviderProfile, profile config.ProviderProfile) []config.ProviderProfile {
|
||||
for index := range saved {
|
||||
if strings.EqualFold(strings.TrimSpace(saved[index].Name), strings.TrimSpace(profile.Name)) {
|
||||
saved[index] = profile
|
||||
return saved
|
||||
}
|
||||
}
|
||||
return append(saved, profile)
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) renderManageStep(width int) []string {
|
||||
rows := wizard.manageRows
|
||||
lines := []string{}
|
||||
if wizard.manageStatus != "" {
|
||||
lines = append(lines, fitStyledLine(zeroTheme.accent.Render(wizard.manageStatus), width), "")
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
lines = append(lines, zeroTheme.faint.Render(" No providers saved — press a to add one."))
|
||||
return lines
|
||||
}
|
||||
wizard.manageCursor = clampInt(wizard.manageCursor, 0, len(rows)-1)
|
||||
nameWidth := 0
|
||||
for _, row := range rows {
|
||||
nameWidth = maxInt(nameWidth, len([]rune(row.profile.Name)))
|
||||
}
|
||||
nameWidth = minInt(nameWidth, 28)
|
||||
|
||||
maxVisible := minInt(providerManagerMaxVisible, len(rows))
|
||||
start := selectableListStart(len(rows), maxVisible, wizard.manageCursor)
|
||||
for offset, row := range rows[start : start+maxVisible] {
|
||||
index := start + offset
|
||||
surface := transparentSurface
|
||||
marker := surface(zeroTheme.faintest).Render(" ")
|
||||
if index == wizard.manageCursor {
|
||||
surface = zeroTheme.onSel
|
||||
marker = surface(zeroTheme.accent).Render("❯ ")
|
||||
}
|
||||
active := ""
|
||||
if strings.EqualFold(strings.TrimSpace(row.profile.Name), strings.TrimSpace(wizard.manageActiveName)) {
|
||||
active = surface(zeroTheme.accent).Render(" ● active")
|
||||
}
|
||||
name := padProviderManagerCell(row.profile.Name, nameWidth)
|
||||
meta := providerManagerRowMeta(row.profile)
|
||||
left := marker + surface(zeroTheme.ink).Render(name) + " " + surface(zeroTheme.faint).Render(meta)
|
||||
right := surface(zeroTheme.faint).Render(providerManagerCredDisplay(row)) + active
|
||||
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
line := left + surface(zeroTheme.ink).Render(strings.Repeat(" ", maxInt(1, gap))) + right
|
||||
lines = append(lines, fillPaletteLine(line, width, surface))
|
||||
}
|
||||
|
||||
if row, ok := wizard.currentManagerRow(); ok {
|
||||
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", width)))
|
||||
detail := strings.TrimSpace(row.profile.BaseURL)
|
||||
if description := strings.TrimSpace(row.profile.Description); description != "" {
|
||||
if detail != "" {
|
||||
detail += " — "
|
||||
}
|
||||
detail += description
|
||||
}
|
||||
if detail == "" {
|
||||
detail = "(no endpoint)"
|
||||
}
|
||||
lines = append(lines, fitStyledLine(zeroTheme.faint.Render(detail), width))
|
||||
if wizard.manageDeleting {
|
||||
lines = append(lines, fitStyledLine(zeroTheme.red.Render("Delete "+row.profile.Name+"? This also removes its stored API key. Enter/y confirm · Esc/n cancel"), width))
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// manageActiveName is resolved at render time via the field set in
|
||||
// reloadProviderManagerRows — kept on the struct so the pure render funcs
|
||||
// don't need the model.
|
||||
func providerManagerRowMeta(profile config.ProviderProfile) string {
|
||||
kind := strings.TrimSpace(string(profile.ProviderKind))
|
||||
if kind == "" {
|
||||
kind = strings.TrimSpace(profile.Provider)
|
||||
}
|
||||
if catalog := strings.TrimSpace(profile.CatalogID); catalog != "" && !strings.EqualFold(catalog, profile.Name) {
|
||||
kind = catalog
|
||||
}
|
||||
model := strings.TrimSpace(profile.Model)
|
||||
switch {
|
||||
case kind != "" && model != "":
|
||||
return kind + " · " + model
|
||||
case kind != "":
|
||||
return kind
|
||||
default:
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
func providerManagerCredDisplay(row providerManagerRow) string {
|
||||
if row.cred == "" {
|
||||
return "checking…"
|
||||
}
|
||||
return row.cred
|
||||
}
|
||||
|
||||
func padProviderManagerCell(value string, width int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) > width {
|
||||
if width <= 1 {
|
||||
return string(runes[:width])
|
||||
}
|
||||
return string(runes[:width-1]) + "…"
|
||||
}
|
||||
return value + strings.Repeat(" ", width-len(runes))
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) renderEditMenuStep(width int) []string {
|
||||
lines := []string{zeroTheme.accent.Render("Edit " + wizard.editOriginal.Name)}
|
||||
wizard.editCursor = clampInt(wizard.editCursor, 0, len(providerEditFields)-1)
|
||||
for index, entry := range providerEditFields {
|
||||
surface := transparentSurface
|
||||
marker := surface(zeroTheme.faintest).Render(" ")
|
||||
if index == wizard.editCursor {
|
||||
surface = zeroTheme.onSel
|
||||
marker = surface(zeroTheme.accent).Render("❯ ")
|
||||
}
|
||||
value := wizard.editFieldValue(entry.field, wizard.editDraft)
|
||||
display := ""
|
||||
switch entry.field {
|
||||
case providerEditFieldAPIKey:
|
||||
if value != "" {
|
||||
display = maskedProviderWizardKey(value) + " (new)"
|
||||
} else if wizard.editOriginal.APIKeyStored {
|
||||
display = "stored — enter to replace"
|
||||
} else {
|
||||
display = "none — enter to add"
|
||||
}
|
||||
case providerEditFieldSave:
|
||||
display = ""
|
||||
default:
|
||||
display = displayValue(value, "(empty)")
|
||||
}
|
||||
left := marker + surface(zeroTheme.ink).Render(padProviderManagerCell(entry.label, 12))
|
||||
if display != "" {
|
||||
left += surface(zeroTheme.faint).Render(display)
|
||||
}
|
||||
lines = append(lines, fillPaletteLine(fitStyledLine(left, width), width, surface))
|
||||
}
|
||||
entry := providerEditFields[wizard.editCursor]
|
||||
lines = append(lines, "", fitStyledLine(zeroTheme.faint.Render(entry.hint), width))
|
||||
return lines
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) renderEditValueStep(width int) []string {
|
||||
entry := providerEditFields[clampInt(wizard.editCursor, 0, len(providerEditFields)-1)]
|
||||
value := wizard.editBuffer
|
||||
if wizard.editField == providerEditFieldAPIKey {
|
||||
value = maskedProviderWizardKey(value)
|
||||
}
|
||||
prompt := zeroTheme.userPrompt.Render(entry.label + " > ")
|
||||
if value == "" {
|
||||
value = zeroTheme.faint.Render("(empty)")
|
||||
} else {
|
||||
value = zeroTheme.ink.Render(value)
|
||||
}
|
||||
return []string{
|
||||
zeroTheme.accent.Render("Edit " + wizard.editOriginal.Name + " — " + entry.label),
|
||||
fitStyledLine(prompt+value, width),
|
||||
"",
|
||||
fitStyledLine(zeroTheme.faint.Render(entry.hint), width),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// managerTestModel builds a model with two saved providers, a seeded config
|
||||
// file, and a fake provider factory, then opens /provider on the manager list.
|
||||
func managerTestModel(t *testing.T) model {
|
||||
t.Helper()
|
||||
// Isolate every credential surface the manager touches: XDG redirects the
|
||||
// default config dir (oauth token store, default credential store location)
|
||||
// and the file backend keeps the OS keychain out of tests entirely.
|
||||
home := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", home)
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", filepath.Join(home, "oauth-tokens.json"))
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := config.FileConfig{
|
||||
ActiveProvider: "opengateway",
|
||||
Providers: []config.ProviderProfile{
|
||||
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", APIKey: "sk-gw", Model: "mimo-v2.5-pro", Description: "Main gateway"},
|
||||
{Name: "backup", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://backup.example.com/v1", APIKey: "sk-backup", Model: "backup-model"},
|
||||
},
|
||||
}
|
||||
data, err := json.MarshalIndent(seed, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode seed config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0o600); err != nil {
|
||||
t.Fatalf("write seed config: %v", err)
|
||||
}
|
||||
m := newModel(context.Background(), Options{
|
||||
ProviderName: "opengateway",
|
||||
ModelName: "mimo-v2.5-pro",
|
||||
Provider: &fakeProvider{},
|
||||
ProviderProfile: seed.Providers[0],
|
||||
SavedProviders: seed.Providers,
|
||||
UserConfigPath: configPath,
|
||||
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &fakeProvider{}, nil
|
||||
},
|
||||
})
|
||||
m.width = 120
|
||||
m.height = 40
|
||||
next, _ := m.openProviderManager()
|
||||
return next
|
||||
}
|
||||
|
||||
func managerKey(t *testing.T, m model, msg tea.KeyMsg) model {
|
||||
t.Helper()
|
||||
next, _ := m.handleProviderWizardKey(msg)
|
||||
return next
|
||||
}
|
||||
|
||||
func TestOpenProviderManagerListsSavedProviders(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
if m.providerWizard == nil || m.providerWizard.step != providerWizardStepManage {
|
||||
t.Fatalf("expected the manager list to open, got %+v", m.providerWizard)
|
||||
}
|
||||
if len(m.providerWizard.manageRows) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", len(m.providerWizard.manageRows))
|
||||
}
|
||||
rendered := m.providerWizard.render(m.width)
|
||||
for _, want := range []string{"opengateway", "backup", "active", "Enter activate"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("manager render missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenProviderManagerFallsBackToWizardWhenEmpty(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
next, _ := m.openProviderManager()
|
||||
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepMethod {
|
||||
t.Fatalf("no saved providers should open the add wizard, got %+v", next.providerWizard)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderManagerEnterActivatesSelection(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
m = managerKey(t, m, testKey(tea.KeyDown)) // move to "backup"
|
||||
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
|
||||
if next.providerWizard != nil {
|
||||
t.Fatalf("manager should close after a successful switch")
|
||||
}
|
||||
if next.providerName != "backup" || next.modelName != "backup-model" {
|
||||
t.Fatalf("switch did not commit: provider=%q model=%q", next.providerName, next.modelName)
|
||||
}
|
||||
persisted := readManagerConfig(t, next.userConfigPath)
|
||||
if persisted.ActiveProvider != "backup" {
|
||||
t.Fatalf("activeProvider not persisted, got %q", persisted.ActiveProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderManagerActivateBlockedWhilePending(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
m.pending = true
|
||||
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
|
||||
if next.providerWizard == nil || next.providerWizard.manageStatus == "" {
|
||||
t.Fatalf("a pending run must keep the manager open with a status, got %+v", next.providerWizard)
|
||||
}
|
||||
if next.providerName != "opengateway" {
|
||||
t.Fatalf("provider must not switch while pending, got %q", next.providerName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderManagerDeleteConfirmsAndRemoves(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
|
||||
m = managerKey(t, m, testKeyText("d"))
|
||||
if !m.providerWizard.manageDeleting {
|
||||
t.Fatalf("d must arm the inline delete confirm")
|
||||
}
|
||||
// Esc cancels the confirm without closing the manager.
|
||||
m = managerKey(t, m, testKey(tea.KeyEsc))
|
||||
if m.providerWizard == nil || m.providerWizard.manageDeleting {
|
||||
t.Fatalf("Esc must cancel the confirm, keeping the manager open")
|
||||
}
|
||||
if got := readManagerConfig(t, m.userConfigPath); len(got.Providers) != 2 {
|
||||
t.Fatalf("cancelled delete must not touch config, got %d providers", len(got.Providers))
|
||||
}
|
||||
|
||||
m = managerKey(t, m, testKeyText("d"))
|
||||
next, _ := m.handleProviderWizardKey(testKeyText("y"))
|
||||
if next.providerWizard == nil {
|
||||
t.Fatalf("manager should stay open while providers remain")
|
||||
}
|
||||
if len(next.savedProviders) != 1 || next.savedProviders[0].Name != "opengateway" {
|
||||
t.Fatalf("savedProviders not updated: %+v", next.savedProviders)
|
||||
}
|
||||
persisted := readManagerConfig(t, next.userConfigPath)
|
||||
if len(persisted.Providers) != 1 || persisted.Providers[0].Name != "opengateway" {
|
||||
t.Fatalf("config not updated: %+v", persisted.Providers)
|
||||
}
|
||||
if persisted.ActiveProvider != "opengateway" {
|
||||
t.Fatalf("active must be untouched when a non-active profile is deleted, got %q", persisted.ActiveProvider)
|
||||
}
|
||||
if !strings.Contains(next.providerWizard.manageStatus, "Deleted backup") {
|
||||
t.Fatalf("expected delete status, got %q", next.providerWizard.manageStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderManagerEditModelPersists(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
m = managerKey(t, m, testKeyText("e"))
|
||||
if m.providerWizard.step != providerWizardStepEditMenu {
|
||||
t.Fatalf("e must open the field editor, got step %v", m.providerWizard.step)
|
||||
}
|
||||
// Field order: Name, Endpoint, Model, API key, Description, Save.
|
||||
m = managerKey(t, m, testKey(tea.KeyDown))
|
||||
m = managerKey(t, m, testKey(tea.KeyDown))
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter)) // edit Model
|
||||
if m.providerWizard.step != providerWizardStepEditValue || m.providerWizard.editField != providerEditFieldModel {
|
||||
t.Fatalf("expected model value editor, got step %v field %v", m.providerWizard.step, m.providerWizard.editField)
|
||||
}
|
||||
m.providerWizard.editBuffer = "mimo-v3"
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter)) // commit field
|
||||
if m.providerWizard.step != providerWizardStepEditMenu {
|
||||
t.Fatalf("commit should return to the field menu")
|
||||
}
|
||||
// Move to Save (cursor still on Model=index 2; Save is index 5).
|
||||
m = managerKey(t, m, testKey(tea.KeyDown))
|
||||
m = managerKey(t, m, testKey(tea.KeyDown))
|
||||
m = managerKey(t, m, testKey(tea.KeyDown))
|
||||
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
|
||||
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
|
||||
t.Fatalf("save should land back on the manager list")
|
||||
}
|
||||
persisted := readManagerConfig(t, next.userConfigPath)
|
||||
if persisted.Providers[0].Model != "mimo-v3" {
|
||||
t.Fatalf("edited model not persisted: %+v", persisted.Providers[0])
|
||||
}
|
||||
if persisted.Providers[0].APIKey != "sk-gw" {
|
||||
t.Fatalf("unrelated credential must survive the edit: %+v", persisted.Providers[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderManagerRenameFollowsLiveSession(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
m = managerKey(t, m, testKeyText("e"))
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter)) // edit Name (cursor 0)
|
||||
m.providerWizard.editBuffer = "gateway-main"
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter))
|
||||
// Save is 5 rows below Name.
|
||||
for range 5 {
|
||||
m = managerKey(t, m, testKey(tea.KeyDown))
|
||||
}
|
||||
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
|
||||
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
|
||||
t.Fatalf("save should return to the list, got %+v", next.providerWizard)
|
||||
}
|
||||
if next.providerName != "gateway-main" {
|
||||
t.Fatalf("live session name must follow the rename, got %q", next.providerName)
|
||||
}
|
||||
persisted := readManagerConfig(t, next.userConfigPath)
|
||||
if persisted.ActiveProvider != "gateway-main" {
|
||||
t.Fatalf("activeProvider must follow the rename, got %q", persisted.ActiveProvider)
|
||||
}
|
||||
names := []string{}
|
||||
for _, profile := range persisted.Providers {
|
||||
names = append(names, profile.Name)
|
||||
}
|
||||
if len(persisted.Providers) != 2 || persisted.Providers[0].Name != "gateway-main" {
|
||||
t.Fatalf("rename not persisted, providers: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderManagerEscWalksBackThenCloses(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
m = managerKey(t, m, testKeyText("e"))
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter)) // into Name editor
|
||||
m = managerKey(t, m, testKey(tea.KeyEsc))
|
||||
if m.providerWizard.step != providerWizardStepEditMenu {
|
||||
t.Fatalf("Esc from a field editor must return to the field menu")
|
||||
}
|
||||
m = managerKey(t, m, testKey(tea.KeyEsc))
|
||||
if m.providerWizard.step != providerWizardStepManage {
|
||||
t.Fatalf("Esc from the field menu must return to the list")
|
||||
}
|
||||
m = managerKey(t, m, testKey(tea.KeyEsc))
|
||||
if m.providerWizard != nil {
|
||||
t.Fatalf("Esc from the list must close the manager")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderManagerAddReturnsToListOnEsc(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
m = managerKey(t, m, testKeyText("a"))
|
||||
if m.providerWizard.step != providerWizardStepMethod {
|
||||
t.Fatalf("a must open the add wizard's method step")
|
||||
}
|
||||
m = managerKey(t, m, testKey(tea.KeyEsc))
|
||||
if m.providerWizard == nil || m.providerWizard.step != providerWizardStepManage {
|
||||
t.Fatalf("Esc from a manager-opened add flow must return to the list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderManagerCredsMsgFillsRowsAndIgnoresStale(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
gen := m.providerWizard.manageCredGen
|
||||
next, _ := m.applyProviderManagerCreds(providerManagerCredsMsg{gen: gen, creds: map[string]string{
|
||||
"opengateway": "key set",
|
||||
"backup": "no credential",
|
||||
}})
|
||||
if next.providerWizard.manageRows[0].cred != "key set" || next.providerWizard.manageRows[1].cred != "no credential" {
|
||||
t.Fatalf("creds not applied: %+v", next.providerWizard.manageRows)
|
||||
}
|
||||
// A stale generation must not clobber fresh rows.
|
||||
next, _ = next.applyProviderManagerCreds(providerManagerCredsMsg{gen: gen - 1, creds: map[string]string{
|
||||
"opengateway": "stale",
|
||||
}})
|
||||
if next.providerWizard.manageRows[0].cred != "key set" {
|
||||
t.Fatalf("stale creds applied: %+v", next.providerWizard.manageRows[0])
|
||||
}
|
||||
}
|
||||
|
||||
func readManagerConfig(t *testing.T, path string) config.FileConfig {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
var cfg config.FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("decode config: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestProviderManagerEditKeyPersistsStoredMarker: replacing the key of a
|
||||
// provider that previously had NO stored-key marker (e.g. env-authed) must
|
||||
// persist apiKeyStored — otherwise the secret sits in the credential store
|
||||
// while every ApplyStoredAPIKey gate skips it (PR #560 review, P2).
|
||||
func TestProviderManagerEditKeyPersistsStoredMarker(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
// Reshape "backup" into an env-authed profile with no marker and no inline key.
|
||||
seedCfg := readManagerConfig(t, m.userConfigPath)
|
||||
seedCfg.Providers[1].APIKey = ""
|
||||
seedCfg.Providers[1].APIKeyEnv = "BACKUP_API_KEY"
|
||||
data, err := json.MarshalIndent(seedCfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
|
||||
m = managerKey(t, m, testKeyText("e"))
|
||||
for range 3 { // Name → Endpoint → Model → API key
|
||||
m = managerKey(t, m, testKey(tea.KeyDown))
|
||||
}
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter))
|
||||
if m.providerWizard.editField != providerEditFieldAPIKey {
|
||||
t.Fatalf("expected API key editor, got field %v", m.providerWizard.editField)
|
||||
}
|
||||
m.providerWizard.editBuffer = "sk-new-secret"
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter))
|
||||
m = managerKey(t, m, testKey(tea.KeyDown)) // Description
|
||||
m = managerKey(t, m, testKey(tea.KeyDown)) // Save
|
||||
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
|
||||
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
|
||||
t.Fatalf("save should return to the list, err=%q", next.providerWizard.err)
|
||||
}
|
||||
|
||||
persisted := readManagerConfig(t, next.userConfigPath)
|
||||
backup := persisted.Providers[1]
|
||||
if !backup.APIKeyStored {
|
||||
t.Fatalf("apiKeyStored marker must persist after a key edit: %+v", backup)
|
||||
}
|
||||
if backup.APIKey != "" {
|
||||
t.Fatalf("cleartext key must never land in config.json: %+v", backup)
|
||||
}
|
||||
// The env reference is deliberately preserved as an explicit override: the
|
||||
// resolver fills APIKey from a SET env var first, and the stored key applies
|
||||
// only when the env var is absent — locked in here so a future merge change
|
||||
// can't silently flip the precedence.
|
||||
if backup.APIKeyEnv != "BACKUP_API_KEY" {
|
||||
t.Fatalf("APIKeyEnv must survive a key edit as an explicit override, got %q", backup.APIKeyEnv)
|
||||
}
|
||||
store, err := config.ProviderKeyStoreAt(filepath.Dir(next.userConfigPath))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if key, ok, err := store.Get("backup"); err != nil || !ok || key != "sk-new-secret" {
|
||||
t.Fatalf("key must be captured into the store beside the config, got key=%q ok=%v err=%v", key, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderManagerDescriptionClearPersists: clearing a description must
|
||||
// actually persist (the upsert merge treats empty as "unchanged" — PR #560, P3).
|
||||
func TestProviderManagerDescriptionClearPersists(t *testing.T) {
|
||||
m := managerTestModel(t) // opengateway has description "Main gateway"
|
||||
m = managerKey(t, m, testKeyText("e"))
|
||||
for range 4 { // Name → Endpoint → Model → API key → Description
|
||||
m = managerKey(t, m, testKey(tea.KeyDown))
|
||||
}
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter))
|
||||
if m.providerWizard.editField != providerEditFieldDescription {
|
||||
t.Fatalf("expected description editor, got field %v", m.providerWizard.editField)
|
||||
}
|
||||
m.providerWizard.editBuffer = ""
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter))
|
||||
m = managerKey(t, m, testKey(tea.KeyDown)) // Save
|
||||
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
|
||||
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
|
||||
t.Fatalf("save should return to the list, err=%q", next.providerWizard.err)
|
||||
}
|
||||
|
||||
persisted := readManagerConfig(t, next.userConfigPath)
|
||||
if persisted.Providers[0].Description != "" {
|
||||
t.Fatalf("cleared description must persist, got %q", persisted.Providers[0].Description)
|
||||
}
|
||||
if row, ok := next.providerWizard.currentManagerRow(); !ok || row.profile.Description != "" {
|
||||
t.Fatalf("manager rows must reflect the cleared description: %+v", row.profile)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderManagerDeleteHintNamesActualOAuthLogin: after a rename the token
|
||||
// lives under the catalog id, not the profile name — the cleanup hint must name
|
||||
// the entry `zero auth logout` would actually delete (PR #560 review, P3).
|
||||
func TestProviderManagerDeleteHintNamesActualOAuthLogin(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
// Reshape "backup" into a renamed OAuth catalog profile: keyless, named
|
||||
// "codex", backed by a token stored under the chatgpt catalog id.
|
||||
seedCfg := readManagerConfig(t, m.userConfigPath)
|
||||
seedCfg.Providers[1] = config.ProviderProfile{Name: "codex", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"}
|
||||
data, err := json.MarshalIndent(seedCfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
m.savedProviders = seedCfg.Providers
|
||||
next, _ := m.reloadProviderManagerRows()
|
||||
m = next
|
||||
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("oauth store: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
|
||||
t.Fatalf("save token: %v", err)
|
||||
}
|
||||
|
||||
m = managerKey(t, m, testKey(tea.KeyDown)) // select "codex"
|
||||
m = managerKey(t, m, testKeyText("d"))
|
||||
next, cmd := m.handleProviderWizardKey(testKeyText("y"))
|
||||
if next.providerWizard == nil {
|
||||
t.Fatalf("manager should stay open")
|
||||
}
|
||||
// The stored-key delete and OAuth hint run in a follow-up cmd off the UI
|
||||
// goroutine; drain it into the model like the runtime would.
|
||||
next = drainProviderManagerCmds(t, next, cmd)
|
||||
status := next.providerWizard.manageStatus
|
||||
if !strings.Contains(status, "zero auth logout chatgpt") {
|
||||
t.Fatalf("hint must name the stored login (chatgpt), got %q", status)
|
||||
}
|
||||
if strings.Contains(status, "logout codex") {
|
||||
t.Fatalf("hint must not point at a login key that does not exist, got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderManagerReadsStoredKeyBesideConfig: the TUI must READ stored keys
|
||||
// from the same config-adjacent store its write paths use. managerTestModel
|
||||
// deliberately puts userConfigPath outside XDG_CONFIG_HOME, so a key seeded
|
||||
// beside the config is invisible to the default-path store — with the old
|
||||
// default-store reads, the switch gate rejected the provider and the manager
|
||||
// showed "stored key missing" for a perfectly healthy profile (PR #560, P2).
|
||||
func TestProviderManagerReadsStoredKeyBesideConfig(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
// Reshape "backup" into a stored-key profile whose key lives ONLY in the
|
||||
// store beside userConfigPath.
|
||||
seedCfg := readManagerConfig(t, m.userConfigPath)
|
||||
seedCfg.Providers[1].APIKey = ""
|
||||
seedCfg.Providers[1].APIKeyStored = true
|
||||
data, err := json.MarshalIndent(seedCfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
store, err := config.ProviderKeyStoreAt(filepath.Dir(m.userConfigPath))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("backup", "sk-beside-config"); err != nil {
|
||||
t.Fatalf("seed key: %v", err)
|
||||
}
|
||||
m.savedProviders = seedCfg.Providers
|
||||
next, _ := m.reloadProviderManagerRows()
|
||||
m = next
|
||||
|
||||
// The async credential probe must find the key in the config-adjacent store.
|
||||
cmd := providerManagerCredsCmd(m.providerWizard.manageCredGen, m.providerWizard.manageRows, m.userConfigPath)
|
||||
msg, ok := cmd().(providerManagerCredsMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected creds msg")
|
||||
}
|
||||
if msg.creds["backup"] != "key stored" {
|
||||
t.Fatalf("creds probe must read the store beside the config, got %q", msg.creds["backup"])
|
||||
}
|
||||
|
||||
// And the switch path must load the key instead of rejecting the provider.
|
||||
var built config.ProviderProfile
|
||||
m.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
built = profile
|
||||
return &fakeProvider{}, nil
|
||||
}
|
||||
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
|
||||
next, _ = m.handleProviderWizardKey(testKey(tea.KeyEnter))
|
||||
if next.providerWizard != nil {
|
||||
t.Fatalf("switch must succeed on a config-adjacent stored key, status=%q", next.providerWizard.manageStatus)
|
||||
}
|
||||
if built.APIKey != "sk-beside-config" {
|
||||
t.Fatalf("switch must load the key from the store beside the config, got %q", built.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// drainProviderManagerCmds executes a manager action's follow-up cmds (batch
|
||||
// or single) and applies any providerManagerCleanupMsg, mirroring what the
|
||||
// bubbletea runtime does with the returned tea.Cmd.
|
||||
func drainProviderManagerCmds(t *testing.T, m model, cmd tea.Cmd) model {
|
||||
t.Helper()
|
||||
var apply func(c tea.Cmd)
|
||||
apply = func(c tea.Cmd) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
switch msg := c().(type) {
|
||||
case tea.BatchMsg:
|
||||
for _, sub := range msg {
|
||||
apply(sub)
|
||||
}
|
||||
case providerManagerCleanupMsg:
|
||||
m, _ = m.applyProviderManagerCleanup(msg)
|
||||
}
|
||||
}
|
||||
apply(cmd)
|
||||
return m
|
||||
}
|
||||
|
||||
// TestProviderManagerCaseOnlyRenameUpdatesInPlace: groq → Groq through the
|
||||
// editor must rename in place — the old EqualFold-skip + case-sensitive upsert
|
||||
// combination appended a duplicate profile (review finding, empirically shown).
|
||||
func TestProviderManagerCaseOnlyRenameUpdatesInPlace(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
m = managerKey(t, m, testKeyText("e"))
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter)) // edit Name (cursor 0)
|
||||
m.providerWizard.editBuffer = "OpenGateway"
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter))
|
||||
for range 5 {
|
||||
m = managerKey(t, m, testKey(tea.KeyDown))
|
||||
}
|
||||
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter)) // Save
|
||||
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
|
||||
t.Fatalf("save should return to the list, err=%q", next.providerWizard.err)
|
||||
}
|
||||
persisted := readManagerConfig(t, next.userConfigPath)
|
||||
if len(persisted.Providers) != 2 {
|
||||
t.Fatalf("case-only rename must not duplicate: %d providers", len(persisted.Providers))
|
||||
}
|
||||
if persisted.Providers[0].Name != "OpenGateway" || persisted.Providers[0].APIKey != "sk-gw" {
|
||||
t.Fatalf("in-place update wrong: %+v", persisted.Providers[0])
|
||||
}
|
||||
if persisted.ActiveProvider != "OpenGateway" {
|
||||
t.Fatalf("active must follow, got %q", persisted.ActiveProvider)
|
||||
}
|
||||
if len(next.savedProviders) != 2 || next.savedProviders[0].Name != "OpenGateway" {
|
||||
t.Fatalf("in-memory list must mirror the rename: %+v", next.savedProviders)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderManagerMutationsKeepResolvedProviders: savedProviders is seeded
|
||||
// from the RESOLVED+FILTERED provider set — a manager delete/edit must mutate
|
||||
// it surgically, not replace it with the raw user-config list (which would drop
|
||||
// project-config-contributed providers for the session).
|
||||
func TestProviderManagerMutationsKeepResolvedProviders(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
// Simulate a project-config-contributed provider: present in the session's
|
||||
// resolved list, absent from the user config.json the writers operate on.
|
||||
projectProvider := config.ProviderProfile{Name: "team-gateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://team.example.com/v1", APIKey: "sk-team", Model: "team-model"}
|
||||
m.savedProviders = append(m.savedProviders, projectProvider)
|
||||
next, _ := m.reloadProviderManagerRows()
|
||||
m = next
|
||||
|
||||
// Delete "backup" (a user-config profile): team-gateway must survive.
|
||||
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
|
||||
m = managerKey(t, m, testKeyText("d"))
|
||||
next, _ = m.handleProviderWizardKey(testKeyText("y"))
|
||||
names := []string{}
|
||||
for _, profile := range next.savedProviders {
|
||||
names = append(names, profile.Name)
|
||||
}
|
||||
if len(next.savedProviders) != 2 || names[0] != "opengateway" || names[1] != "team-gateway" {
|
||||
t.Fatalf("project-contributed provider lost by delete: %v", names)
|
||||
}
|
||||
|
||||
// Edit opengateway's model: team-gateway must still survive.
|
||||
m = next
|
||||
m.providerWizard.manageCursor = 0
|
||||
m = managerKey(t, m, testKeyText("e"))
|
||||
for range 2 {
|
||||
m = managerKey(t, m, testKey(tea.KeyDown))
|
||||
}
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter)) // Model field
|
||||
m.providerWizard.editBuffer = "mimo-next"
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter))
|
||||
for range 3 {
|
||||
m = managerKey(t, m, testKey(tea.KeyDown))
|
||||
}
|
||||
next, _ = m.handleProviderWizardKey(testKey(tea.KeyEnter)) // Save
|
||||
names = names[:0]
|
||||
for _, profile := range next.savedProviders {
|
||||
names = append(names, profile.Name)
|
||||
}
|
||||
if len(next.savedProviders) != 2 || names[1] != "team-gateway" {
|
||||
t.Fatalf("project-contributed provider lost by edit: %v", names)
|
||||
}
|
||||
if next.savedProviders[0].Model != "mimo-next" {
|
||||
t.Fatalf("edit not mirrored in-memory: %+v", next.savedProviders[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderManagerEscWalksBackFromDeepAddFlow: Esc anywhere inside a
|
||||
// manager-launched add flow returns to the provider list ("Esc walks back one
|
||||
// level"), never destroying the manager context — previously only the first
|
||||
// step walked back and every deeper step hard-closed the overlay.
|
||||
func TestProviderManagerEscWalksBackFromDeepAddFlow(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
m = managerKey(t, m, testKeyText("a")) // add flow, method step
|
||||
m = managerKey(t, m, testKey(tea.KeyEnter))
|
||||
if m.providerWizard == nil || m.providerWizard.step == providerWizardStepMethod {
|
||||
t.Fatalf("expected to advance past the method step, got %+v", m.providerWizard)
|
||||
}
|
||||
deepStep := m.providerWizard.step
|
||||
m = managerKey(t, m, testKey(tea.KeyEsc))
|
||||
if m.providerWizard == nil {
|
||||
t.Fatalf("Esc on step %v must not destroy the manager overlay", deepStep)
|
||||
}
|
||||
if m.providerWizard.step != providerWizardStepManage {
|
||||
t.Fatalf("Esc must return to the provider list, got step %v", m.providerWizard.step)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderManagerActivationUsesStructuredResult: a refusal whose display
|
||||
// text happens to contain "Switched to" (a provider literally named that) must
|
||||
// NOT close the manager as a success — the outcome bool, not UI copy, decides.
|
||||
func TestProviderManagerActivationUsesStructuredResult(t *testing.T) {
|
||||
m := managerTestModel(t)
|
||||
seedCfg := readManagerConfig(t, m.userConfigPath)
|
||||
seedCfg.Providers[1] = config.ProviderProfile{Name: "Switched to prod", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://p.example.com/v1", Model: "m"}
|
||||
data, err := json.MarshalIndent(seedCfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
m.savedProviders = seedCfg.Providers
|
||||
next, _ := m.reloadProviderManagerRows()
|
||||
m = next
|
||||
|
||||
m = managerKey(t, m, testKey(tea.KeyDown)) // select the credential-less provider
|
||||
next, _ = m.handleProviderWizardKey(testKey(tea.KeyEnter))
|
||||
if next.providerWizard == nil {
|
||||
t.Fatalf("a refused switch must keep the manager open even when the refusal text contains \"Switched to\"")
|
||||
}
|
||||
if !strings.Contains(next.providerWizard.manageStatus, "no usable credential") {
|
||||
t.Fatalf("expected the refusal inline, got %q", next.providerWizard.manageStatus)
|
||||
}
|
||||
if next.providerName != "opengateway" {
|
||||
t.Fatalf("provider must not switch, got %q", next.providerName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderManagerCredStateFallsThroughStaleMarker: a stale APIKeyStored
|
||||
// marker with a SET env var must render the env credential (the runtime falls
|
||||
// back the same way and switches fine), not "stored key missing".
|
||||
func TestProviderManagerCredStateFallsThroughStaleMarker(t *testing.T) {
|
||||
t.Setenv("STALE_MARKER_KEY", "sk-env")
|
||||
profile := config.ProviderProfile{Name: "gw", APIKeyStored: true, APIKeyEnv: "STALE_MARKER_KEY"}
|
||||
state := providerManagerCredState(profile, false, nil, map[string]bool{})
|
||||
if state != "env STALE_MARKER_KEY" {
|
||||
t.Fatalf("stale marker must fall through to the env credential, got %q", state)
|
||||
}
|
||||
// Marker with neither store entry nor fallback: the broken state still shows.
|
||||
t.Setenv("STALE_MARKER_KEY", "")
|
||||
state = providerManagerCredState(profile, false, nil, map[string]bool{})
|
||||
if state != "stored key missing" {
|
||||
t.Fatalf("expected stored key missing with no fallback, got %q", state)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,18 @@ func (m model) applyProviderWizardOAuth(msg providerWizardOAuthMsg) (model, tea.
|
||||
if msg.apiKey != "" {
|
||||
m.providerWizard.apiKey = msg.apiKey
|
||||
}
|
||||
if msg.tokenLogin {
|
||||
// Persist the profile HERE, on the Update goroutine (see
|
||||
// persistOAuthLoginProvider for the threading contract), and only mirror
|
||||
// it into the in-memory saved list when the write succeeded — a mirror of
|
||||
// a profile that never reached disk shows a phantom provider that works
|
||||
// this session and silently vanishes on restart.
|
||||
if err := persistOAuthLoginProvider(m.userConfigPath, msg.providerID); err != nil {
|
||||
m.providerWizard.oauthErr = "Signed in, but the provider profile could not be saved: " + redaction.ErrorMessage(err, redaction.Options{})
|
||||
return m, nil
|
||||
}
|
||||
m.savedProviders = appendOAuthLoginProfile(m.savedProviders, msg.providerID)
|
||||
}
|
||||
// OAuth succeeded (key minted, or a refreshable token stored for the runtime
|
||||
// resolver). Skip the endpoint/credential steps and go straight to model
|
||||
// selection.
|
||||
@@ -141,6 +153,48 @@ func runProviderChatGPTLogin() error {
|
||||
return store.Save(oauth.ProviderKey("chatgpt"), token)
|
||||
}
|
||||
|
||||
// appendOAuthLoginProfile mirrors the profile persistOAuthLoginProvider wrote to
|
||||
// config into an in-memory saved-provider list, skipping when a profile already
|
||||
// serves the catalog entry (by name or catalog id).
|
||||
func appendOAuthLoginProfile(saved []config.ProviderProfile, providerID string) []config.ProviderProfile {
|
||||
descriptor, ok := providercatalog.Get(providerID)
|
||||
if !ok {
|
||||
return saved
|
||||
}
|
||||
for _, profile := range saved {
|
||||
if strings.EqualFold(strings.TrimSpace(profile.CatalogID), descriptor.ID) ||
|
||||
strings.EqualFold(strings.TrimSpace(profile.Name), descriptor.ID) {
|
||||
return saved
|
||||
}
|
||||
}
|
||||
return append(saved, config.ProviderProfile{
|
||||
Name: descriptor.ID,
|
||||
ProviderKind: providerWizardProviderKind(descriptor),
|
||||
CatalogID: descriptor.ID,
|
||||
BaseURL: descriptor.DefaultBaseURL,
|
||||
Model: descriptor.DefaultModel,
|
||||
})
|
||||
}
|
||||
|
||||
// persistOAuthLoginProvider guarantees the stored login is reachable from the
|
||||
// provider list even when the wizard is abandoned before the model step: the
|
||||
// token alone is invisible — no profile means no entry in /provider, /model, or
|
||||
// `zero providers use`, which reads as the login having vanished.
|
||||
//
|
||||
// MUST run on the Update goroutine (call it from a message handler, never from
|
||||
// inside a tea.Cmd): config.json writes are unlocked read-modify-writes, and a
|
||||
// background login — a device-code poll can complete minutes later — racing a
|
||||
// UI-thread write would silently clobber it (lost update). The returned error
|
||||
// lets the caller surface a failed write instead of showing a provider that
|
||||
// never reached disk.
|
||||
func persistOAuthLoginProvider(configPath string, catalogID string) error {
|
||||
if strings.TrimSpace(configPath) == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := config.EnsureCatalogProvider(configPath, catalogID)
|
||||
return err
|
||||
}
|
||||
|
||||
// buildOAuthPresetEnv layers the process env with the preset opt-in so a
|
||||
// `zero` launch from a TUI session can use the baked-in ChatGPT client_id
|
||||
// without requiring the user to export ZERO_OAUTH_ALLOW_PRESETS themselves.
|
||||
@@ -249,6 +303,12 @@ const (
|
||||
providerWizardStepCredential
|
||||
providerWizardStepModel
|
||||
providerWizardStepDone
|
||||
// Manager steps (provider_manager.go): the list-first /provider surface.
|
||||
// They live on the same state struct so every overlay gate, key route, and
|
||||
// mouse hook that already checks m.providerWizard covers them for free.
|
||||
providerWizardStepManage
|
||||
providerWizardStepEditMenu
|
||||
providerWizardStepEditValue
|
||||
)
|
||||
|
||||
// providerWizardMethodOption is a row in the "How do you want to connect?" step.
|
||||
@@ -319,6 +379,20 @@ type providerWizardState struct {
|
||||
oauthDevice bool
|
||||
deviceUserCode string
|
||||
deviceVerificationURI string
|
||||
// Manager state (provider_manager.go): the list-first /provider surface.
|
||||
manage bool
|
||||
manageRows []providerManagerRow
|
||||
manageCursor int
|
||||
manageDeleting bool
|
||||
manageStatus string
|
||||
manageCredGen int
|
||||
manageActiveName string
|
||||
// Edit state: field-level editor for one saved profile.
|
||||
editOriginal config.ProviderProfile
|
||||
editDraft config.ProviderProfile
|
||||
editCursor int
|
||||
editField providerEditField
|
||||
editBuffer string
|
||||
}
|
||||
|
||||
func (m model) newProviderWizard() *providerWizardState {
|
||||
@@ -624,6 +698,26 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
if m.providerWizard == nil {
|
||||
return m, nil
|
||||
}
|
||||
// A manager-launched add flow honors the manager's "Esc walks back one
|
||||
// level" contract on EVERY step (not just the first): Esc returns to the
|
||||
// provider list instead of destroying the overlay — and with it the user's
|
||||
// cursor and status — from a step deep in the flow. An in-flight OAuth login
|
||||
// is abandoned the same way; bumping the attempt id makes its late result
|
||||
// stale so applyProviderWizardOAuth drops it.
|
||||
if m.providerWizard.manage && !m.providerWizard.managerStep() && keyIs(msg, tea.KeyEsc) {
|
||||
wizard := m.providerWizard
|
||||
if wizard.oauthPending {
|
||||
wizard.oauthPending = false
|
||||
wizard.oauthAttemptID++
|
||||
}
|
||||
wizard.oauthDevice = false
|
||||
wizard.deviceUserCode = ""
|
||||
wizard.deviceVerificationURI = ""
|
||||
wizard.err = ""
|
||||
wizard.oauthErr = ""
|
||||
wizard.step = providerWizardStepManage
|
||||
return m, nil
|
||||
}
|
||||
// While a browser/device OAuth login is in flight, ignore input except Esc,
|
||||
// which abandons the wizard (the background flow times out and is dropped).
|
||||
if m.providerWizard.oauthPending {
|
||||
@@ -632,6 +726,10 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
// Manager steps (list / edit) own their keys entirely.
|
||||
if m.providerWizard.managerStep() {
|
||||
return m.handleProviderManagerKey(msg)
|
||||
}
|
||||
// Manage-key step: keep/replace/remove a provider's stored key.
|
||||
if m.providerWizard.step == providerWizardStepManageKey {
|
||||
switch {
|
||||
@@ -799,6 +897,13 @@ func (m model) handleProviderWizardPaste(content string) (model, tea.Cmd) {
|
||||
m.providerWizard.appendAPIKey([]rune(content))
|
||||
case providerWizardStepModel:
|
||||
m.providerWizard.appendModelSearch([]rune(content))
|
||||
case providerWizardStepEditValue:
|
||||
for _, r := range content {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
continue
|
||||
}
|
||||
m.providerWizard.editBuffer += string(r)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -1012,6 +1117,9 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
|
||||
m.providerProfile = profile
|
||||
m.providerName = profile.Name
|
||||
m.modelName = profile.Model
|
||||
// Keep the in-memory saved list in sync so the provider manager and /model
|
||||
// picker show the new profile without a restart.
|
||||
m.savedProviders = upsertSavedProviderProfile(m.savedProviders, profile)
|
||||
// Keep sub-agent child processes on the same provider we just switched to —
|
||||
// same as the /model and /provider switch paths (command_center.go). Without
|
||||
// this, a ZERO_PROVIDER exported by an earlier switch stays pointing at the
|
||||
@@ -1131,13 +1239,23 @@ func (wizard *providerWizardState) render(width int) string {
|
||||
lines = append(lines, wizard.renderModelStep(innerWidth)...)
|
||||
case providerWizardStepDone:
|
||||
lines = append(lines, wizard.renderDoneStep(innerWidth)...)
|
||||
case providerWizardStepManage:
|
||||
lines = append(lines, wizard.renderManageStep(innerWidth)...)
|
||||
case providerWizardStepEditMenu:
|
||||
lines = append(lines, wizard.renderEditMenuStep(innerWidth)...)
|
||||
case providerWizardStepEditValue:
|
||||
lines = append(lines, wizard.renderEditValueStep(innerWidth)...)
|
||||
}
|
||||
lines = append(lines,
|
||||
zeroTheme.line.Render(strings.Repeat("─", innerWidth)),
|
||||
zeroTheme.faint.Render(wizard.footer()),
|
||||
)
|
||||
|
||||
block := styledBlockFillTitle(overlayWidth, "Provider setup", lines, zeroTheme.lineStrong, lipgloss.NewStyle())
|
||||
title := "Provider setup"
|
||||
if wizard.managerStep() {
|
||||
title = "Providers"
|
||||
}
|
||||
block := styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle())
|
||||
if width > overlayWidth {
|
||||
return indentBlock(block, (width-overlayWidth)/2)
|
||||
}
|
||||
@@ -1145,8 +1263,30 @@ func (wizard *providerWizardState) render(width int) string {
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) footer() string {
|
||||
text := wizard.footerText()
|
||||
// Inside a manager-launched add flow, Esc walks back to the provider list
|
||||
// (see handleProviderWizardKey) — the hint must match.
|
||||
if wizard.manage && !wizard.managerStep() {
|
||||
text = strings.ReplaceAll(text, "Esc close", "Esc back")
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) footerText() string {
|
||||
canRight := wizard.canAdvanceWithRight()
|
||||
switch wizard.step {
|
||||
case providerWizardStepManage:
|
||||
if wizard.manageDeleting {
|
||||
return "Enter/y delete Esc/n cancel"
|
||||
}
|
||||
if len(wizard.manageRows) == 0 {
|
||||
return "a add Esc close"
|
||||
}
|
||||
return "↑/↓ move Enter activate a add e edit d delete Esc close"
|
||||
case providerWizardStepEditMenu:
|
||||
return "↑/↓ move Enter edit field / save Esc back"
|
||||
case providerWizardStepEditValue:
|
||||
return "type to edit Enter apply Ctrl+U clear Esc back"
|
||||
case providerWizardStepMethod:
|
||||
return "↑/↓ move Enter/→ continue Esc close"
|
||||
case providerWizardStepProvider:
|
||||
@@ -1189,7 +1329,7 @@ func providerWizardOverlayWidth(width int, step providerWizardStep) int {
|
||||
switch step {
|
||||
case providerWizardStepProvider:
|
||||
target = providerWizardProviderWidth
|
||||
case providerWizardStepModel:
|
||||
case providerWizardStepModel, providerWizardStepManage:
|
||||
target = providerWizardModelWidth
|
||||
}
|
||||
target = minInt(target, width)
|
||||
@@ -1203,6 +1343,14 @@ func providerWizardStepLine(wizard *providerWizardState) string {
|
||||
if wizard == nil {
|
||||
return ""
|
||||
}
|
||||
if wizard.managerStep() {
|
||||
switch wizard.step {
|
||||
case providerWizardStepManage:
|
||||
return pluralCount(len(wizard.manageRows), "saved provider")
|
||||
default:
|
||||
return "edit provider"
|
||||
}
|
||||
}
|
||||
step := wizard.step
|
||||
type stepLabel struct {
|
||||
step providerWizardStep
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
@@ -384,3 +388,64 @@ func TestProviderWizardDeviceCodeIgnoresStaleAttempt(t *testing.T) {
|
||||
t.Fatalf("stale device-code result applied device details: %+v", out.providerWizard)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistOAuthLoginProviderWritesKeylessProfileWithoutStealingActive(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
persistOAuthLoginProvider(configPath, "chatgpt")
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
var cfg config.FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("decode config: %v", err)
|
||||
}
|
||||
if cfg.ActiveProvider != "opengateway" {
|
||||
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
|
||||
}
|
||||
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
|
||||
t.Fatalf("expected chatgpt profile appended, got %+v", cfg.Providers)
|
||||
}
|
||||
if cfg.Providers[1].APIKey != "" || cfg.Providers[1].APIKeyStored {
|
||||
t.Fatalf("OAuth profile must stay keyless so the token resolver attaches, got %+v", cfg.Providers[1])
|
||||
}
|
||||
|
||||
// Blank path (no user config, e.g. tests/ephemeral runs) must be a no-op.
|
||||
persistOAuthLoginProvider("", "chatgpt")
|
||||
}
|
||||
|
||||
func TestAppendOAuthLoginProfileAddsOnceAndRespectsRenames(t *testing.T) {
|
||||
saved := []config.ProviderProfile{{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible}}
|
||||
|
||||
saved = appendOAuthLoginProfile(saved, "chatgpt")
|
||||
if len(saved) != 2 || saved[1].Name != "chatgpt" || saved[1].CatalogID != "chatgpt" {
|
||||
t.Fatalf("expected chatgpt appended, got %+v", saved)
|
||||
}
|
||||
if saved[1].Model == "" || saved[1].BaseURL == "" {
|
||||
t.Fatalf("appended profile must carry catalog defaults, got %+v", saved[1])
|
||||
}
|
||||
|
||||
// Idempotent: a second login must not duplicate.
|
||||
saved = appendOAuthLoginProfile(saved, "chatgpt")
|
||||
if len(saved) != 2 {
|
||||
t.Fatalf("duplicate appended: %+v", saved)
|
||||
}
|
||||
|
||||
// A renamed profile serving the same catalog entry blocks the append too.
|
||||
renamed := []config.ProviderProfile{{Name: "codex", CatalogID: "chatgpt"}}
|
||||
renamed = appendOAuthLoginProfile(renamed, "chatgpt")
|
||||
if len(renamed) != 1 {
|
||||
t.Fatalf("renamed profile not respected: %+v", renamed)
|
||||
}
|
||||
|
||||
// Unknown catalog id: no-op.
|
||||
if got := appendOAuthLoginProfile(nil, "no-such-provider"); got != nil {
|
||||
t.Fatalf("unknown provider must not append, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,12 @@ func (m model) startNewSession() model {
|
||||
// Scrollback above can't be un-printed; a faint divider marks the boundary and
|
||||
// the flush frontier restarts for the fresh transcript (mirrors /clear, /resume).
|
||||
m.resetFlushFrontier("· new session ·")
|
||||
// Loops belong to the previous session; stop them so they don't fire the old
|
||||
// conversation's prompt into the fresh one.
|
||||
if updated, cleared := m.clearLoopsForSessionSwitch(); cleared > 0 {
|
||||
m = updated
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", cleared)})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -205,6 +211,10 @@ func (m model) handleResumeCommand(args string) (model, string) {
|
||||
return m, "Sessions\nerror: " + err.Error()
|
||||
}
|
||||
|
||||
// Capture the current session id before switching so loops are only torn down
|
||||
// on a real change — `/resume latest` or `/resume <currentID>` can resolve to
|
||||
// the already-active session, whose loops belong to it, not a "previous" one.
|
||||
previousID := m.activeSession.SessionID
|
||||
m.activeSession = *session
|
||||
m.sessionEvents = append([]sessions.Event{}, events...)
|
||||
if m.providerName == "" {
|
||||
@@ -213,9 +223,16 @@ func (m model) handleResumeCommand(args string) (model, string) {
|
||||
if m.modelName == "" {
|
||||
m.modelName = session.ModelID
|
||||
}
|
||||
loopsCleared := 0
|
||||
if session.SessionID != previousID {
|
||||
m, loopsCleared = m.clearLoopsForSessionSwitch()
|
||||
}
|
||||
|
||||
rows := initialTranscript()
|
||||
rows = appendRow(rows, rowSystem, m.formatResumeSummary(*session, len(events)))
|
||||
if loopsCleared > 0 {
|
||||
rows = appendRow(rows, rowSystem, fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", loopsCleared))
|
||||
}
|
||||
rows = appendTranscriptRowsDedup(rows, transcriptRowsFromSessionEvents(events))
|
||||
m.transcript = rows
|
||||
// Every rehydrated row is settled by construction, so resetting the flush
|
||||
|
||||
+21
-3
@@ -92,10 +92,14 @@ func (m model) emptyStateLines(width int) []string {
|
||||
// emptyStateExamples seeds the first prompt with a few representative asks.
|
||||
const emptyStateExamples = `Try "explain this codebase" · "fix the failing test" · "add a --json flag"`
|
||||
|
||||
// emptyStateOrientation renders a faint "cwd · branch · model" line for the home
|
||||
// screen, omitting any piece that's unknown. Empty when nothing is known.
|
||||
// emptyStateOrientation renders a faint "version · cwd · branch · model" line
|
||||
// for the home screen, omitting any piece that's unknown. Empty when nothing
|
||||
// is known.
|
||||
func (m model) emptyStateOrientation() string {
|
||||
parts := make([]string, 0, 3)
|
||||
parts := make([]string, 0, 4)
|
||||
if version := displayVersion(m.appVersion); version != "" {
|
||||
parts = append(parts, version)
|
||||
}
|
||||
if cwd := strings.TrimSpace(m.cwd); cwd != "" {
|
||||
parts = append(parts, shortenPath(cwd))
|
||||
}
|
||||
@@ -111,6 +115,20 @@ func (m model) emptyStateOrientation() string {
|
||||
return zeroTheme.faint.Render(strings.Join(parts, " · "))
|
||||
}
|
||||
|
||||
// displayVersion formats the CLI build version for display: numeric releases
|
||||
// gain a "v" prefix (0.2.0 → v0.2.0), anything else (dev, git SHAs) passes
|
||||
// through unchanged. Empty stays empty so unset builds show nothing.
|
||||
func displayVersion(version string) string {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return ""
|
||||
}
|
||||
if version[0] >= '0' && version[0] <= '9' {
|
||||
return "v" + version
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func zeroWordmarkLines() []string {
|
||||
lines := make([]string, 0, minInt(len(zeroWordmarkPrefixLines), len(zeroWordmarkOLines)))
|
||||
for index := 0; index < len(zeroWordmarkPrefixLines) && index < len(zeroWordmarkOLines); index++ {
|
||||
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user