From 076622ac2e369355d7954aa10fd4f3222ba5eadd Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:05:06 -0400 Subject: [PATCH 01/27] fix(dictation): redact API keys from streaming transcriber errors Streaming dictation (Deepgram, OpenAI Realtime) injected API keys into websocket URLs/headers and echoed unredacted transport errors and server-side error payloads. Wrap those error endpoints with providerio.Redact and switch the connect-error format specifier from %w to %s so the raw error cannot leak via errors.Unwrap(). Adds regression tests asserting the API key is not present in the returned stream error for both transcribers. Co-authored-by: euxaristia <25621994+euxaristia@users.noreply.github.com> --- internal/dictation/transcriber_deepgram.go | 5 +- .../dictation/transcriber_openai_realtime.go | 7 ++- internal/dictation/transcriber_stream_test.go | 55 +++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index b8b9319aa..7f6cf7696 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/Gitlawb/zero/internal/providers/providerio" "github.com/coder/websocket" ) @@ -60,7 +61,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}}, }) if err != nil { - return "", fmt.Errorf("connecting to Deepgram: %w", err) + return "", fmt.Errorf("connecting to Deepgram: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } defer conn.CloseNow() @@ -100,7 +101,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha } default: } - return compose(), fmt.Errorf("Deepgram stream error: %w", err) + return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } if typ != websocket.MessageText { continue diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index 590cbc8b4..55d026f48 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + "github.com/Gitlawb/zero/internal/providers/providerio" "github.com/coder/websocket" ) @@ -59,7 +60,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, }) if err != nil { - return "", fmt.Errorf("connecting to OpenAI Realtime: %w", err) + return "", fmt.Errorf("connecting to OpenAI Realtime: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } defer conn.CloseNow() @@ -113,7 +114,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks } default: } - return compose(), fmt.Errorf("OpenAI Realtime stream error: %w", err) + return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } if typ != websocket.MessageText { continue @@ -147,7 +148,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks case realtimeCommitted: committed = true case realtimeError: - return compose(), fmt.Errorf("OpenAI Realtime error: %s", evt.text) + return compose(), fmt.Errorf("OpenAI Realtime error: %s", providerio.Redact(evt.text, o.cfg.APIKey)) } // The writer signals commit completion out of band; observe it so a // stop with no further audio still flips `committed`. diff --git a/internal/dictation/transcriber_stream_test.go b/internal/dictation/transcriber_stream_test.go index 8ece95306..4b65dd5f0 100644 --- a/internal/dictation/transcriber_stream_test.go +++ b/internal/dictation/transcriber_stream_test.go @@ -229,3 +229,58 @@ func TestParseRealtimeEvent(t *testing.T) { t.Errorf("error parse: %+v", e) } } + +func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + defer c.Close(websocket.StatusNormalClosure, "") + for { + typ, _, err := c.Read(ctx) + if err != nil { + return + } + if typ != websocket.MessageText { + continue + } + _ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`)) + } + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 480) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-test-key") { + t.Errorf("API key leaked: %v", ferr) + } +} + +func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // close immediately with an error that simulates connection issue with API key + c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key") + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-test-key") { + t.Errorf("API key leaked: %v", ferr) + } +} From c0dcecaf5ab61465788c345ecdcd607ab4f957f0 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:28:44 -0400 Subject: [PATCH 02/27] fixup(dictation): address review: redact session/write errors, move redaction tests - Redact API key in OpenAI Realtime session configuration error. - Return redacted OpenAI Realtime write error from writeErrCh instead of discarding. - Move TestOpenAIRealtimeStreamTranscribeErrorRedaction and TestDeepgramStreamTranscribeErrorRedaction next to their source files. Refs #710 --- .../dictation/transcriber_deepgram_test.go | 32 +++++++++++ .../dictation/transcriber_openai_realtime.go | 4 +- .../transcriber_openai_realtime_test.go | 41 ++++++++++++++ internal/dictation/transcriber_stream_test.go | 55 ------------------- 4 files changed, 76 insertions(+), 56 deletions(-) create mode 100644 internal/dictation/transcriber_deepgram_test.go create mode 100644 internal/dictation/transcriber_openai_realtime_test.go diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go new file mode 100644 index 000000000..c473ac5b9 --- /dev/null +++ b/internal/dictation/transcriber_deepgram_test.go @@ -0,0 +1,32 @@ +package dictation + +import ( + "context" + "strings" + "testing" + + "github.com/coder/websocket" +) + +func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // close immediately with an error that simulates connection issue with API key + c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key") + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-test-key") { + t.Errorf("API key leaked: %v", ferr) + } +} diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index 55d026f48..6c8d0f25c 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -75,7 +75,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, } if err := writeJSON(ctx, conn, sessionUpdate); err != nil { - return "", fmt.Errorf("configuring OpenAI Realtime session: %w", err) + return "", fmt.Errorf("configuring OpenAI Realtime session: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } writeErrCh := make(chan error, 1) @@ -156,6 +156,8 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks case werr := <-writeErrCh: if werr == nil { committed = true + } else { + return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(werr.Error(), o.cfg.APIKey)) } default: } diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go new file mode 100644 index 000000000..aa28d67db --- /dev/null +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -0,0 +1,41 @@ +package dictation + +import ( + "context" + "strings" + "testing" + + "github.com/coder/websocket" +) + +func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + defer c.Close(websocket.StatusNormalClosure, "") + for { + typ, _, err := c.Read(ctx) + if err != nil { + return + } + if typ != websocket.MessageText { + continue + } + _ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`)) + } + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 480) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-test-key") { + t.Errorf("API key leaked: %v", ferr) + } +} diff --git a/internal/dictation/transcriber_stream_test.go b/internal/dictation/transcriber_stream_test.go index 4b65dd5f0..8ece95306 100644 --- a/internal/dictation/transcriber_stream_test.go +++ b/internal/dictation/transcriber_stream_test.go @@ -229,58 +229,3 @@ func TestParseRealtimeEvent(t *testing.T) { t.Errorf("error parse: %+v", e) } } - -func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { - url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - defer c.Close(websocket.StatusNormalClosure, "") - for { - typ, _, err := c.Read(ctx) - if err != nil { - return - } - if typ != websocket.MessageText { - continue - } - _ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`)) - } - }) - - tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) - if err != nil { - t.Fatal(err) - } - - chunks := make(chan []byte, 1) - chunks <- make([]byte, 480) - close(chunks) - _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) - if ferr == nil { - t.Fatal("expected error") - } - if strings.Contains(ferr.Error(), "sk-test-key") { - t.Errorf("API key leaked: %v", ferr) - } -} - -func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { - url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - // close immediately with an error that simulates connection issue with API key - c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key") - }) - - tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) - if err != nil { - t.Fatal(err) - } - - chunks := make(chan []byte, 1) - chunks <- make([]byte, 320) - close(chunks) - _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) - if ferr == nil { - t.Fatal("expected error") - } - if strings.Contains(ferr.Error(), "sk-test-key") { - t.Errorf("API key leaked: %v", ferr) - } -} From 12248b69a4870ca82053e20b009bfafb0cf9e021 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:37:14 -0400 Subject: [PATCH 03/27] fixup(dictation): sync Deepgram redaction test on CloseStream before close Address CodeRabbit nitpick: the server now drains the client's audio frames and waits for CloseStream before rejecting the connection with an API-key-bearing policy-violation close reason. This ensures StreamTranscribe observes the close reason (and redacts it) instead of failing earlier on a write error. Refs #710 --- internal/dictation/transcriber_deepgram_test.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index c473ac5b9..e431937e3 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -10,7 +10,19 @@ import ( func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - // close immediately with an error that simulates connection issue with API key + // Drain the client's audio frames, then wait for CloseStream so the + // client has flushed its writes before we reject the connection. Closing + // immediately (before CloseStream) risks the client failing on a write + // error and never observing this API-key-bearing close reason. + for { + typ, data, err := c.Read(ctx) + if err != nil { + return + } + if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") { + break + } + } c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key") }) From 04418ab206ea93e1b3367487641e5792d0d57bb3 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:33:52 -0400 Subject: [PATCH 04/27] fix(dictation): preserve context.Canceled through streaming error redaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formatting the read error with %s dropped the unwrap chain, so an Esc abort of a Deepgram or OpenAI Realtime dictation no longer matched errors.Is(msg.err, context.Canceled) in the TUI and produced a spurious error toast on top of the cancellation notice. Return the cancellation sentinel itself before redacting — it carries no key — and keep the redacted flat string for genuine failures. Adds regression tests that cancel a live stream and assert the sentinel survives. Co-Authored-By: Claude Fable 5 --- internal/dictation/transcriber_deepgram.go | 7 ++++ .../dictation/transcriber_deepgram_test.go | 40 +++++++++++++++++++ .../dictation/transcriber_openai_realtime.go | 7 ++++ .../transcriber_openai_realtime_test.go | 40 +++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index 7f6cf7696..4e9d7b386 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -3,6 +3,7 @@ package dictation import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -101,6 +102,12 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha } default: } + // A user abort cancels the streaming context; the UI matches it + // with errors.Is(err, context.Canceled), so return the sentinel + // itself (it carries no key) instead of a flat redacted string. + if errors.Is(err, context.Canceled) { + return compose(), ctx.Err() + } return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } if typ != websocket.MessageText { diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index e431937e3..0df89aed8 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -2,7 +2,9 @@ package dictation import ( "context" + "errors" "strings" + "sync" "testing" "github.com/coder/websocket" @@ -42,3 +44,41 @@ func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { t.Errorf("API key leaked: %v", ferr) } } + +func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) { + firstFrame := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // Hold the connection open, never answering, so the client blocks in + // Read until its context is cancelled (the Esc-abort path). + var once sync.Once + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + once.Do(func() { close(firstFrame) }) + } + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + // The channel stays open: the session is live when the user aborts. + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + <-firstFrame + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + } +} diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index 6c8d0f25c..cc463ee3b 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -114,6 +115,12 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks } default: } + // A user abort cancels the streaming context; the UI matches it + // with errors.Is(err, context.Canceled), so return the sentinel + // itself (it carries no key) instead of a flat redacted string. + if errors.Is(err, context.Canceled) { + return compose(), ctx.Err() + } return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } if typ != websocket.MessageText { diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index aa28d67db..9ac682053 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -2,7 +2,9 @@ package dictation import ( "context" + "errors" "strings" + "sync" "testing" "github.com/coder/websocket" @@ -39,3 +41,41 @@ func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { t.Errorf("API key leaked: %v", ferr) } } + +func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) { + firstFrame := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // Hold the connection open, never answering, so the client blocks in + // Read until its context is cancelled (the Esc-abort path). + var once sync.Once + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + once.Do(func() { close(firstFrame) }) + } + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + chunks <- make([]byte, 480) + // The channel stays open: the session is live when the user aborts. + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + <-firstFrame + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + } +} From 7ae37e7b65748a92c3f7acbadc3028c9b4d004fa Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:49:32 -0400 Subject: [PATCH 05/27] fix(dictation): close remaining cancellation and redaction gaps Key cancellation detection on ctx.Err() instead of errors.Is(err, context.Canceled), which was racing against writer-goroutine transport errors that could overwrite the error value right before the check. Applied consistently across both transcriber implementations. Fix goroutine leaks and a possible hang in the cancellation regression tests. --- internal/dictation/transcriber_deepgram.go | 15 +++++++++++-- .../dictation/transcriber_deepgram_test.go | 15 ++++++++----- .../dictation/transcriber_openai_realtime.go | 22 +++++++++++++++++-- .../transcriber_openai_realtime_test.go | 15 ++++++++----- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index 4e9d7b386..2f25b386a 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -3,7 +3,6 @@ package dictation import ( "context" "encoding/json" - "errors" "fmt" "net/http" "net/url" @@ -62,6 +61,14 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}}, }) if err != nil { + // Preserve the context.Canceled sentinel (it carries no key) so an + // Esc-abort during dial still matches the UI's errors.Is check. Key + // on ctx.Err() itself rather than unwrapping err: a cancellation can + // surface as a plain transport error (e.g. "closed network + // connection") rather than context.Canceled directly. + if ctx.Err() != nil { + return "", ctx.Err() + } return "", fmt.Errorf("connecting to Deepgram: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } defer conn.CloseNow() @@ -105,7 +112,11 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha // A user abort cancels the streaming context; the UI matches it // with errors.Is(err, context.Canceled), so return the sentinel // itself (it carries no key) instead of a flat redacted string. - if errors.Is(err, context.Canceled) { + // Key on ctx.Err() rather than unwrapping err: the writeErrCh + // swap above can replace err with a plain transport error (e.g. + // "closed network connection") that doesn't itself unwrap to + // context.Canceled even though the cancellation is what caused it. + if ctx.Err() != nil { return compose(), ctx.Err() } return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index 0df89aed8..dd8b7c4ee 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -67,6 +67,7 @@ func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() chunks := make(chan []byte, 1) + defer close(chunks) chunks <- make([]byte, 320) // The channel stays open: the session is live when the user aborts. errCh := make(chan error, 1) @@ -75,10 +76,14 @@ func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) { errCh <- ferr }() - <-firstFrame - cancel() - ferr := <-errCh - if !errors.Is(ferr, context.Canceled) { - t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + select { + case <-firstFrame: + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index cc463ee3b..e64021326 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -4,7 +4,6 @@ import ( "context" "encoding/base64" "encoding/json" - "errors" "fmt" "net/http" "strings" @@ -61,6 +60,14 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, }) if err != nil { + // Preserve the context.Canceled sentinel (it carries no key) so an + // Esc-abort during dial still matches the UI's errors.Is check. Key + // on ctx.Err() itself rather than unwrapping err: a cancellation can + // surface as a plain transport error (e.g. "closed network + // connection") rather than context.Canceled directly. + if ctx.Err() != nil { + return "", ctx.Err() + } return "", fmt.Errorf("connecting to OpenAI Realtime: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } defer conn.CloseNow() @@ -76,6 +83,11 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, } if err := writeJSON(ctx, conn, sessionUpdate); err != nil { + // Same cancellation short-circuit as the dial above: an Esc-abort + // while the session update is in flight must stay context.Canceled. + if ctx.Err() != nil { + return "", ctx.Err() + } return "", fmt.Errorf("configuring OpenAI Realtime session: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } @@ -118,7 +130,11 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks // A user abort cancels the streaming context; the UI matches it // with errors.Is(err, context.Canceled), so return the sentinel // itself (it carries no key) instead of a flat redacted string. - if errors.Is(err, context.Canceled) { + // Key on ctx.Err() rather than unwrapping err: the writeErrCh + // swap above can replace err with a plain transport error (e.g. + // "closed network connection") that doesn't itself unwrap to + // context.Canceled even though the cancellation is what caused it. + if ctx.Err() != nil { return compose(), ctx.Err() } return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) @@ -163,6 +179,8 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks case werr := <-writeErrCh: if werr == nil { committed = true + } else if ctx.Err() != nil { + return compose(), ctx.Err() } else { return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(werr.Error(), o.cfg.APIKey)) } diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index 9ac682053..4f394da6b 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -64,6 +64,7 @@ func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() chunks := make(chan []byte, 1) + defer close(chunks) chunks <- make([]byte, 480) // The channel stays open: the session is live when the user aborts. errCh := make(chan error, 1) @@ -72,10 +73,14 @@ func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) { errCh <- ferr }() - <-firstFrame - cancel() - ferr := <-errCh - if !errors.Is(ferr, context.Canceled) { - t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + select { + case <-firstFrame: + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } From 3eda0ad69e60afa06b258b1df7a8dc5d310761e7 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:19:01 -0400 Subject: [PATCH 06/27] test(dictation): cover dial and startup cancel identity Add regression tests for Esc-abort during WebSocket dial and right after accept (session update / first write) so those redaction sites keep returning context.Canceled for the TUI errors.Is check. --- .../dictation/transcriber_deepgram_test.go | 63 ++++++++++ .../transcriber_openai_realtime_test.go | 113 ++++++++++++++++++ 2 files changed, 176 insertions(+) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index dd8b7c4ee..2d18b6b69 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -87,3 +87,66 @@ func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) { t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } + +// Esc can cancel while the WebSocket dial is still pending. The dial error is +// redacted with %s, so we must return ctx.Err() rather than a flat string. +func TestDeepgramStreamTranscribeDialCancelKeepsSentinel(t *testing.T) { + tr, err := NewDeepgramTranscriber(DeepgramConfig{ + APIKey: "sk-test-key", + BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled + }) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + chunks := make(chan []byte) + defer close(chunks) + + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr) + } +} + +// Esc right after accept can race the first write or the first Read; both +// setup/write redaction sites must still yield context.Canceled. +func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { + accepted := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + close(accepted) + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + } + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + defer close(chunks) + // No frames yet: the writer blocks on chunks or the reader blocks on Read. + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + select { + case <-accepted: + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed before accept: %v", ferr) + } +} diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index 4f394da6b..5b2d3b317 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -84,3 +84,116 @@ func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) { t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } + +// Esc can cancel while the WebSocket dial is still pending. The dial error is +// redacted with %s, so we must return ctx.Err() rather than a flat string. +func TestOpenAIRealtimeStreamTranscribeDialCancelKeepsSentinel(t *testing.T) { + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{ + APIKey: "sk-test-key", + BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled + }) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + chunks := make(chan []byte) + defer close(chunks) + + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr) + } +} + +// Esc right after accept can hit the session-update write or the first Read; +// both redaction sites must still yield context.Canceled. +func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { + accepted := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + close(accepted) + // Do not read: leave the client in session-update write or first Read. + <-ctx.Done() + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + defer close(chunks) + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + select { + case <-accepted: + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed before accept: %v", ferr) + } +} + +// After a server event the client selects writeErrCh. Cancel while the writer +// is blocked on more chunks so that path observes a cancelled write and must +// still return context.Canceled rather than a redacted flat string. +func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { + deltaSent := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // Session update, then a delta. Leave the socket open so the client + // stays in the event loop rather than failing on Read first. + if _, _, err := c.Read(ctx); err != nil { + return + } + _ = c.Write(ctx, websocket.MessageText, []byte( + `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, + )) + close(deltaSent) + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + } + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // One frame so the writer can progress past session setup; channel stays + // open so a later Write is still pending when we cancel. + chunks := make(chan []byte, 1) + defer close(chunks) + chunks <- make([]byte, 480) + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + select { + case <-deltaSent: + // Give the client a moment to process the delta and re-enter Read or + // select writeErrCh, then cancel so the blocked writer fails. + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("write-path cancel lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed before delta: %v", ferr) + } +} From 9826b8fa3c25c9e0f8402188888706a6bcff802e Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:58:36 -0400 Subject: [PATCH 07/27] fix(dictation): prefer cancel over a racing OpenAI realtime error Esc can cancel after conn.Read already has an error frame. Check ctx.Err() after partial callbacks and on the realtimeError path so the stream still returns context.Canceled instead of a redacted failure notice. Refs Gitlawb/zero#710 --- .../dictation/transcriber_openai_realtime.go | 17 ++++++++ .../transcriber_openai_realtime_test.go | 42 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index e64021326..64ee56f3a 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -149,6 +149,12 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks if onPartial != nil { onPartial(compose(), false) } + // onPartial may deliver the partial that makes the TUI cancel + // (Esc). Observe that before the next Read, which can already + // have a racing OpenAI error frame buffered. + if ctx.Err() != nil { + return compose(), ctx.Err() + } case realtimeCompleted: // A completed item replaces the in-progress delta buffer with the // server's authoritative transcript for that segment. @@ -162,6 +168,9 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks if onPartial != nil { onPartial(compose(), true) } + if ctx.Err() != nil { + return compose(), ctx.Err() + } // Once we've committed the buffer (user stopped) and the server has // returned a completed transcription, the utterance is done. if committed { @@ -171,6 +180,14 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks case realtimeCommitted: committed = true case realtimeError: + // Esc can race an incoming error event: conn.Read may already have + // the OpenAI error in hand by the time cancellation lands. Key on + // ctx.Err() first, same as every other return point in this loop, + // so a cancel that wins the race still surfaces as context.Canceled + // instead of a spurious redacted failure alongside it. + if ctx.Err() != nil { + return compose(), ctx.Err() + } return compose(), fmt.Errorf("OpenAI Realtime error: %s", providerio.Redact(evt.text, o.cfg.APIKey)) } // The writer signals commit completion out of band; observe it so a diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index 5b2d3b317..6b9075c3c 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -197,3 +197,45 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { t.Fatalf("StreamTranscribe failed before delta: %v", ferr) } } + +// jatmn (review, PR #710): Esc can race an incoming OpenAI error event. +// conn.Read may already have the error frame in hand by the time the +// cancellation lands. The server fires a delta immediately followed by +// the error, back to back. Cancel from inside the delta callback (same +// path the TUI uses when a partial triggers Esc handling) so the cancel +// is observed before the next event is processed. The returned error +// must still be context.Canceled, not the redacted OpenAI error. +func TestOpenAIRealtimeStreamTranscribeErrorRaceKeepsSentinel(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + if _, _, err := c.Read(ctx); err != nil { + return + } + _ = c.Write(ctx, websocket.MessageText, []byte( + `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, + )) + _ = c.Write(ctx, websocket.MessageText, []byte( + `{"type":"error","error":{"message":"invalid API key sk-test-key"}}`, + )) + <-ctx.Done() + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + defer close(chunks) + chunks <- make([]byte, 480) + var once sync.Once + _, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) { + // Cancel synchronously from the partial callback, before the + // stream loop continues to the already-buffered error frame. + once.Do(cancel) + }) + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("StreamTranscribe error = %v, want context.Canceled (cancel must win over a racing OpenAI error event)", ferr) + } +} From 56c6a738245b63f647dff5fc51145fbb079af314 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:57:57 -0400 Subject: [PATCH 08/27] fix(dictation): prevent auto-submit on cancelled realtime race handleDictationTranscribed suppressed context.Canceled but then fell through to the msg.submit branch. A delta -> Esc -> already-buffered realtime error race returns a nonempty transcript alongside context.Canceled, so with stt.autoSubmit on, Esc could submit the composer's restored pre-existing text instead of doing nothing. Treat a canceled result as terminal before the auto-submit path is reached, and add a TUI-level test covering the race. --- internal/tui/dictation.go | 12 ++++++++++- internal/tui/dictation_test.go | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index 3d96d82f6..4e6c5221b 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -392,7 +392,17 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode } m.dictation.reset() - if msg.err != nil && !errors.Is(msg.err, context.Canceled) { + if errors.Is(msg.err, context.Canceled) { + // cancelDictation already discarded the live region, reset the + // session, and posted "Dictation cancelled." A streaming transcriber + // can still race a buffered event past that cancel and report back a + // nonempty compose()+context.Canceled; treat that as terminal here, + // before the auto-submit branch below, so Esc can never fall through + // to msg.submit and fire the composer's restored pre-existing text. + return m, nil + } + + if msg.err != nil { // 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. diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index 5101cbd64..c6a226407 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -136,6 +136,44 @@ func TestDictationStreamingPartialReplacesRegion(t *testing.T) { } } +func TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) { + // Esc can race an already-buffered realtime event: cancelDictation discards + // the live region and resets state synchronously, but the streaming + // goroutine's dictationTranscribedMsg (a nonempty compose() alongside + // context.Canceled) can still arrive afterward. With stt.autoSubmit on, + // that must never fall through to msg.submit and fire the composer's + // restored pre-existing text. + m := model{} + m.setComposerState(composerState{text: "existing prompt", cursor: len("existing prompt")}) + m.dictation.phase = dictRecording + m.dictation.streaming = true + + m = m.handleDictationPartial(sttPartialMsg{text: "half-formed transcript"}) + if m.composer.text != "existing prompt half-formed transcript" { + t.Fatalf("partial did not render into composer: %q", m.composer.text) + } + + m, _ = m.cancelDictation() + if m.composer.text != "existing prompt" { + t.Fatalf("cancel should restore the pre-existing composer text, got %q", m.composer.text) + } + + // The already-buffered event's message arrives after the cancel above. + next, cmd := m.handleDictationTranscribed(dictationTranscribedMsg{ + text: "half-formed transcript", + err: context.Canceled, + submit: true, + streaming: true, + }) + got := next.(model) + if got.composer.text != "existing prompt" { + t.Errorf("a raced cancellation must not auto-submit the restored composer text, got %q", got.composer.text) + } + if cmd != nil { + t.Error("a raced cancellation must not return a submit command") + } +} + func TestDictationCommitKeepsStreamedText(t *testing.T) { m := model{} m.setComposerState(composerState{text: "", cursor: 0}) From 84f21116623fbd1c1c620e0cf199f8ddb625e660 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:57:19 -0400 Subject: [PATCH 09/27] test(dictation): exercise writeErrCh cancellation branch in realtime transcriber --- .../transcriber_openai_realtime_test.go | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index 6b9075c3c..c15518245 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -150,8 +150,7 @@ func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { deltaSent := make(chan struct{}) url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - // Session update, then a delta. Leave the socket open so the client - // stays in the event loop rather than failing on Read first. + // Session update, then a delta. if _, _, err := c.Read(ctx); err != nil { return } @@ -159,11 +158,8 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, )) close(deltaSent) - for { - if _, _, err := c.Read(ctx); err != nil { - return - } - } + // Close socket so the next client Write fails and sends to writeErrCh + _ = c.Close(websocket.StatusAbnormalClosure, "closed") }) tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) @@ -173,9 +169,7 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // One frame so the writer can progress past session setup; channel stays - // open so a later Write is still pending when we cancel. - chunks := make(chan []byte, 1) + chunks := make(chan []byte, 2) defer close(chunks) chunks <- make([]byte, 480) errCh := make(chan error, 1) @@ -186,8 +180,9 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { select { case <-deltaSent: - // Give the client a moment to process the delta and re-enter Read or - // select writeErrCh, then cancel so the blocked writer fails. + // Push another chunk so writer tries to write to the closed socket + chunks <- make([]byte, 480) + // Cancel context so writeErrCh observes ctx.Err() != nil cancel() ferr := <-errCh if !errors.Is(ferr, context.Canceled) { From cffc46db78dea2c45c7411086a6c18588cc17643 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:03:14 -0400 Subject: [PATCH 10/27] fix(dictation): add custom key header redaction and single-pass redaction tests --- .../dictation/transcriber_deepgram_test.go | 44 +++++++++++++++++++ internal/providers/providerio/providerio.go | 11 ++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index 2d18b6b69..73998555d 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -150,3 +150,47 @@ func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { t.Fatalf("StreamTranscribe failed before accept: %v", ferr) } } + +func TestDeepgramCustomHeaderErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + c.Close(websocket.StatusPolicyViolation, "X-Api-Key: sk-custom-secret-key-1234567890 failed") + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-custom-secret-key-1234567890", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-custom-secret-key-1234567890") { + t.Errorf("Custom header API key leaked: %v", ferr) + } +} + +func TestDeepgramSinglePassRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + c.Close(websocket.StatusPolicyViolation, "error with sk-key1234567890abcdef1234") + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-key1234567890abcdef1234", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "[REDACTED:[REDACTED") { + t.Errorf("nested redaction marker created: %v", ferr) + } +} diff --git a/internal/providers/providerio/providerio.go b/internal/providers/providerio/providerio.go index 374269f65..ab967719b 100644 --- a/internal/providers/providerio/providerio.go +++ b/internal/providers/providerio/providerio.go @@ -429,16 +429,17 @@ func looksLikeToken(w string) bool { // Redact removes known API-key and bearer-token forms from provider messages. func Redact(message string, secrets ...string) string { for _, secret := range secrets { - if secret != "" { + if secret != "" && !strings.HasPrefix(secret, "[REDACTED") { message = strings.ReplaceAll(message, secret, "[REDACTED]") } } words := strings.Fields(message) for index := 0; index < len(words)-1; index++ { - // Only redact the word after "Bearer" when it is actually token-shaped, so the - // provider's own help text ("use Bearer authentication", "Bearer token") is no - // longer corrupted into "authorization [REDACTED]". (AUDIT-H7) - if strings.EqualFold(strings.TrimRight(words[index], ":"), "Bearer") && looksLikeToken(words[index+1]) { + hdr := strings.EqualFold(strings.TrimRight(words[index], ":"), "Bearer") || + strings.EqualFold(strings.TrimRight(words[index], ":"), "Token") || + strings.EqualFold(strings.TrimRight(words[index], ":"), "X-Api-Key") || + strings.EqualFold(strings.TrimRight(words[index], ":"), "api-key") + if hdr && !strings.HasPrefix(words[index+1], "[REDACTED") && looksLikeToken(words[index+1]) { words[index+1] = "[REDACTED]" } } From cb9ba5ab8ce9e44edba5ea21ec3dd952fdcea956 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:10:05 -0400 Subject: [PATCH 11/27] fix(dictation): track dictation sessionID to drop stale completions after Esc and synchronize write cancellation test --- .../dictation/transcriber_deepgram_test.go | 18 ++++++++++++ .../transcriber_openai_realtime_test.go | 27 ++++++++--------- internal/tui/dictation.go | 14 ++++++--- internal/tui/dictation_stream.go | 13 ++++++--- internal/tui/dictation_test.go | 29 +++++++++++++++++++ 5 files changed, 78 insertions(+), 23 deletions(-) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index 73998555d..09bc3c03c 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -153,6 +153,15 @@ func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { func TestDeepgramCustomHeaderErrorRedaction(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + for { + typ, data, err := c.Read(ctx) + if err != nil { + return + } + if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") { + break + } + } c.Close(websocket.StatusPolicyViolation, "X-Api-Key: sk-custom-secret-key-1234567890 failed") }) @@ -175,6 +184,15 @@ func TestDeepgramCustomHeaderErrorRedaction(t *testing.T) { func TestDeepgramSinglePassRedaction(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + for { + typ, data, err := c.Read(ctx) + if err != nil { + return + } + if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") { + break + } + } c.Close(websocket.StatusPolicyViolation, "error with sk-key1234567890abcdef1234") }) diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index c15518245..c46f91287 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -148,18 +148,17 @@ func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) // is blocked on more chunks so that path observes a cancelled write and must // still return context.Canceled rather than a redacted flat string. func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { - deltaSent := make(chan struct{}) + sessionReceived := make(chan struct{}) url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - // Session update, then a delta. if _, _, err := c.Read(ctx); err != nil { return } - _ = c.Write(ctx, websocket.MessageText, []byte( - `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, - )) - close(deltaSent) - // Close socket so the next client Write fails and sends to writeErrCh - _ = c.Close(websocket.StatusAbnormalClosure, "closed") + close(sessionReceived) + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + } }) tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) @@ -169,9 +168,10 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - chunks := make(chan []byte, 2) - defer close(chunks) + chunks := make(chan []byte, 1) chunks <- make([]byte, 480) + defer close(chunks) + errCh := make(chan error, 1) go func() { _, ferr := tr.StreamTranscribe(ctx, chunks, nil) @@ -179,17 +179,14 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { }() select { - case <-deltaSent: - // Push another chunk so writer tries to write to the closed socket - chunks <- make([]byte, 480) - // Cancel context so writeErrCh observes ctx.Err() != nil + case <-sessionReceived: cancel() ferr := <-errCh if !errors.Is(ferr, context.Canceled) { t.Fatalf("write-path cancel lost the context.Canceled sentinel: %v", ferr) } case ferr := <-errCh: - t.Fatalf("StreamTranscribe failed before delta: %v", ferr) + t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index 4e6c5221b..b995ceac7 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -61,6 +61,7 @@ type dictationController struct { browseVariants []dictation.ModelVariant // in-flight session state + sessionID int64 streaming bool recorder Recorder transcriber Transcriber @@ -108,6 +109,7 @@ type dictationStartedMsg struct { // dictationTranscribedMsg carries the final transcript of a batch (or a // completed streaming) recording. type dictationTranscribedMsg struct { + sessionID int64 text string err error submit bool @@ -276,7 +278,7 @@ func (m model) stopDictation() (model, tea.Cmd) { } 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()) + return m, transcribeBatchCmd(m.dictation.sessionID, m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled()) } // cancelDictation aborts an in-flight recording without transcribing. Bound to @@ -311,6 +313,7 @@ func (d dictationController) maxDuration() time.Duration { } func (d *dictationController) reset() { + d.sessionID++ d.phase = dictIdle d.recorder = nil d.transcriber = nil @@ -335,17 +338,17 @@ func startBatchRecordingCmd(rec Recorder) tea.Cmd { // 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 { +func transcribeBatchCmd(sessionID int64, 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} + return dictationTranscribedMsg{sessionID: sessionID, err: err} } text, err := transcriber.Transcribe(ctx, audio) - return dictationTranscribedMsg{text: text, err: err, submit: submit} + return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit} } } @@ -375,6 +378,9 @@ func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd) // 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) { + if msg.sessionID != 0 && msg.sessionID != m.dictation.sessionID { + return m, nil + } streaming := m.dictation.streaming || msg.streaming m = m.commitDictationRegion() // A streaming session can end via a transcriber error (not just a user stop), so diff --git a/internal/tui/dictation_stream.go b/internal/tui/dictation_stream.go index 90b74c321..85e73814c 100644 --- a/internal/tui/dictation_stream.go +++ b/internal/tui/dictation_stream.go @@ -14,8 +14,9 @@ import ( // 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 + sessionID int64 + text string + final bool } // startStreamingDictation begins continuous capture and launches the streaming @@ -38,6 +39,7 @@ func (m model) startStreamingDictation() (model, tea.Cmd) { } transcriber := m.dictation.transcriber submit := m.dictation.cfg.AutoSubmitEnabled() + sessionID := m.dictation.sessionID // 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 @@ -63,11 +65,11 @@ func (m model) startStreamingDictation() (model, tea.Cmd) { streamCmd := func() tea.Msg { onPartial := func(text string, final bool) { if sink != nil { - sink(sttPartialMsg{text: text, final: final}) + sink(sttPartialMsg{sessionID: sessionID, text: text, final: final}) } } text, err := transcriber.StreamTranscribe(ctx, tapped, onPartial) - return dictationTranscribedMsg{text: text, err: err, submit: submit, streaming: true} + return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit, streaming: true} } // Streaming drives the waveform from real levels (no synthetic tick needed). return m, streamCmd @@ -77,6 +79,9 @@ func (m model) startStreamingDictation() (model, tea.Cmd) { // 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 { + if msg.sessionID != 0 && msg.sessionID != m.dictation.sessionID { + return m + } // Ignore stragglers that arrive after the session ended (cancel/final). if m.dictation.phase != dictRecording && m.dictation.phase != dictTranscribing { return m diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index c6a226407..fcfabea9e 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -340,3 +340,32 @@ func TestCurrentModelLabel(t *testing.T) { t.Errorf("no-model label = %q", got) } } + +func TestStaleDictationCompletionIgnoredAfterCancel(t *testing.T) { + m := model{} + m.setComposerState(composerState{text: "original composer text", cursor: 22}) + m.dictation.sessionID = 1 + m.dictation.phase = dictRecording + + m, _ = m.cancelDictation() + + if m.composer.text != "original composer text" { + t.Fatalf("composer text = %q, want 'original composer text'", m.composer.text) + } + + staleMsg := dictationTranscribedMsg{ + sessionID: 1, + text: "stale text that should be ignored", + submit: true, + streaming: true, + } + afterStale, cmd := m.handleDictationTranscribed(staleMsg) + got := afterStale.(model) + + if got.composer.text != "original composer text" { + t.Fatalf("composer text changed to %q after stale completion", got.composer.text) + } + if cmd != nil { + t.Fatalf("expected no command for stale completion, got %v", cmd) + } +} From 2814b356cde6b572be93c5eba1973faddea5871a Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:16:31 -0400 Subject: [PATCH 12/27] fix(dictation): initialize non-zero session ID and filter stale session 0 messages Ensure the first dictation session in a process gets stale-message protection by initializing sessionID to 1 in newDictationController and removing msg.sessionID != 0 exemptions in handleDictationTranscribed and handleDictationPartial. Clean up unreachable redaction guards in providerio. Refs #710 --- internal/providers/providerio/providerio.go | 4 +- internal/tui/dictation.go | 3 +- internal/tui/dictation_stream.go | 2 +- internal/tui/dictation_test.go | 51 +++++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/internal/providers/providerio/providerio.go b/internal/providers/providerio/providerio.go index ab967719b..ea94d7de6 100644 --- a/internal/providers/providerio/providerio.go +++ b/internal/providers/providerio/providerio.go @@ -429,7 +429,7 @@ func looksLikeToken(w string) bool { // Redact removes known API-key and bearer-token forms from provider messages. func Redact(message string, secrets ...string) string { for _, secret := range secrets { - if secret != "" && !strings.HasPrefix(secret, "[REDACTED") { + if secret != "" { message = strings.ReplaceAll(message, secret, "[REDACTED]") } } @@ -439,7 +439,7 @@ func Redact(message string, secrets ...string) string { strings.EqualFold(strings.TrimRight(words[index], ":"), "Token") || strings.EqualFold(strings.TrimRight(words[index], ":"), "X-Api-Key") || strings.EqualFold(strings.TrimRight(words[index], ":"), "api-key") - if hdr && !strings.HasPrefix(words[index+1], "[REDACTED") && looksLikeToken(words[index+1]) { + if hdr && looksLikeToken(words[index+1]) { words[index+1] = "[REDACTED]" } } diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index b995ceac7..071d1f9bd 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -127,6 +127,7 @@ func newDictationController(opts Options) dictationController { platform: dictation.DetectPlatform(), downloadRoot: opts.STTDownloadRoot, userConfigPath: opts.UserConfigPath, + sessionID: 1, } } @@ -378,7 +379,7 @@ func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd) // 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) { - if msg.sessionID != 0 && msg.sessionID != m.dictation.sessionID { + if msg.sessionID != m.dictation.sessionID { return m, nil } streaming := m.dictation.streaming || msg.streaming diff --git a/internal/tui/dictation_stream.go b/internal/tui/dictation_stream.go index 85e73814c..5e994eedd 100644 --- a/internal/tui/dictation_stream.go +++ b/internal/tui/dictation_stream.go @@ -79,7 +79,7 @@ func (m model) startStreamingDictation() (model, tea.Cmd) { // 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 { - if msg.sessionID != 0 && msg.sessionID != m.dictation.sessionID { + if msg.sessionID != m.dictation.sessionID { return m } // Ignore stragglers that arrive after the session ended (cancel/final). diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index fcfabea9e..a9d839288 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -353,6 +353,16 @@ func TestStaleDictationCompletionIgnoredAfterCancel(t *testing.T) { t.Fatalf("composer text = %q, want 'original composer text'", m.composer.text) } + // Stale partial message from session 1 must be ignored + stalePartial := sttPartialMsg{ + sessionID: 1, + text: "stale partial that should be ignored", + } + mPartial := m.handleDictationPartial(stalePartial) + if mPartial.composer.text != "original composer text" { + t.Fatalf("composer text changed to %q after stale partial", mPartial.composer.text) + } + staleMsg := dictationTranscribedMsg{ sessionID: 1, text: "stale text that should be ignored", @@ -369,3 +379,44 @@ func TestStaleDictationCompletionIgnoredAfterCancel(t *testing.T) { t.Fatalf("expected no command for stale completion, got %v", cmd) } } + +func TestStaleDictationSessionZeroIgnoredAfterCancel(t *testing.T) { + m := model{} + m.setComposerState(composerState{text: "initial composer text", cursor: 21}) + m.dictation.sessionID = 0 + m.dictation.phase = dictRecording + + m, _ = m.cancelDictation() + + // Session 0 was reset to session 1 + if m.dictation.sessionID != 1 { + t.Fatalf("dictation.sessionID = %d after cancel, want 1", m.dictation.sessionID) + } + + // Stale partial from session 0 must be ignored + stalePartial := sttPartialMsg{ + sessionID: 0, + text: "stale partial from session 0", + } + mPartial := m.handleDictationPartial(stalePartial) + if mPartial.composer.text != "initial composer text" { + t.Fatalf("composer text changed to %q after stale session 0 partial", mPartial.composer.text) + } + + // Stale completion from session 0 must be ignored + staleMsg := dictationTranscribedMsg{ + sessionID: 0, + text: "stale completion from session 0", + submit: true, + streaming: true, + } + afterStale, cmd := m.handleDictationTranscribed(staleMsg) + got := afterStale.(model) + + if got.composer.text != "initial composer text" { + t.Fatalf("composer text changed to %q after stale session 0 completion", got.composer.text) + } + if cmd != nil { + t.Fatalf("expected no command for stale session 0 completion, got %v", cmd) + } +} From fdbf8bac287afcf5a7bab150ffb1bea39315fa65 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:05:06 -0400 Subject: [PATCH 13/27] fix(dictation): redact API keys from streaming transcriber errors Streaming dictation (Deepgram, OpenAI Realtime) injected API keys into websocket URLs/headers and echoed unredacted transport errors and server-side error payloads. Wrap those error endpoints with providerio.Redact and switch the connect-error format specifier from %w to %s so the raw error cannot leak via errors.Unwrap(). Adds regression tests asserting the API key is not present in the returned stream error for both transcribers. Co-authored-by: euxaristia <25621994+euxaristia@users.noreply.github.com> --- internal/dictation/transcriber_deepgram.go | 5 +- .../dictation/transcriber_openai_realtime.go | 7 ++- internal/dictation/transcriber_stream_test.go | 55 +++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index d4a69ce7f..7f6cf7696 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/Gitlawb/zero/internal/providers/providerio" "github.com/coder/websocket" ) @@ -60,7 +61,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}}, }) if err != nil { - return "", fmt.Errorf("connecting to Deepgram: %w", err) + return "", fmt.Errorf("connecting to Deepgram: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } defer conn.CloseNow() @@ -100,7 +101,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha } default: } - return compose(), fmt.Errorf("Deepgram stream error: %w", err) //nolint:staticcheck // Preserve established user-facing error text. + return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } if typ != websocket.MessageText { continue diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index 590cbc8b4..55d026f48 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + "github.com/Gitlawb/zero/internal/providers/providerio" "github.com/coder/websocket" ) @@ -59,7 +60,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, }) if err != nil { - return "", fmt.Errorf("connecting to OpenAI Realtime: %w", err) + return "", fmt.Errorf("connecting to OpenAI Realtime: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } defer conn.CloseNow() @@ -113,7 +114,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks } default: } - return compose(), fmt.Errorf("OpenAI Realtime stream error: %w", err) + return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } if typ != websocket.MessageText { continue @@ -147,7 +148,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks case realtimeCommitted: committed = true case realtimeError: - return compose(), fmt.Errorf("OpenAI Realtime error: %s", evt.text) + return compose(), fmt.Errorf("OpenAI Realtime error: %s", providerio.Redact(evt.text, o.cfg.APIKey)) } // The writer signals commit completion out of band; observe it so a // stop with no further audio still flips `committed`. diff --git a/internal/dictation/transcriber_stream_test.go b/internal/dictation/transcriber_stream_test.go index 8ece95306..4b65dd5f0 100644 --- a/internal/dictation/transcriber_stream_test.go +++ b/internal/dictation/transcriber_stream_test.go @@ -229,3 +229,58 @@ func TestParseRealtimeEvent(t *testing.T) { t.Errorf("error parse: %+v", e) } } + +func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + defer c.Close(websocket.StatusNormalClosure, "") + for { + typ, _, err := c.Read(ctx) + if err != nil { + return + } + if typ != websocket.MessageText { + continue + } + _ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`)) + } + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 480) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-test-key") { + t.Errorf("API key leaked: %v", ferr) + } +} + +func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // close immediately with an error that simulates connection issue with API key + c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key") + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-test-key") { + t.Errorf("API key leaked: %v", ferr) + } +} From c47f0a4081c1c27f96f958e5cd1e6efc851de555 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:28:44 -0400 Subject: [PATCH 14/27] fixup(dictation): address review: redact session/write errors, move redaction tests - Redact API key in OpenAI Realtime session configuration error. - Return redacted OpenAI Realtime write error from writeErrCh instead of discarding. - Move TestOpenAIRealtimeStreamTranscribeErrorRedaction and TestDeepgramStreamTranscribeErrorRedaction next to their source files. Refs #710 --- .../dictation/transcriber_deepgram_test.go | 32 +++++++++++ .../dictation/transcriber_openai_realtime.go | 4 +- .../transcriber_openai_realtime_test.go | 41 ++++++++++++++ internal/dictation/transcriber_stream_test.go | 55 ------------------- 4 files changed, 76 insertions(+), 56 deletions(-) create mode 100644 internal/dictation/transcriber_deepgram_test.go create mode 100644 internal/dictation/transcriber_openai_realtime_test.go diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go new file mode 100644 index 000000000..c473ac5b9 --- /dev/null +++ b/internal/dictation/transcriber_deepgram_test.go @@ -0,0 +1,32 @@ +package dictation + +import ( + "context" + "strings" + "testing" + + "github.com/coder/websocket" +) + +func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // close immediately with an error that simulates connection issue with API key + c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key") + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-test-key") { + t.Errorf("API key leaked: %v", ferr) + } +} diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index 55d026f48..6c8d0f25c 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -75,7 +75,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, } if err := writeJSON(ctx, conn, sessionUpdate); err != nil { - return "", fmt.Errorf("configuring OpenAI Realtime session: %w", err) + return "", fmt.Errorf("configuring OpenAI Realtime session: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } writeErrCh := make(chan error, 1) @@ -156,6 +156,8 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks case werr := <-writeErrCh: if werr == nil { committed = true + } else { + return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(werr.Error(), o.cfg.APIKey)) } default: } diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go new file mode 100644 index 000000000..aa28d67db --- /dev/null +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -0,0 +1,41 @@ +package dictation + +import ( + "context" + "strings" + "testing" + + "github.com/coder/websocket" +) + +func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + defer c.Close(websocket.StatusNormalClosure, "") + for { + typ, _, err := c.Read(ctx) + if err != nil { + return + } + if typ != websocket.MessageText { + continue + } + _ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`)) + } + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 480) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-test-key") { + t.Errorf("API key leaked: %v", ferr) + } +} diff --git a/internal/dictation/transcriber_stream_test.go b/internal/dictation/transcriber_stream_test.go index 4b65dd5f0..8ece95306 100644 --- a/internal/dictation/transcriber_stream_test.go +++ b/internal/dictation/transcriber_stream_test.go @@ -229,58 +229,3 @@ func TestParseRealtimeEvent(t *testing.T) { t.Errorf("error parse: %+v", e) } } - -func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { - url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - defer c.Close(websocket.StatusNormalClosure, "") - for { - typ, _, err := c.Read(ctx) - if err != nil { - return - } - if typ != websocket.MessageText { - continue - } - _ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`)) - } - }) - - tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) - if err != nil { - t.Fatal(err) - } - - chunks := make(chan []byte, 1) - chunks <- make([]byte, 480) - close(chunks) - _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) - if ferr == nil { - t.Fatal("expected error") - } - if strings.Contains(ferr.Error(), "sk-test-key") { - t.Errorf("API key leaked: %v", ferr) - } -} - -func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { - url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - // close immediately with an error that simulates connection issue with API key - c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key") - }) - - tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) - if err != nil { - t.Fatal(err) - } - - chunks := make(chan []byte, 1) - chunks <- make([]byte, 320) - close(chunks) - _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) - if ferr == nil { - t.Fatal("expected error") - } - if strings.Contains(ferr.Error(), "sk-test-key") { - t.Errorf("API key leaked: %v", ferr) - } -} From f2413127d8bea4384137c3c656680f0c0671b6bb Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:37:14 -0400 Subject: [PATCH 15/27] fixup(dictation): sync Deepgram redaction test on CloseStream before close Address CodeRabbit nitpick: the server now drains the client's audio frames and waits for CloseStream before rejecting the connection with an API-key-bearing policy-violation close reason. This ensures StreamTranscribe observes the close reason (and redacts it) instead of failing earlier on a write error. Refs #710 --- internal/dictation/transcriber_deepgram_test.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index c473ac5b9..e431937e3 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -10,7 +10,19 @@ import ( func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - // close immediately with an error that simulates connection issue with API key + // Drain the client's audio frames, then wait for CloseStream so the + // client has flushed its writes before we reject the connection. Closing + // immediately (before CloseStream) risks the client failing on a write + // error and never observing this API-key-bearing close reason. + for { + typ, data, err := c.Read(ctx) + if err != nil { + return + } + if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") { + break + } + } c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key") }) From d039c736fe9ee0558565af3b58525d9d3dd3d877 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:33:52 -0400 Subject: [PATCH 16/27] fix(dictation): preserve context.Canceled through streaming error redaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formatting the read error with %s dropped the unwrap chain, so an Esc abort of a Deepgram or OpenAI Realtime dictation no longer matched errors.Is(msg.err, context.Canceled) in the TUI and produced a spurious error toast on top of the cancellation notice. Return the cancellation sentinel itself before redacting — it carries no key — and keep the redacted flat string for genuine failures. Adds regression tests that cancel a live stream and assert the sentinel survives. Co-Authored-By: Claude Fable 5 --- internal/dictation/transcriber_deepgram.go | 7 ++++ .../dictation/transcriber_deepgram_test.go | 40 +++++++++++++++++++ .../dictation/transcriber_openai_realtime.go | 7 ++++ .../transcriber_openai_realtime_test.go | 40 +++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index 7f6cf7696..4e9d7b386 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -3,6 +3,7 @@ package dictation import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -101,6 +102,12 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha } default: } + // A user abort cancels the streaming context; the UI matches it + // with errors.Is(err, context.Canceled), so return the sentinel + // itself (it carries no key) instead of a flat redacted string. + if errors.Is(err, context.Canceled) { + return compose(), ctx.Err() + } return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } if typ != websocket.MessageText { diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index e431937e3..0df89aed8 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -2,7 +2,9 @@ package dictation import ( "context" + "errors" "strings" + "sync" "testing" "github.com/coder/websocket" @@ -42,3 +44,41 @@ func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { t.Errorf("API key leaked: %v", ferr) } } + +func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) { + firstFrame := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // Hold the connection open, never answering, so the client blocks in + // Read until its context is cancelled (the Esc-abort path). + var once sync.Once + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + once.Do(func() { close(firstFrame) }) + } + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + // The channel stays open: the session is live when the user aborts. + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + <-firstFrame + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + } +} diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index 6c8d0f25c..cc463ee3b 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -114,6 +115,12 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks } default: } + // A user abort cancels the streaming context; the UI matches it + // with errors.Is(err, context.Canceled), so return the sentinel + // itself (it carries no key) instead of a flat redacted string. + if errors.Is(err, context.Canceled) { + return compose(), ctx.Err() + } return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } if typ != websocket.MessageText { diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index aa28d67db..9ac682053 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -2,7 +2,9 @@ package dictation import ( "context" + "errors" "strings" + "sync" "testing" "github.com/coder/websocket" @@ -39,3 +41,41 @@ func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { t.Errorf("API key leaked: %v", ferr) } } + +func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) { + firstFrame := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // Hold the connection open, never answering, so the client blocks in + // Read until its context is cancelled (the Esc-abort path). + var once sync.Once + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + once.Do(func() { close(firstFrame) }) + } + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + chunks <- make([]byte, 480) + // The channel stays open: the session is live when the user aborts. + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + <-firstFrame + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + } +} From 7453dab9df37e3b47008bc99c1ad0cc1919dc480 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:49:32 -0400 Subject: [PATCH 17/27] fix(dictation): close remaining cancellation and redaction gaps Key cancellation detection on ctx.Err() instead of errors.Is(err, context.Canceled), which was racing against writer-goroutine transport errors that could overwrite the error value right before the check. Applied consistently across both transcriber implementations. Fix goroutine leaks and a possible hang in the cancellation regression tests. --- internal/dictation/transcriber_deepgram.go | 15 +++++++++++-- .../dictation/transcriber_deepgram_test.go | 15 ++++++++----- .../dictation/transcriber_openai_realtime.go | 22 +++++++++++++++++-- .../transcriber_openai_realtime_test.go | 15 ++++++++----- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index 4e9d7b386..2f25b386a 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -3,7 +3,6 @@ package dictation import ( "context" "encoding/json" - "errors" "fmt" "net/http" "net/url" @@ -62,6 +61,14 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}}, }) if err != nil { + // Preserve the context.Canceled sentinel (it carries no key) so an + // Esc-abort during dial still matches the UI's errors.Is check. Key + // on ctx.Err() itself rather than unwrapping err: a cancellation can + // surface as a plain transport error (e.g. "closed network + // connection") rather than context.Canceled directly. + if ctx.Err() != nil { + return "", ctx.Err() + } return "", fmt.Errorf("connecting to Deepgram: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } defer conn.CloseNow() @@ -105,7 +112,11 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha // A user abort cancels the streaming context; the UI matches it // with errors.Is(err, context.Canceled), so return the sentinel // itself (it carries no key) instead of a flat redacted string. - if errors.Is(err, context.Canceled) { + // Key on ctx.Err() rather than unwrapping err: the writeErrCh + // swap above can replace err with a plain transport error (e.g. + // "closed network connection") that doesn't itself unwrap to + // context.Canceled even though the cancellation is what caused it. + if ctx.Err() != nil { return compose(), ctx.Err() } return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index 0df89aed8..dd8b7c4ee 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -67,6 +67,7 @@ func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() chunks := make(chan []byte, 1) + defer close(chunks) chunks <- make([]byte, 320) // The channel stays open: the session is live when the user aborts. errCh := make(chan error, 1) @@ -75,10 +76,14 @@ func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) { errCh <- ferr }() - <-firstFrame - cancel() - ferr := <-errCh - if !errors.Is(ferr, context.Canceled) { - t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + select { + case <-firstFrame: + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index cc463ee3b..e64021326 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -4,7 +4,6 @@ import ( "context" "encoding/base64" "encoding/json" - "errors" "fmt" "net/http" "strings" @@ -61,6 +60,14 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, }) if err != nil { + // Preserve the context.Canceled sentinel (it carries no key) so an + // Esc-abort during dial still matches the UI's errors.Is check. Key + // on ctx.Err() itself rather than unwrapping err: a cancellation can + // surface as a plain transport error (e.g. "closed network + // connection") rather than context.Canceled directly. + if ctx.Err() != nil { + return "", ctx.Err() + } return "", fmt.Errorf("connecting to OpenAI Realtime: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } defer conn.CloseNow() @@ -76,6 +83,11 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, } if err := writeJSON(ctx, conn, sessionUpdate); err != nil { + // Same cancellation short-circuit as the dial above: an Esc-abort + // while the session update is in flight must stay context.Canceled. + if ctx.Err() != nil { + return "", ctx.Err() + } return "", fmt.Errorf("configuring OpenAI Realtime session: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) } @@ -118,7 +130,11 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks // A user abort cancels the streaming context; the UI matches it // with errors.Is(err, context.Canceled), so return the sentinel // itself (it carries no key) instead of a flat redacted string. - if errors.Is(err, context.Canceled) { + // Key on ctx.Err() rather than unwrapping err: the writeErrCh + // swap above can replace err with a plain transport error (e.g. + // "closed network connection") that doesn't itself unwrap to + // context.Canceled even though the cancellation is what caused it. + if ctx.Err() != nil { return compose(), ctx.Err() } return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey)) @@ -163,6 +179,8 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks case werr := <-writeErrCh: if werr == nil { committed = true + } else if ctx.Err() != nil { + return compose(), ctx.Err() } else { return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(werr.Error(), o.cfg.APIKey)) } diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index 9ac682053..4f394da6b 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -64,6 +64,7 @@ func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() chunks := make(chan []byte, 1) + defer close(chunks) chunks <- make([]byte, 480) // The channel stays open: the session is live when the user aborts. errCh := make(chan error, 1) @@ -72,10 +73,14 @@ func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) { errCh <- ferr }() - <-firstFrame - cancel() - ferr := <-errCh - if !errors.Is(ferr, context.Canceled) { - t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + select { + case <-firstFrame: + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } From 89ad2b33d75a8f3c81d424b5fcd5a29b1341ee24 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:19:01 -0400 Subject: [PATCH 18/27] test(dictation): cover dial and startup cancel identity Add regression tests for Esc-abort during WebSocket dial and right after accept (session update / first write) so those redaction sites keep returning context.Canceled for the TUI errors.Is check. --- .../dictation/transcriber_deepgram_test.go | 63 ++++++++++ .../transcriber_openai_realtime_test.go | 113 ++++++++++++++++++ 2 files changed, 176 insertions(+) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index dd8b7c4ee..2d18b6b69 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -87,3 +87,66 @@ func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) { t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } + +// Esc can cancel while the WebSocket dial is still pending. The dial error is +// redacted with %s, so we must return ctx.Err() rather than a flat string. +func TestDeepgramStreamTranscribeDialCancelKeepsSentinel(t *testing.T) { + tr, err := NewDeepgramTranscriber(DeepgramConfig{ + APIKey: "sk-test-key", + BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled + }) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + chunks := make(chan []byte) + defer close(chunks) + + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr) + } +} + +// Esc right after accept can race the first write or the first Read; both +// setup/write redaction sites must still yield context.Canceled. +func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { + accepted := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + close(accepted) + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + } + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + defer close(chunks) + // No frames yet: the writer blocks on chunks or the reader blocks on Read. + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + select { + case <-accepted: + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed before accept: %v", ferr) + } +} diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index 4f394da6b..5b2d3b317 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -84,3 +84,116 @@ func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) { t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } + +// Esc can cancel while the WebSocket dial is still pending. The dial error is +// redacted with %s, so we must return ctx.Err() rather than a flat string. +func TestOpenAIRealtimeStreamTranscribeDialCancelKeepsSentinel(t *testing.T) { + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{ + APIKey: "sk-test-key", + BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled + }) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + chunks := make(chan []byte) + defer close(chunks) + + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr) + } +} + +// Esc right after accept can hit the session-update write or the first Read; +// both redaction sites must still yield context.Canceled. +func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { + accepted := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + close(accepted) + // Do not read: leave the client in session-update write or first Read. + <-ctx.Done() + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + defer close(chunks) + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + select { + case <-accepted: + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed before accept: %v", ferr) + } +} + +// After a server event the client selects writeErrCh. Cancel while the writer +// is blocked on more chunks so that path observes a cancelled write and must +// still return context.Canceled rather than a redacted flat string. +func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { + deltaSent := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // Session update, then a delta. Leave the socket open so the client + // stays in the event loop rather than failing on Read first. + if _, _, err := c.Read(ctx); err != nil { + return + } + _ = c.Write(ctx, websocket.MessageText, []byte( + `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, + )) + close(deltaSent) + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + } + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // One frame so the writer can progress past session setup; channel stays + // open so a later Write is still pending when we cancel. + chunks := make(chan []byte, 1) + defer close(chunks) + chunks <- make([]byte, 480) + errCh := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(ctx, chunks, nil) + errCh <- ferr + }() + + select { + case <-deltaSent: + // Give the client a moment to process the delta and re-enter Read or + // select writeErrCh, then cancel so the blocked writer fails. + cancel() + ferr := <-errCh + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("write-path cancel lost the context.Canceled sentinel: %v", ferr) + } + case ferr := <-errCh: + t.Fatalf("StreamTranscribe failed before delta: %v", ferr) + } +} From b4754d6213c78f78b3c2af13296980d3737a1fdf Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:58:36 -0400 Subject: [PATCH 19/27] fix(dictation): prefer cancel over a racing OpenAI realtime error Esc can cancel after conn.Read already has an error frame. Check ctx.Err() after partial callbacks and on the realtimeError path so the stream still returns context.Canceled instead of a redacted failure notice. Refs Gitlawb/zero#710 --- .../dictation/transcriber_openai_realtime.go | 17 ++++++++ .../transcriber_openai_realtime_test.go | 42 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index e64021326..64ee56f3a 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -149,6 +149,12 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks if onPartial != nil { onPartial(compose(), false) } + // onPartial may deliver the partial that makes the TUI cancel + // (Esc). Observe that before the next Read, which can already + // have a racing OpenAI error frame buffered. + if ctx.Err() != nil { + return compose(), ctx.Err() + } case realtimeCompleted: // A completed item replaces the in-progress delta buffer with the // server's authoritative transcript for that segment. @@ -162,6 +168,9 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks if onPartial != nil { onPartial(compose(), true) } + if ctx.Err() != nil { + return compose(), ctx.Err() + } // Once we've committed the buffer (user stopped) and the server has // returned a completed transcription, the utterance is done. if committed { @@ -171,6 +180,14 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks case realtimeCommitted: committed = true case realtimeError: + // Esc can race an incoming error event: conn.Read may already have + // the OpenAI error in hand by the time cancellation lands. Key on + // ctx.Err() first, same as every other return point in this loop, + // so a cancel that wins the race still surfaces as context.Canceled + // instead of a spurious redacted failure alongside it. + if ctx.Err() != nil { + return compose(), ctx.Err() + } return compose(), fmt.Errorf("OpenAI Realtime error: %s", providerio.Redact(evt.text, o.cfg.APIKey)) } // The writer signals commit completion out of band; observe it so a diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index 5b2d3b317..6b9075c3c 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -197,3 +197,45 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { t.Fatalf("StreamTranscribe failed before delta: %v", ferr) } } + +// jatmn (review, PR #710): Esc can race an incoming OpenAI error event. +// conn.Read may already have the error frame in hand by the time the +// cancellation lands. The server fires a delta immediately followed by +// the error, back to back. Cancel from inside the delta callback (same +// path the TUI uses when a partial triggers Esc handling) so the cancel +// is observed before the next event is processed. The returned error +// must still be context.Canceled, not the redacted OpenAI error. +func TestOpenAIRealtimeStreamTranscribeErrorRaceKeepsSentinel(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + if _, _, err := c.Read(ctx); err != nil { + return + } + _ = c.Write(ctx, websocket.MessageText, []byte( + `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, + )) + _ = c.Write(ctx, websocket.MessageText, []byte( + `{"type":"error","error":{"message":"invalid API key sk-test-key"}}`, + )) + <-ctx.Done() + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + chunks := make(chan []byte, 1) + defer close(chunks) + chunks <- make([]byte, 480) + var once sync.Once + _, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) { + // Cancel synchronously from the partial callback, before the + // stream loop continues to the already-buffered error frame. + once.Do(cancel) + }) + if !errors.Is(ferr, context.Canceled) { + t.Fatalf("StreamTranscribe error = %v, want context.Canceled (cancel must win over a racing OpenAI error event)", ferr) + } +} From 676438fcf7f548e9bbdd6c461853fca5d47d9a20 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:57:57 -0400 Subject: [PATCH 20/27] fix(dictation): prevent auto-submit on cancelled realtime race handleDictationTranscribed suppressed context.Canceled but then fell through to the msg.submit branch. A delta -> Esc -> already-buffered realtime error race returns a nonempty transcript alongside context.Canceled, so with stt.autoSubmit on, Esc could submit the composer's restored pre-existing text instead of doing nothing. Treat a canceled result as terminal before the auto-submit path is reached, and add a TUI-level test covering the race. --- internal/tui/dictation.go | 12 ++++++++++- internal/tui/dictation_test.go | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index 3d96d82f6..4e6c5221b 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -392,7 +392,17 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode } m.dictation.reset() - if msg.err != nil && !errors.Is(msg.err, context.Canceled) { + if errors.Is(msg.err, context.Canceled) { + // cancelDictation already discarded the live region, reset the + // session, and posted "Dictation cancelled." A streaming transcriber + // can still race a buffered event past that cancel and report back a + // nonempty compose()+context.Canceled; treat that as terminal here, + // before the auto-submit branch below, so Esc can never fall through + // to msg.submit and fire the composer's restored pre-existing text. + return m, nil + } + + if msg.err != nil { // 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. diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index 5101cbd64..c6a226407 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -136,6 +136,44 @@ func TestDictationStreamingPartialReplacesRegion(t *testing.T) { } } +func TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) { + // Esc can race an already-buffered realtime event: cancelDictation discards + // the live region and resets state synchronously, but the streaming + // goroutine's dictationTranscribedMsg (a nonempty compose() alongside + // context.Canceled) can still arrive afterward. With stt.autoSubmit on, + // that must never fall through to msg.submit and fire the composer's + // restored pre-existing text. + m := model{} + m.setComposerState(composerState{text: "existing prompt", cursor: len("existing prompt")}) + m.dictation.phase = dictRecording + m.dictation.streaming = true + + m = m.handleDictationPartial(sttPartialMsg{text: "half-formed transcript"}) + if m.composer.text != "existing prompt half-formed transcript" { + t.Fatalf("partial did not render into composer: %q", m.composer.text) + } + + m, _ = m.cancelDictation() + if m.composer.text != "existing prompt" { + t.Fatalf("cancel should restore the pre-existing composer text, got %q", m.composer.text) + } + + // The already-buffered event's message arrives after the cancel above. + next, cmd := m.handleDictationTranscribed(dictationTranscribedMsg{ + text: "half-formed transcript", + err: context.Canceled, + submit: true, + streaming: true, + }) + got := next.(model) + if got.composer.text != "existing prompt" { + t.Errorf("a raced cancellation must not auto-submit the restored composer text, got %q", got.composer.text) + } + if cmd != nil { + t.Error("a raced cancellation must not return a submit command") + } +} + func TestDictationCommitKeepsStreamedText(t *testing.T) { m := model{} m.setComposerState(composerState{text: "", cursor: 0}) From 136d596b0435bdc8a9eeee50dd40983352bfad74 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:57:19 -0400 Subject: [PATCH 21/27] test(dictation): exercise writeErrCh cancellation branch in realtime transcriber --- .../transcriber_openai_realtime_test.go | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index 6b9075c3c..c15518245 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -150,8 +150,7 @@ func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { deltaSent := make(chan struct{}) url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - // Session update, then a delta. Leave the socket open so the client - // stays in the event loop rather than failing on Read first. + // Session update, then a delta. if _, _, err := c.Read(ctx); err != nil { return } @@ -159,11 +158,8 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, )) close(deltaSent) - for { - if _, _, err := c.Read(ctx); err != nil { - return - } - } + // Close socket so the next client Write fails and sends to writeErrCh + _ = c.Close(websocket.StatusAbnormalClosure, "closed") }) tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) @@ -173,9 +169,7 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // One frame so the writer can progress past session setup; channel stays - // open so a later Write is still pending when we cancel. - chunks := make(chan []byte, 1) + chunks := make(chan []byte, 2) defer close(chunks) chunks <- make([]byte, 480) errCh := make(chan error, 1) @@ -186,8 +180,9 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { select { case <-deltaSent: - // Give the client a moment to process the delta and re-enter Read or - // select writeErrCh, then cancel so the blocked writer fails. + // Push another chunk so writer tries to write to the closed socket + chunks <- make([]byte, 480) + // Cancel context so writeErrCh observes ctx.Err() != nil cancel() ferr := <-errCh if !errors.Is(ferr, context.Canceled) { From 0853700eb3bdaa66b5c0ba81e809df0889e60e3c Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:03:14 -0400 Subject: [PATCH 22/27] fix(dictation): add custom key header redaction and single-pass redaction tests --- .../dictation/transcriber_deepgram_test.go | 44 +++++++++++++++++++ internal/providers/providerio/providerio.go | 11 ++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index 2d18b6b69..73998555d 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -150,3 +150,47 @@ func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { t.Fatalf("StreamTranscribe failed before accept: %v", ferr) } } + +func TestDeepgramCustomHeaderErrorRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + c.Close(websocket.StatusPolicyViolation, "X-Api-Key: sk-custom-secret-key-1234567890 failed") + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-custom-secret-key-1234567890", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "sk-custom-secret-key-1234567890") { + t.Errorf("Custom header API key leaked: %v", ferr) + } +} + +func TestDeepgramSinglePassRedaction(t *testing.T) { + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + c.Close(websocket.StatusPolicyViolation, "error with sk-key1234567890abcdef1234") + }) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-key1234567890abcdef1234", BaseURL: url}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 320) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + if ferr == nil { + t.Fatal("expected error") + } + if strings.Contains(ferr.Error(), "[REDACTED:[REDACTED") { + t.Errorf("nested redaction marker created: %v", ferr) + } +} diff --git a/internal/providers/providerio/providerio.go b/internal/providers/providerio/providerio.go index 39f5f745b..50f7d726f 100644 --- a/internal/providers/providerio/providerio.go +++ b/internal/providers/providerio/providerio.go @@ -422,16 +422,17 @@ func looksLikeToken(w string) bool { // Redact removes known API-key and bearer-token forms from provider messages. func Redact(message string, secrets ...string) string { for _, secret := range secrets { - if secret != "" { + if secret != "" && !strings.HasPrefix(secret, "[REDACTED") { message = strings.ReplaceAll(message, secret, "[REDACTED]") } } words := strings.Fields(message) for index := 0; index < len(words)-1; index++ { - // Only redact the word after "Bearer" when it is actually token-shaped, so the - // provider's own help text ("use Bearer authentication", "Bearer token") is no - // longer corrupted into "authorization [REDACTED]". (AUDIT-H7) - if strings.EqualFold(strings.TrimRight(words[index], ":"), "Bearer") && looksLikeToken(words[index+1]) { + hdr := strings.EqualFold(strings.TrimRight(words[index], ":"), "Bearer") || + strings.EqualFold(strings.TrimRight(words[index], ":"), "Token") || + strings.EqualFold(strings.TrimRight(words[index], ":"), "X-Api-Key") || + strings.EqualFold(strings.TrimRight(words[index], ":"), "api-key") + if hdr && !strings.HasPrefix(words[index+1], "[REDACTED") && looksLikeToken(words[index+1]) { words[index+1] = "[REDACTED]" } } From 9500b57e6eba1f60074c048471b5774346babde0 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:10:05 -0400 Subject: [PATCH 23/27] fix(dictation): track dictation sessionID to drop stale completions after Esc and synchronize write cancellation test --- .../dictation/transcriber_deepgram_test.go | 18 ++++++++++++ .../transcriber_openai_realtime_test.go | 27 ++++++++--------- internal/tui/dictation.go | 14 ++++++--- internal/tui/dictation_stream.go | 13 ++++++--- internal/tui/dictation_test.go | 29 +++++++++++++++++++ 5 files changed, 78 insertions(+), 23 deletions(-) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index 73998555d..09bc3c03c 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -153,6 +153,15 @@ func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { func TestDeepgramCustomHeaderErrorRedaction(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + for { + typ, data, err := c.Read(ctx) + if err != nil { + return + } + if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") { + break + } + } c.Close(websocket.StatusPolicyViolation, "X-Api-Key: sk-custom-secret-key-1234567890 failed") }) @@ -175,6 +184,15 @@ func TestDeepgramCustomHeaderErrorRedaction(t *testing.T) { func TestDeepgramSinglePassRedaction(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + for { + typ, data, err := c.Read(ctx) + if err != nil { + return + } + if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") { + break + } + } c.Close(websocket.StatusPolicyViolation, "error with sk-key1234567890abcdef1234") }) diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index c15518245..c46f91287 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -148,18 +148,17 @@ func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) // is blocked on more chunks so that path observes a cancelled write and must // still return context.Canceled rather than a redacted flat string. func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { - deltaSent := make(chan struct{}) + sessionReceived := make(chan struct{}) url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { - // Session update, then a delta. if _, _, err := c.Read(ctx); err != nil { return } - _ = c.Write(ctx, websocket.MessageText, []byte( - `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, - )) - close(deltaSent) - // Close socket so the next client Write fails and sends to writeErrCh - _ = c.Close(websocket.StatusAbnormalClosure, "closed") + close(sessionReceived) + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + } }) tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url}) @@ -169,9 +168,10 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - chunks := make(chan []byte, 2) - defer close(chunks) + chunks := make(chan []byte, 1) chunks <- make([]byte, 480) + defer close(chunks) + errCh := make(chan error, 1) go func() { _, ferr := tr.StreamTranscribe(ctx, chunks, nil) @@ -179,17 +179,14 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { }() select { - case <-deltaSent: - // Push another chunk so writer tries to write to the closed socket - chunks <- make([]byte, 480) - // Cancel context so writeErrCh observes ctx.Err() != nil + case <-sessionReceived: cancel() ferr := <-errCh if !errors.Is(ferr, context.Canceled) { t.Fatalf("write-path cancel lost the context.Canceled sentinel: %v", ferr) } case ferr := <-errCh: - t.Fatalf("StreamTranscribe failed before delta: %v", ferr) + t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr) } } diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index 4e6c5221b..b995ceac7 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -61,6 +61,7 @@ type dictationController struct { browseVariants []dictation.ModelVariant // in-flight session state + sessionID int64 streaming bool recorder Recorder transcriber Transcriber @@ -108,6 +109,7 @@ type dictationStartedMsg struct { // dictationTranscribedMsg carries the final transcript of a batch (or a // completed streaming) recording. type dictationTranscribedMsg struct { + sessionID int64 text string err error submit bool @@ -276,7 +278,7 @@ func (m model) stopDictation() (model, tea.Cmd) { } 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()) + return m, transcribeBatchCmd(m.dictation.sessionID, m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled()) } // cancelDictation aborts an in-flight recording without transcribing. Bound to @@ -311,6 +313,7 @@ func (d dictationController) maxDuration() time.Duration { } func (d *dictationController) reset() { + d.sessionID++ d.phase = dictIdle d.recorder = nil d.transcriber = nil @@ -335,17 +338,17 @@ func startBatchRecordingCmd(rec Recorder) tea.Cmd { // 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 { +func transcribeBatchCmd(sessionID int64, 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} + return dictationTranscribedMsg{sessionID: sessionID, err: err} } text, err := transcriber.Transcribe(ctx, audio) - return dictationTranscribedMsg{text: text, err: err, submit: submit} + return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit} } } @@ -375,6 +378,9 @@ func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd) // 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) { + if msg.sessionID != 0 && msg.sessionID != m.dictation.sessionID { + return m, nil + } streaming := m.dictation.streaming || msg.streaming m = m.commitDictationRegion() // A streaming session can end via a transcriber error (not just a user stop), so diff --git a/internal/tui/dictation_stream.go b/internal/tui/dictation_stream.go index 90b74c321..85e73814c 100644 --- a/internal/tui/dictation_stream.go +++ b/internal/tui/dictation_stream.go @@ -14,8 +14,9 @@ import ( // 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 + sessionID int64 + text string + final bool } // startStreamingDictation begins continuous capture and launches the streaming @@ -38,6 +39,7 @@ func (m model) startStreamingDictation() (model, tea.Cmd) { } transcriber := m.dictation.transcriber submit := m.dictation.cfg.AutoSubmitEnabled() + sessionID := m.dictation.sessionID // 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 @@ -63,11 +65,11 @@ func (m model) startStreamingDictation() (model, tea.Cmd) { streamCmd := func() tea.Msg { onPartial := func(text string, final bool) { if sink != nil { - sink(sttPartialMsg{text: text, final: final}) + sink(sttPartialMsg{sessionID: sessionID, text: text, final: final}) } } text, err := transcriber.StreamTranscribe(ctx, tapped, onPartial) - return dictationTranscribedMsg{text: text, err: err, submit: submit, streaming: true} + return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit, streaming: true} } // Streaming drives the waveform from real levels (no synthetic tick needed). return m, streamCmd @@ -77,6 +79,9 @@ func (m model) startStreamingDictation() (model, tea.Cmd) { // 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 { + if msg.sessionID != 0 && msg.sessionID != m.dictation.sessionID { + return m + } // Ignore stragglers that arrive after the session ended (cancel/final). if m.dictation.phase != dictRecording && m.dictation.phase != dictTranscribing { return m diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index c6a226407..fcfabea9e 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -340,3 +340,32 @@ func TestCurrentModelLabel(t *testing.T) { t.Errorf("no-model label = %q", got) } } + +func TestStaleDictationCompletionIgnoredAfterCancel(t *testing.T) { + m := model{} + m.setComposerState(composerState{text: "original composer text", cursor: 22}) + m.dictation.sessionID = 1 + m.dictation.phase = dictRecording + + m, _ = m.cancelDictation() + + if m.composer.text != "original composer text" { + t.Fatalf("composer text = %q, want 'original composer text'", m.composer.text) + } + + staleMsg := dictationTranscribedMsg{ + sessionID: 1, + text: "stale text that should be ignored", + submit: true, + streaming: true, + } + afterStale, cmd := m.handleDictationTranscribed(staleMsg) + got := afterStale.(model) + + if got.composer.text != "original composer text" { + t.Fatalf("composer text changed to %q after stale completion", got.composer.text) + } + if cmd != nil { + t.Fatalf("expected no command for stale completion, got %v", cmd) + } +} From fee2e2e9905dfdc7786315320ceef6201f20eeb7 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:16:31 -0400 Subject: [PATCH 24/27] fix(dictation): initialize non-zero session ID and filter stale session 0 messages Ensure the first dictation session in a process gets stale-message protection by initializing sessionID to 1 in newDictationController and removing msg.sessionID != 0 exemptions in handleDictationTranscribed and handleDictationPartial. Clean up unreachable redaction guards in providerio. Refs #710 --- internal/providers/providerio/providerio.go | 4 +- internal/tui/dictation.go | 3 +- internal/tui/dictation_stream.go | 2 +- internal/tui/dictation_test.go | 51 +++++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/internal/providers/providerio/providerio.go b/internal/providers/providerio/providerio.go index 50f7d726f..492493468 100644 --- a/internal/providers/providerio/providerio.go +++ b/internal/providers/providerio/providerio.go @@ -422,7 +422,7 @@ func looksLikeToken(w string) bool { // Redact removes known API-key and bearer-token forms from provider messages. func Redact(message string, secrets ...string) string { for _, secret := range secrets { - if secret != "" && !strings.HasPrefix(secret, "[REDACTED") { + if secret != "" { message = strings.ReplaceAll(message, secret, "[REDACTED]") } } @@ -432,7 +432,7 @@ func Redact(message string, secrets ...string) string { strings.EqualFold(strings.TrimRight(words[index], ":"), "Token") || strings.EqualFold(strings.TrimRight(words[index], ":"), "X-Api-Key") || strings.EqualFold(strings.TrimRight(words[index], ":"), "api-key") - if hdr && !strings.HasPrefix(words[index+1], "[REDACTED") && looksLikeToken(words[index+1]) { + if hdr && looksLikeToken(words[index+1]) { words[index+1] = "[REDACTED]" } } diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index b995ceac7..071d1f9bd 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -127,6 +127,7 @@ func newDictationController(opts Options) dictationController { platform: dictation.DetectPlatform(), downloadRoot: opts.STTDownloadRoot, userConfigPath: opts.UserConfigPath, + sessionID: 1, } } @@ -378,7 +379,7 @@ func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd) // 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) { - if msg.sessionID != 0 && msg.sessionID != m.dictation.sessionID { + if msg.sessionID != m.dictation.sessionID { return m, nil } streaming := m.dictation.streaming || msg.streaming diff --git a/internal/tui/dictation_stream.go b/internal/tui/dictation_stream.go index 85e73814c..5e994eedd 100644 --- a/internal/tui/dictation_stream.go +++ b/internal/tui/dictation_stream.go @@ -79,7 +79,7 @@ func (m model) startStreamingDictation() (model, tea.Cmd) { // 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 { - if msg.sessionID != 0 && msg.sessionID != m.dictation.sessionID { + if msg.sessionID != m.dictation.sessionID { return m } // Ignore stragglers that arrive after the session ended (cancel/final). diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index fcfabea9e..a9d839288 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -353,6 +353,16 @@ func TestStaleDictationCompletionIgnoredAfterCancel(t *testing.T) { t.Fatalf("composer text = %q, want 'original composer text'", m.composer.text) } + // Stale partial message from session 1 must be ignored + stalePartial := sttPartialMsg{ + sessionID: 1, + text: "stale partial that should be ignored", + } + mPartial := m.handleDictationPartial(stalePartial) + if mPartial.composer.text != "original composer text" { + t.Fatalf("composer text changed to %q after stale partial", mPartial.composer.text) + } + staleMsg := dictationTranscribedMsg{ sessionID: 1, text: "stale text that should be ignored", @@ -369,3 +379,44 @@ func TestStaleDictationCompletionIgnoredAfterCancel(t *testing.T) { t.Fatalf("expected no command for stale completion, got %v", cmd) } } + +func TestStaleDictationSessionZeroIgnoredAfterCancel(t *testing.T) { + m := model{} + m.setComposerState(composerState{text: "initial composer text", cursor: 21}) + m.dictation.sessionID = 0 + m.dictation.phase = dictRecording + + m, _ = m.cancelDictation() + + // Session 0 was reset to session 1 + if m.dictation.sessionID != 1 { + t.Fatalf("dictation.sessionID = %d after cancel, want 1", m.dictation.sessionID) + } + + // Stale partial from session 0 must be ignored + stalePartial := sttPartialMsg{ + sessionID: 0, + text: "stale partial from session 0", + } + mPartial := m.handleDictationPartial(stalePartial) + if mPartial.composer.text != "initial composer text" { + t.Fatalf("composer text changed to %q after stale session 0 partial", mPartial.composer.text) + } + + // Stale completion from session 0 must be ignored + staleMsg := dictationTranscribedMsg{ + sessionID: 0, + text: "stale completion from session 0", + submit: true, + streaming: true, + } + afterStale, cmd := m.handleDictationTranscribed(staleMsg) + got := afterStale.(model) + + if got.composer.text != "initial composer text" { + t.Fatalf("composer text changed to %q after stale session 0 completion", got.composer.text) + } + if cmd != nil { + t.Fatalf("expected no command for stale session 0 completion, got %v", cmd) + } +} From c19ceefcf5a58130075f57e2ae04778790a434b8 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:33:27 -0400 Subject: [PATCH 25/27] fix(dictation): drop providerio widen; pin session and redaction tests Revert the Token/X-Api-Key providerio.Redact expansion (belongs in its own PR with coverage). Make Deepgram single-pass, cancel-race, and live-session partial tests pin the branches they name, and restore the ST1005 nolint on the user-facing Deepgram error string. --- internal/dictation/transcriber_deepgram.go | 1 + .../dictation/transcriber_deepgram_test.go | 9 +++++-- internal/providers/providerio/providerio.go | 9 +++---- internal/tui/dictation_test.go | 27 ++++++++++++++++++- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index 2f25b386a..1701a4a87 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -119,6 +119,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha if ctx.Err() != nil { return compose(), ctx.Err() } + //nolint:staticcheck // Preserve established user-facing error text. return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey)) } if typ != websocket.MessageText { diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index 09bc3c03c..f860b6af1 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -208,7 +208,12 @@ func TestDeepgramSinglePassRedaction(t *testing.T) { if ferr == nil { t.Fatal("expected error") } - if strings.Contains(ferr.Error(), "[REDACTED:[REDACTED") { - t.Errorf("nested redaction marker created: %v", ferr) + got := ferr.Error() + if strings.Contains(got, "sk-key1234567890abcdef1234") { + t.Fatalf("API key leaked into stream error: %q", got) + } + // One key occurrence must produce exactly one redaction marker (no double wrap). + if strings.Count(got, "[REDACTED]") != 1 { + t.Fatalf("redaction markers in %q: want exactly 1 [REDACTED]", got) } } diff --git a/internal/providers/providerio/providerio.go b/internal/providers/providerio/providerio.go index 492493468..39f5f745b 100644 --- a/internal/providers/providerio/providerio.go +++ b/internal/providers/providerio/providerio.go @@ -428,11 +428,10 @@ func Redact(message string, secrets ...string) string { } words := strings.Fields(message) for index := 0; index < len(words)-1; index++ { - hdr := strings.EqualFold(strings.TrimRight(words[index], ":"), "Bearer") || - strings.EqualFold(strings.TrimRight(words[index], ":"), "Token") || - strings.EqualFold(strings.TrimRight(words[index], ":"), "X-Api-Key") || - strings.EqualFold(strings.TrimRight(words[index], ":"), "api-key") - if hdr && looksLikeToken(words[index+1]) { + // Only redact the word after "Bearer" when it is actually token-shaped, so the + // provider's own help text ("use Bearer authentication", "Bearer token") is no + // longer corrupted into "authorization [REDACTED]". (AUDIT-H7) + if strings.EqualFold(strings.TrimRight(words[index], ":"), "Bearer") && looksLikeToken(words[index+1]) { words[index+1] = "[REDACTED]" } } diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index a9d839288..b84759ddc 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -143,12 +143,16 @@ func TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) { // context.Canceled) can still arrive afterward. With stt.autoSubmit on, // that must never fall through to msg.submit and fire the composer's // restored pre-existing text. + // Matching sessionID is required so this hits the context.Canceled branch + // rather than only the stale-session gate (session 0 after cancel would be + // dropped before that branch runs). m := model{} + m.dictation.sessionID = 1 m.setComposerState(composerState{text: "existing prompt", cursor: len("existing prompt")}) m.dictation.phase = dictRecording m.dictation.streaming = true - m = m.handleDictationPartial(sttPartialMsg{text: "half-formed transcript"}) + m = m.handleDictationPartial(sttPartialMsg{sessionID: 1, text: "half-formed transcript"}) if m.composer.text != "existing prompt half-formed transcript" { t.Fatalf("partial did not render into composer: %q", m.composer.text) } @@ -160,6 +164,7 @@ func TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) { // The already-buffered event's message arrives after the cancel above. next, cmd := m.handleDictationTranscribed(dictationTranscribedMsg{ + sessionID: m.dictation.sessionID, text: "half-formed transcript", err: context.Canceled, submit: true, @@ -174,6 +179,26 @@ func TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) { } } +// TestStaleDictationPartialIgnoredWhileLive pins the session-ID gate on +// handleDictationPartial while a new session is still recording. Cancel-then- +// stale cases are already covered by the phase guard; this case needs a live +// phase so only the session check drops the ghost partial. +func TestStaleDictationPartialIgnoredWhileLive(t *testing.T) { + m := model{} + m.dictation.sessionID = 2 + m.dictation.phase = dictRecording + m.dictation.streaming = true + m.setComposerState(composerState{text: "user typed this", cursor: len("user typed this")}) + + got := m.handleDictationPartial(sttPartialMsg{ + sessionID: 1, + text: "ghost text from session 1", + }) + if got.composer.text != "user typed this" { + t.Fatalf("stale partial while live: got %q, want %q", got.composer.text, "user typed this") + } +} + func TestDictationCommitKeepsStreamedText(t *testing.T) { m := model{} m.setComposerState(composerState{text: "", cursor: 0}) From 03fa588018673ba7b51cc266d792328f6b8fe17f Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:53:47 -0400 Subject: [PATCH 26/27] fix(dictation): ignore stale batch startup by session ID Gate dictationStartedMsg on session ID so a late Start result after cancel cannot reset a newer recording. Rename and comment cleanups from review; race suite recorded on the PR body. --- .../dictation/transcriber_deepgram_test.go | 5 ++- .../transcriber_openai_realtime_test.go | 20 +++++----- internal/tui/dictation.go | 16 ++++++-- internal/tui/dictation_test.go | 40 ++++++++++++++++++- 4 files changed, 64 insertions(+), 17 deletions(-) diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index f860b6af1..e97a02dd3 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -151,7 +151,10 @@ func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) { } } -func TestDeepgramCustomHeaderErrorRedaction(t *testing.T) { +// TestDeepgramStreamErrorRedactsHeaderShapedKey exercises the same stream-error +// redaction path as TestDeepgramStreamTranscribeErrorRedaction, with a close +// reason that embeds the key next to an X-Api-Key-shaped header label. +func TestDeepgramStreamErrorRedactsHeaderShapedKey(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { for { typ, data, err := c.Read(ctx) diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index c46f91287..b2f6df06b 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -144,9 +144,9 @@ func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) } } -// After a server event the client selects writeErrCh. Cancel while the writer -// is blocked on more chunks so that path observes a cancelled write and must -// still return context.Canceled rather than a redacted flat string. +// Cancel while the writer is blocked on more chunks. Whichever path observes +// the cancellation first (the blocked conn.Read or the writeErrCh drain) must +// return context.Canceled rather than a redacted flat string. func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { sessionReceived := make(chan struct{}) url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { @@ -190,13 +190,13 @@ func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) { } } -// jatmn (review, PR #710): Esc can race an incoming OpenAI error event. -// conn.Read may already have the error frame in hand by the time the -// cancellation lands. The server fires a delta immediately followed by -// the error, back to back. Cancel from inside the delta callback (same -// path the TUI uses when a partial triggers Esc handling) so the cancel -// is observed before the next event is processed. The returned error -// must still be context.Canceled, not the redacted OpenAI error. +// Esc can race an incoming OpenAI error event. conn.Read may already have +// the error frame in hand by the time the cancellation lands. The server +// fires a delta immediately followed by the error, back to back. Cancel from +// inside the delta callback (same path the TUI uses when a partial triggers +// Esc handling) so the cancel is observed before the next event is processed. +// The returned error must still be context.Canceled, not the redacted OpenAI +// error. func TestOpenAIRealtimeStreamTranscribeErrorRaceKeepsSentinel(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { if _, _, err := c.Read(ctx); err != nil { diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index 071d1f9bd..348b72b13 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -102,8 +102,11 @@ type dictationController struct { } // dictationStartedMsg reports the outcome of Recorder.Start(). +// sessionID must match the dictation controller's current session so a late +// start result after cancel/reset cannot reset a newer recording. type dictationStartedMsg struct { - err error + sessionID int64 + err error } // dictationTranscribedMsg carries the final transcript of a batch (or a @@ -265,7 +268,7 @@ func (m model) startDictation() (model, tea.Cmd) { if streaming { return m.startStreamingDictation() } - return m, startBatchRecordingCmd(rec) + return m, startBatchRecordingCmd(m.dictation.sessionID, rec) } // stopDictation ends a recording. For batch it triggers Stop()+Transcribe(); for @@ -330,9 +333,9 @@ func (d *dictationController) reset() { // 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 { +func startBatchRecordingCmd(sessionID int64, rec Recorder) tea.Cmd { return func() tea.Msg { - return dictationStartedMsg{err: rec.Start()} + return dictationStartedMsg{sessionID: sessionID, err: rec.Start()} } } @@ -355,6 +358,11 @@ func transcribeBatchCmd(sessionID int64, ctx context.Context, rec Recorder, tran // handleDictationStarted transitions to recording (or reports a start failure). func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd) { + if msg.sessionID != m.dictation.sessionID { + // Stale startup from a cancelled session must not reset or re-arm a + // newer recording that already owns the controller. + return m, nil + } if msg.err != nil { m = m.discardDictationRegion() m.dictation.reset() diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index b84759ddc..508636168 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -86,9 +86,10 @@ func TestDictationNonAuthErrorDoesNotPrompt(t *testing.T) { func TestDictationStartedFailureResets(t *testing.T) { m := model{} + m.dictation.sessionID = 1 m.dictation.phase = dictStarting m.dictation.streaming = false - got, _ := m.handleDictationStarted(dictationStartedMsg{err: errors.New("mic busy")}) + got, _ := m.handleDictationStarted(dictationStartedMsg{sessionID: 1, err: errors.New("mic busy")}) if got.dictation.active() { t.Error("a start failure should reset to idle") } @@ -99,13 +100,48 @@ func TestDictationStartedFailureResets(t *testing.T) { func TestDictationStartedArmsRecording(t *testing.T) { m := model{} + m.dictation.sessionID = 1 m.dictation.phase = dictStarting - got, _ := m.handleDictationStarted(dictationStartedMsg{}) + got, _ := m.handleDictationStarted(dictationStartedMsg{sessionID: 1}) if got.dictation.phase != dictRecording { t.Error("a successful start should advance to recording") } } +// TestStaleDictationStartedIgnoredWhileLive pins the session gate on batch +// startup messages. A late Start() result after cancel must not reset() a +// newer recording (success would leave dictStarting with no recorder; failure +// would kill the live session). +func TestStaleDictationStartedIgnoredWhileLive(t *testing.T) { + // Active recording on session 2. + m := model{} + m.dictation.sessionID = 2 + m.dictation.phase = dictRecording + m.dictation.streaming = true + m.setComposerState(composerState{text: "live session text", cursor: len("live session text")}) + + // Stale failure from session 1 must not reset the live controller. + afterFail, _ := m.handleDictationStarted(dictationStartedMsg{ + sessionID: 1, + err: errors.New("mic busy from previous start"), + }) + if afterFail.dictation.phase != dictRecording || afterFail.dictation.sessionID != 2 { + t.Fatalf("stale start failure: phase=%v session=%d, want recording/2", afterFail.dictation.phase, afterFail.dictation.sessionID) + } + if transcriptHasText(afterFail, "mic busy from previous start") { + t.Fatal("stale start failure must not post an error notice") + } + + // Stale success from session 1 must not leave the live session stuck mid-start. + m2 := model{} + m2.dictation.sessionID = 2 + m2.dictation.phase = dictRecording + afterOK, _ := m2.handleDictationStarted(dictationStartedMsg{sessionID: 1}) + if afterOK.dictation.phase != dictRecording || afterOK.dictation.sessionID != 2 { + t.Fatalf("stale start success: phase=%v session=%d, want recording/2", afterOK.dictation.phase, afterOK.dictation.sessionID) + } +} + func TestDictationUnavailableShowsHint(t *testing.T) { m := model{} // no build factory next, _ := m.toggleDictation() From f34ad7589b67e4b170b9674d6c6382418480f9dc Mon Sep 17 00:00:00 2001 From: euxaristia Date: Fri, 7 Aug 2026 14:42:27 -0400 Subject: [PATCH 27/27] fix(dictation): exit writers on cancel and pin dial/write redaction paths Select on ctx.Done while waiting for audio chunks so cancelled streaming sessions do not leave writer goroutines blocked on an open idle channel. Add non-cancellation redaction tests for Deepgram dial failure and OpenAI Realtime dial, session-configuration, and async writer failures. Refs #852 --- internal/dictation/transcriber_deepgram.go | 23 ++- .../dictation/transcriber_deepgram_test.go | 56 +++++++ .../dictation/transcriber_openai_realtime.go | 44 ++++-- .../transcriber_openai_realtime_test.go | 146 ++++++++++++++++++ 4 files changed, 253 insertions(+), 16 deletions(-) diff --git a/internal/dictation/transcriber_deepgram.go b/internal/dictation/transcriber_deepgram.go index 1701a4a87..fd3a6d00f 100644 --- a/internal/dictation/transcriber_deepgram.go +++ b/internal/dictation/transcriber_deepgram.go @@ -77,15 +77,26 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha // 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 + // Select on ctx.Done() while waiting for the next chunk so a cancelled + // session does not leave this goroutine blocked on an open idle channel. + for { + select { + case <-ctx.Done(): + writeErrCh <- ctx.Err() return + case chunk, ok := <-chunks: + if !ok { + // CloseStream flushes any buffered audio and returns final + // results before Deepgram closes the socket. + writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte(`{"type":"CloseStream"}`)) + return + } + 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 diff --git a/internal/dictation/transcriber_deepgram_test.go b/internal/dictation/transcriber_deepgram_test.go index e97a02dd3..4ee8880dc 100644 --- a/internal/dictation/transcriber_deepgram_test.go +++ b/internal/dictation/transcriber_deepgram_test.go @@ -3,6 +3,8 @@ package dictation import ( "context" "errors" + "net/http" + "net/http/httptest" "strings" "sync" "testing" @@ -10,6 +12,60 @@ import ( "github.com/coder/websocket" ) +// Non-cancellation dial failure: handshake returns 101 with an Upgrade header +// equal to the API key so the dial error embeds the key and redaction must strip it. +func TestDeepgramDialFailureRedactsKey(t *testing.T) { + const key = "sk-test-dial-key-abcdef" + baseURL := dialFailServerWithUpgrade(t, key) + + tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: key, BaseURL: baseURL}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, nil) + if ferr == nil { + t.Fatal("expected dial failure") + } + if errors.Is(ferr, context.Canceled) { + t.Fatalf("dial failure should not be context.Canceled: %v", ferr) + } + if strings.Contains(ferr.Error(), key) { + t.Errorf("API key leaked from dial failure: %v", ferr) + } + if !strings.Contains(ferr.Error(), "connecting to Deepgram") { + t.Errorf("expected dial-path error prefix, got: %v", ferr) + } +} + +// dialFailServerWithUpgrade returns a ws URL whose handshake responds 101 with +// Upgrade set to upgradeValue (not "websocket"), so websocket.Dial fails with +// that value in the error string. Hijacks and closes immediately to avoid the +// library's 3s body-drain timeout on failed dials. +func dialFailServerWithUpgrade(t *testing.T, upgradeValue string) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijack not supported", http.StatusInternalServerError) + return + } + conn, bufrw, err := hj.Hijack() + if err != nil { + return + } + defer conn.Close() + _, _ = bufrw.WriteString("HTTP/1.1 101 Switching Protocols\r\n") + _, _ = bufrw.WriteString("Connection: Upgrade\r\n") + _, _ = bufrw.WriteString("Upgrade: " + upgradeValue + "\r\n\r\n") + _ = bufrw.Flush() + })) + t.Cleanup(srv.Close) + return "ws" + strings.TrimPrefix(srv.URL, "http") +} + func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { // Drain the client's audio frames, then wait for CloseStream so the diff --git a/internal/dictation/transcriber_openai_realtime.go b/internal/dictation/transcriber_openai_realtime.go index 64ee56f3a..ae737d43c 100644 --- a/internal/dictation/transcriber_openai_realtime.go +++ b/internal/dictation/transcriber_openai_realtime.go @@ -23,6 +23,10 @@ type OpenAIRealtimeConfig struct { Model string // default "gpt-4o-transcribe" // BaseURL overrides the wss endpoint (tests point it at a fake server). BaseURL string + // writeErrInjector, when set, is consulted before each JSON websocket write. + // Used by same-package tests to force controllable write failures that embed + // secrets; production callers leave it nil. + writeErrInjector func() error } type openAIRealtimeTranscriber struct { @@ -82,7 +86,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks }, }, } - if err := writeJSON(ctx, conn, sessionUpdate); err != nil { + if err := o.writeJSON(ctx, conn, sessionUpdate); err != nil { // Same cancellation short-circuit as the dial above: an Esc-abort // while the session update is in flight must stay context.Canceled. if ctx.Err() != nil { @@ -93,18 +97,29 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks 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 + // Select on ctx.Done() while waiting for the next chunk so a cancelled + // session does not leave this goroutine blocked on an open idle channel. + for { + select { + case <-ctx.Done(): + writeErrCh <- ctx.Err() return + case chunk, ok := <-chunks: + if !ok { + // Commit the buffered audio to force final transcription. + writeErrCh <- o.writeJSON(ctx, conn, map[string]any{"type": "input_audio_buffer.commit"}) + return + } + appendMsg := map[string]any{ + "type": "input_audio_buffer.append", + "audio": base64.StdEncoding.EncodeToString(chunk), + } + if err := o.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 @@ -246,6 +261,15 @@ func parseRealtimeEvent(data []byte) realtimeEvent { return realtimeEvent{kind: realtimeOther} } +func (o *openAIRealtimeTranscriber) writeJSON(ctx context.Context, conn *websocket.Conn, v any) error { + if o.cfg.writeErrInjector != nil { + if err := o.cfg.writeErrInjector(); err != nil { + return err + } + } + return writeJSON(ctx, conn, v) +} + func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error { data, err := json.Marshal(v) if err != nil { diff --git a/internal/dictation/transcriber_openai_realtime_test.go b/internal/dictation/transcriber_openai_realtime_test.go index b2f6df06b..4b7ba66f7 100644 --- a/internal/dictation/transcriber_openai_realtime_test.go +++ b/internal/dictation/transcriber_openai_realtime_test.go @@ -5,11 +5,157 @@ import ( "errors" "strings" "sync" + "sync/atomic" "testing" + "time" "github.com/coder/websocket" ) +// Non-cancellation dial failure: handshake returns 101 with an Upgrade header +// equal to the API key so the dial error embeds the key and redaction must strip it. +func TestOpenAIRealtimeDialFailureRedactsKey(t *testing.T) { + const key = "sk-test-dial-key-abcdef" + baseURL := dialFailServerWithUpgrade(t, key) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: key, BaseURL: baseURL}) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, nil) + if ferr == nil { + t.Fatal("expected dial failure") + } + if errors.Is(ferr, context.Canceled) { + t.Fatalf("dial failure should not be context.Canceled: %v", ferr) + } + if strings.Contains(ferr.Error(), key) { + t.Errorf("API key leaked from dial failure: %v", ferr) + } + if !strings.Contains(ferr.Error(), "connecting to OpenAI Realtime") { + t.Errorf("expected dial-path error prefix, got: %v", ferr) + } +} + +// Session-configuration write fails after dial: inject a write error that embeds +// the API key (peer close cannot reach this path reliably — the session write +// always lands in the kernel buffer first) and assert redaction. +func TestOpenAIRealtimeSessionConfigFailureRedactsKey(t *testing.T) { + const key = "sk-test-session-key-abcdef" + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + // Hold open; the client fails on the injected session write before reading. + <-ctx.Done() + }) + + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{ + APIKey: key, + BaseURL: url, + writeErrInjector: func() error { + return errors.New("invalid API key " + key) + }, + }) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte) + close(chunks) + _, ferr := tr.StreamTranscribe(context.Background(), chunks, nil) + if ferr == nil { + t.Fatal("expected session-config failure") + } + if errors.Is(ferr, context.Canceled) { + t.Fatalf("session-config failure should not be context.Canceled: %v", ferr) + } + if strings.Contains(ferr.Error(), key) { + t.Errorf("API key leaked from session-config failure: %v", ferr) + } + if !strings.Contains(ferr.Error(), "configuring OpenAI Realtime session") { + t.Errorf("expected session-config error prefix, got: %v", ferr) + } +} + +// Asynchronous writer failure: session update succeeds, then the audio writer +// injects a key-bearing write error. The server keeps the connection open and +// emits a delta so the reader loop observes writeErrCh (not a peer close). +func TestOpenAIRealtimeWriterFailureRedactsKey(t *testing.T) { + const key = "sk-test-writer-key-abcdef" + sessionDone := make(chan struct{}) + url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { + if _, _, err := c.Read(ctx); err != nil { + return + } + close(sessionDone) + // Keep reading and emit a delta so the client processes an event and + // drains writeErrCh while the connection is still open. + _ = c.Write(ctx, websocket.MessageText, []byte( + `{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`, + )) + for { + if _, _, err := c.Read(ctx); err != nil { + return + } + } + }) + + var writes atomic.Int32 + tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{ + APIKey: key, + BaseURL: url, + writeErrInjector: func() error { + // First write is session.update; fail subsequent audio/commit writes. + if writes.Add(1) == 1 { + return nil + } + return errors.New("invalid API key " + key) + }, + }) + if err != nil { + t.Fatal(err) + } + + chunks := make(chan []byte, 1) + chunks <- make([]byte, 480) + // Leave open after the first frame so the writer hits the injected error + // on that append rather than a clean commit. + + done := make(chan error, 1) + go func() { + _, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {}) + done <- ferr + }() + + var ferr error + select { + case ferr = <-done: + case <-time.After(5 * time.Second): + close(chunks) + t.Fatal("StreamTranscribe did not complete after writer failure") + } + close(chunks) + + if ferr == nil { + t.Fatal("expected writer failure") + } + if errors.Is(ferr, context.Canceled) { + t.Fatalf("writer failure should not be context.Canceled: %v", ferr) + } + if strings.Contains(ferr.Error(), key) { + t.Errorf("API key leaked from writer failure: %v", ferr) + } + if !strings.Contains(ferr.Error(), "OpenAI Realtime stream error") { + t.Errorf("expected stream/writer error prefix, got: %v", ferr) + } + select { + case <-sessionDone: + default: + // Session may race the injected audio write; prefix assertion above is enough. + } +} + func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) { url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) { defer c.Close(websocket.StatusNormalClosure, "")