From 9ac33560dcb8d9d18ca25885e057f5f3e33511e5 Mon Sep 17 00:00:00 2001 From: Angus Kola Date: Thu, 5 Nov 2026 20:39:19 -0500 Subject: [PATCH] Share verified reading context announcements with Remote --- docs/tentacle-remote-speech.md | 36 +++- internal/app/reading_context.go | 62 ++++-- .../app/speech_context_observation_test.go | 177 ++++++++++++++++++ internal/app/speech_delivery.go | 2 +- internal/app/speech_observation_test.go | 10 +- internal/app/speech_worker.go | 3 + .../app/tentacle_remote_connection_test.go | 17 +- 7 files changed, 287 insertions(+), 20 deletions(-) create mode 100644 internal/app/speech_context_observation_test.go diff --git a/docs/tentacle-remote-speech.md b/docs/tentacle-remote-speech.md index a03e369..4a3456e 100644 --- a/docs/tentacle-remote-speech.md +++ b/docs/tentacle-remote-speech.md @@ -98,8 +98,7 @@ in an assembled line must have a known role and no password ancestry. Say All classifies all spoken lines before admission; a sensitive or unclassified line keeps the entire batch private, including sentence-mode continuations. Local navigation failures stay private. Derived phonetic hints retain their parent's -origin and original observation ticket. Reading-context announcements, -separate key-name echo and notification paths +origin and original observation ticket. Separate key-name echo and notification paths still need explicit source and sensitivity review. Visual-line assembly now retains each contributing object's complete captured @@ -186,8 +185,26 @@ keyboard, audible speech and real NVDA/Orca acceptance remain pending. Say All exports each resolved chunk immediately before its completion-paced backend call. Sentence boundaries, pronunciation and the original request's lease/generation are retained. A lease replacement during reading cannot export -later chunks to the new helper; local reading continues. Context announcements -remain private, including labels remembered from an earlier reading context. +later chunks to the new helper; local reading continues. + +Reading-context entry and exit announcements now export only for an admitted, +exportable local request whose retained contexts and their ancestry have known, +nonprivate roles and are not defunct. Classification of the original source +still precedes presentation filtering. This includes navigation, named article +context, spelling and Say All, under their existing context settings, counts, +deduplication and utterance order. Context labels retain the exact text submitted +to the local backend; that existing path does not apply pronunciation replacement. +The change adds no new source classification or backend/voice metadata. + +The serialized speech delivery owner retains an observation ticket alongside +its current context labels. Every known context replacement replaces this ticket; +context reset clears both. Cached exit labels use the previous context's original +ticket, while still requiring an eligible current request and current admission. +Labels read before consent, under private/unclassified requests or under a retired +lease cannot acquire later consent on exit. Nil/unknown context retains existing +local state without granting new authority. Policy, expiry and speech generation +are rechecked by the normal observation path. Local labels, system voice, focus, +caret and Braille are unchanged; private labels remain readable locally. Observation runs after speech-mode suppression, line/indentation processing, pronunciation, spelling expansion and backend character-path selection. It @@ -540,6 +557,17 @@ tests; it is not a pass. The source-command race run needed approval for local fixture sockets; its approved rerun passed. No native desktop, hardware or audible checks were performed. See the [live Where Am I steps](../../cthulhu-tentacles/remote/command.md#physical-keyboard-acceptance-still-pending). +The reading-context increment reproduced missing ordinary export before the +implementation. Focused application Remote, speech, reading, context-settings, +Say All, spelling and interruption race regressions passed, including ten +repetitions of context privacy, reconnect and source-command/private-relay cases. +The external Remote race suite, existing speech regression harness, application +vet, a separate reader build and Tentacles `make check` passed. The relay fixture +now supplies reading settings and verifies ordinary context entry/exit output +while private labels and their cached exits stay local. No full reader suite, +native desktop, hardware or audible acceptance was performed. See the +[live context steps](../../cthulhu-tentacles/remote/command.md#physical-keyboard-acceptance-still-pending). + Next: verify real Speech Dispatcher scheduling and audible local/remote arbitration. Physical input capture/release and ordinary reading while connected still need acceptance. diff --git a/internal/app/reading_context.go b/internal/app/reading_context.go index 6f7f650..c38fb0a 100644 --- a/internal/app/reading_context.go +++ b/internal/app/reading_context.go @@ -17,6 +17,19 @@ func (worker *speechWorker) context_messages(ancestors []atspi.Snapshot) []strin } func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, sayAll bool) []string { + var messages []string + for _, message := range worker.context_announcements_for(ancestors, sayAll) { + messages = append(messages, message.text) + } + return messages +} + +type readingContextMessage struct { + text string + leaving bool +} + +func (worker *speechWorker) context_announcements_for(ancestors []atspi.Snapshot, sayAll bool) []readingContextMessage { if ancestors == nil || worker.reading == nil { return nil } @@ -28,6 +41,9 @@ func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, say } previous := worker.speechContext worker.speechContext = current + // A new capture replaces both labels and their authority. Callers without + // an admitted local request cannot authorize later cached exit labels. + worker.speechContextObservation = nil settings := worker.reading() contains := func(values []atspi.Snapshot, candidate atspi.Snapshot) bool { for _, item := range values { @@ -37,9 +53,9 @@ func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, say } return false } - var messages []string + var messages []readingContextMessage panelMessages := make(map[string]bool) - appendMessage := func(item atspi.Snapshot, message string) { + appendMessage := func(item atspi.Snapshot, message string, leaving bool) { // Keep every container identity for later transitions, but say an // identical panel label only once in this transition. if presentation.ContextType(item) == "panel" { @@ -48,7 +64,7 @@ func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, say } panelMessages[message] = true } - messages = append(messages, message) + messages = append(messages, readingContextMessage{text: message, leaving: leaving}) } enabled := presentation.ContextEnabled verbosity := 0 @@ -62,13 +78,13 @@ func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, say // Article labels can contain an entire chat message. Python announces // them on entry only; repeating the label on exit obscures the next one. if item.Role != "article" && !contains(current, item) && enabled(presentation.ContextType(item), settings) { - appendMessage(item, "Leaving "+presentation.ContextLabel(item)+".") + appendMessage(item, "Leaving "+presentation.ContextLabel(item)+".", true) } } for index := len(current) - 1; index >= 0; index-- { item := current[index] if !contains(previous, item) && enabled(presentation.ContextType(item), settings) { - appendMessage(item, presentation.ContextCountLabel(item, verbosity)) + appendMessage(item, presentation.ContextCountLabel(item, verbosity), false) } } return messages @@ -77,9 +93,10 @@ func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, say func (worker *speechWorker) deliver_context(ctx context.Context, request speechRequest) error { if request.ResetContext { worker.speechContext = nil + worker.speechContextObservation = nil return nil } - return worker.deliver_context_for(ctx, request.Context, false) + return worker.deliver_context_for(ctx, request, request.Context, false) } func (browser *browserModeController) speech_context_enabled() bool { @@ -98,16 +115,39 @@ func (queue contextSpeechQueue) Enqueue(request speechRequest) { queue.speechEnqueuer.Enqueue(request) } -func (worker *speechWorker) deliver_context_for(ctx context.Context, ancestors []atspi.Snapshot, sayAll bool) error { - for _, message := range worker.context_messages_for(ancestors, sayAll) { - worker.logDelivery(ctx, speechRequest{Role: "system", Cause: "reading-context", Append: true}, message, -1) +func (worker *speechWorker) deliver_context_for(ctx context.Context, request speechRequest, ancestors []atspi.Snapshot, sayAll bool) error { + previousObservation := worker.speechContextObservation + messages := worker.context_announcements_for(ancestors, sayAll) + if ancestors != nil && worker.reading != nil { + // Source classification precedes presentation filtering. Independently + // check every retained context, including its unfiltered ancestry. + eligible := exportable_speech(request) + for _, ancestor := range ancestors { + eligible = eligible && accessibility_speech_origin(ancestor) == speechOriginLocal + } + if eligible { + worker.speechContextObservation = request.observation + } + } + for _, message := range messages { + delivery := request + delivery.Role, delivery.Cause, delivery.Append = "system", "reading-context", true + delivery.Character, delivery.CustomEcho = false, false + delivery.observation = worker.speechContextObservation + if message.leaving && delivery.observation != nil { + // Cached labels retain their original lease. New consent may admit + // current content, but cannot adopt labels from an earlier session. + delivery.observation = previousObservation + } + worker.logDelivery(ctx, delivery, message.text, -1) + worker.observe_speech(ctx, delivery, message.text, false) if voiced, ok := worker.speaker.(interface { SpeakVoice(context.Context, string, string) error }); ok { - if err := voiced.SpeakVoice(ctx, message, "system"); err != nil { + if err := voiced.SpeakVoice(ctx, message.text, "system"); err != nil { return err } - } else if err := worker.speaker.Speak(ctx, message); err != nil { + } else if err := worker.speaker.Speak(ctx, message.text); err != nil { return err } } diff --git a/internal/app/speech_context_observation_test.go b/internal/app/speech_context_observation_test.go new file mode 100644 index 0000000..500f532 --- /dev/null +++ b/internal/app/speech_context_observation_test.go @@ -0,0 +1,177 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "encoding/json" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" +) + +func TestRemoteSpeechContextObservationTransitions(t *testing.T) { + worker, session, _, _ := observation_fixture(t) + worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } + list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"} + for _, request := range []speechRequest{ + {origin: speechOriginLocal, Text: "Inside", Context: []atspi.Snapshot{list}}, + {origin: speechOriginLocal, Text: "Still inside", Context: []atspi.Snapshot{list}}, + {origin: speechOriginLocal, Text: "Outside", Context: []atspi.Snapshot{}}, + } { + if err := worker.EnqueueAndWait(t.Context(), request); err != nil { + t.Fatal(err) + } + } + for _, want := range []string{"Items list", "Inside", "Still inside", "Leaving Items list.", "Outside"} { + if got := <-worker.speaker.(*observationSpeaker).spoken; got != want { + t.Fatalf("local context speech = %q, want %q", got, want) + } + if got := observed_speech(t, session); got.Kind != "speak" || got.Parts[0].Text != want || !got.Parts[1].End || got.Parts[0].Characters { + t.Fatalf("observed context speech = %+v, want %q", got, want) + } + } + worker.InterruptForUser() + if got := observed_speech(t, session); got.Kind != "cancel" { + t.Fatal("unexpected context output", got) + } +} + +func TestRemoteSpeechContextObservationPrivateSources(t *testing.T) { + for _, privacy := range []string{"unclassified", "password", "unknown", "invalid", "defunct", "ancestor", "history", "remote", "reset"} { + t.Run(privacy, func(t *testing.T) { + worker, session, _, _ := observation_fixture(t) + worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } + list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Private"} + request := speechRequest{origin: speechOriginLocal, Text: "Inside", Context: []atspi.Snapshot{list}} + switch privacy { + case "unclassified": + request.origin = speechOriginUnknown + case "password": + // Presentation may retain only an article/list while discarding + // a private outer ancestor. Preserve the original classification. + request.origin = accessibility_speech_origin(atspi.Snapshot{Role: "text", SpeechContext: []atspi.Snapshot{list, {Role: "password text"}}}) + case "unknown", "invalid": + request.Context = append(request.Context, atspi.Snapshot{Role: privacy}) + case "defunct": + request.Context[0].Defunct = true + case "ancestor": + request.Context[0].SpeechContext = []atspi.Snapshot{{Role: "password text"}} + case "history": + request.ExcludeFromHistory = true + case "remote": + request.origin = speechOriginRemote + case "reset": + request.ResetContext = true + } + // Unknown/defunct ancestry is checked independently even if a + // producer classified only its source. Keep content private here + // so this test isolates context admission and cached exit labels. + request.Text = "" + if err := worker.EnqueueAndWait(t.Context(), request); err != nil { + t.Fatal(err) + } + if err := worker.EnqueueAndWait(t.Context(), speechRequest{origin: speechOriginLocal, Text: "Outside", Context: []atspi.Snapshot{}}); err != nil { + t.Fatal(err) + } + if got := observed_speech(t, session); got.Kind != "speak" || got.Parts[0].Text != "Outside" { + t.Fatal("private context or cached label exported", got) + } + worker.InterruptForUser() + if got := observed_speech(t, session); got.Kind != "cancel" { + t.Fatal("unexpected private context output", got) + } + if privacy != "reset" { + if got := <-worker.speaker.(*observationSpeaker).spoken; got != "Private list" { + t.Fatal("privacy changed local context", got) + } + // The second request must retain ordinary local exit speech. + <-worker.speaker.(*observationSpeaker).spoken // Empty content. + if got := <-worker.speaker.(*observationSpeaker).spoken; got != "Leaving Private list." { + t.Fatal("privacy changed local exit", got) + } + } + }) + } +} + +func TestRemoteSpeechContextObservationCachedLabelsKeepOriginalLease(t *testing.T) { + for _, priorConsent := range []bool{false, true} { + t.Run(map[bool]string{false: "before-consent", true: "replacement"}[priorConsent], func(t *testing.T) { + worker, session, _, _ := observation_fixture(t) + worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } + revoke := func() { + t.Helper() + session.RevokeRemote() + var event tentaclewire.SessionEvent + if json.Unmarshal(observation_call(t, session, "event"), &event) != nil || event.Kind != "remote.revoked" { + t.Fatal("expected old lease revocation") + } + } + if !priorConsent { + revoke() + } + list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Old"} + if err := worker.EnqueueAndWait(t.Context(), speechRequest{origin: speechOriginLocal, Text: "Inside", Context: []atspi.Snapshot{list}}); err != nil { + t.Fatal(err) + } + if priorConsent { + for _, want := range []string{"Old list", "Inside"} { + if got := observed_speech(t, session); got.Kind != "speak" || got.Parts[0].Text != want { + t.Fatal("missing original lease output", got) + } + } + revoke() + } + approval, err := session.BeginRemoteApproval("controlled", []string{"output-observe"}) + if err != nil { + t.Fatal(err) + } + if _, err := session.AuthorizeRemoteWithApproval(approval, "controlled", []string{"output-observe"}); err != nil { + t.Fatal(err) + } + observation_call(t, session, "open") + if err := worker.EnqueueAndWait(t.Context(), speechRequest{origin: speechOriginLocal, Text: "Outside", Context: []atspi.Snapshot{}}); err != nil { + t.Fatal(err) + } + if got := observed_speech(t, session); got.Kind != "speak" || got.Parts[0].Text != "Outside" { + t.Fatal("cached exit label acquired new consent", got) + } + for _, want := range []string{"Old list", "Inside", "Leaving Old list.", "Outside"} { + if got := <-worker.speaker.(*observationSpeaker).spoken; got != want { + t.Fatalf("local speech = %q, want %q", got, want) + } + } + worker.InterruptForUser() + if got := observed_speech(t, session); got.Kind != "cancel" { + t.Fatal("unexpected replacement output", got) + } + }) + } +} + +func TestRemoteSpeechContextObservationSayAllAndSpelling(t *testing.T) { + worker, session, _, _ := observation_fixture(t) + worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } + list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"} + for _, request := range []speechRequest{ + {origin: speechOriginLocal, ReadAll: []readAllPart{{Text: "First", Context: []atspi.Snapshot{list}}, {Text: "Second", Context: []atspi.Snapshot{}}}}, + {origin: speechOriginLocal, Spelling: []string{"a", "a"}, Character: true, Context: []atspi.Snapshot{list}}, + } { + if err := worker.EnqueueAndWait(t.Context(), request); err != nil { + t.Fatal(err) + } + } + for index, want := range []string{"Items list", "First", "Leaving Items list.", "Second", "Items list", "a", "a"} { + if got := observed_speech(t, session); got.Kind != "speak" || got.Parts[0].Text != want || got.Parts[0].Characters != (index >= 5) { + t.Fatalf("context/chunk/spelling boundary lost, want %q: %+v", want, got) + } + } + worker.InterruptForUser() + if got := observed_speech(t, session); got.Kind != "cancel" { + t.Fatal("repeated context during spelling", got) + } +} diff --git a/internal/app/speech_delivery.go b/internal/app/speech_delivery.go index 8d755cc..3e9aff6 100644 --- a/internal/app/speech_delivery.go +++ b/internal/app/speech_delivery.go @@ -85,7 +85,7 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest) return err } if part.Context != nil && !request.ResetContext { - if err := worker.deliver_context_for(ctx, part.Context, true); err != nil { + if err := worker.deliver_context_for(ctx, request, part.Context, true); err != nil { return err } } diff --git a/internal/app/speech_observation_test.go b/internal/app/speech_observation_test.go index d347d50..5ad8d48 100644 --- a/internal/app/speech_observation_test.go +++ b/internal/app/speech_observation_test.go @@ -14,6 +14,7 @@ import ( "time" "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/tentacle" "git.stormux.org/storm/cthulhu/internal/tentaclewire" @@ -325,6 +326,7 @@ func TestRemoteSpeechObservationCancellationBarrier(t *testing.T) { func TestRemoteSpeechObservationQueuedAuthorityDoesNotFollowReconnect(t *testing.T) { worker, session, _, _ := observation_fixture(t) + worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading } speaker := &observationBarrierSpeaker{fakeSpeaker: fakeSpeaker{spoken: make(chan string, 8)}, began: make(chan struct{}), release: make(chan struct{})} worker.speaker = speaker @@ -343,13 +345,15 @@ func TestRemoteSpeechObservationQueuedAuthorityDoesNotFollowReconnect(t *testing close(speaker.release) } }() - worker.Enqueue(speechRequest{Text: "old consent", origin: speechOriginLocal}) + list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Old context"} + worker.Enqueue(speechRequest{Text: "old consent", origin: speechOriginLocal, Context: []atspi.Snapshot{list}}) session.RevokeRemote() var event tentaclewire.SessionEvent if err := json.Unmarshal(observation_call(t, session, "event"), &event); err != nil || event.Kind != "remote.revoked" { t.Fatal("expected old lease revocation", err) } - worker.Enqueue(speechRequest{Text: "before consent", origin: speechOriginLocal}) + list.Name = "Before consent" + worker.Enqueue(speechRequest{Text: "before consent", origin: speechOriginLocal, Context: []atspi.Snapshot{list}}) approval, err := session.BeginRemoteApproval("controlled", []string{"output-observe"}) if err != nil { t.Fatal(err) @@ -358,7 +362,7 @@ func TestRemoteSpeechObservationQueuedAuthorityDoesNotFollowReconnect(t *testing t.Fatal(err) } observation_call(t, session, "open") - worker.Enqueue(speechRequest{Text: "new consent", origin: speechOriginLocal}) + worker.Enqueue(speechRequest{Text: "new consent", origin: speechOriginLocal, Context: []atspi.Snapshot{}}) close(speaker.release) if speech := observed_speech(t, session); speech.Kind != "speak" || speech.Parts[0].Text != "new consent" { t.Fatal("queued output acquired replacement consent", speech) diff --git a/internal/app/speech_worker.go b/internal/app/speech_worker.go index 9baf889..13560cc 100644 --- a/internal/app/speech_worker.go +++ b/internal/app/speech_worker.go @@ -108,6 +108,9 @@ type speechWorker struct { speechContext []atspi.Snapshot // Same owner; replaced on each known navigation context. indentationCue func(sound.Cue) + // Owned with speechContext by the serialized delivery goroutine. + speechContextObservation *tentacle.RemoteSpeechOutput + mutex sync.Mutex enabled bool mode speechMode diff --git a/internal/app/tentacle_remote_connection_test.go b/internal/app/tentacle_remote_connection_test.go index f57ba0d..c3fd48a 100644 --- a/internal/app/tentacle_remote_connection_test.go +++ b/internal/app/tentacle_remote_connection_test.go @@ -23,6 +23,7 @@ import ( "time" "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/interaction" "git.stormux.org/storm/cthulhu/internal/review" @@ -353,6 +354,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { worker := runtime.output.speech worker.speaker = &observationSpeaker{fakeSpeaker{spoken: make(chan string, 64)}} worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") } + worker.reading = func() config.ReadingSettings { return runtime.output.live.Load().Reading } if err := worker.Start(t.Context()); err != nil { t.Fatal(err) } @@ -600,6 +602,19 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { t.Fatal(err) } } + list := atspi.Snapshot{BusName: ":1.42", Path: "/relay-list", Role: "list", Name: "Relay items"} + privateList := list + privateList.Path, privateList.Name = "/private-list", "Private relay label" + for _, request := range []speechRequest{ + {origin: speechOriginLocal, Text: "Relay inside", Context: []atspi.Snapshot{list}}, + {origin: speechOriginLocal, Text: "Relay outside", Context: []atspi.Snapshot{}}, + {origin: speechOriginSecret, Text: "Private relay content", Context: []atspi.Snapshot{privateList}}, + {origin: speechOriginLocal, Text: "Relay resumed", Context: []atspi.Snapshot{}}, + } { + if err := worker.EnqueueAndWait(t.Context(), request); err != nil { + t.Fatal(err) + } + } worker.InterruptForUser() var received strings.Builder ends, frames := 0, 0 @@ -635,7 +650,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { t.Fatal("reader output did not reach the relay in order") } } - if received.String() != "color "+remoteText+"First color.Second color.Current color.Native color.Review color.staticFlat color. Review color.Review color.staticNative color.text" || frames < 13 || ends != 12 { + if received.String() != "color "+remoteText+"First color.Second color.Current color.Native color.Review color.staticFlat color. Review color.Review color.staticNative color.textRelay items listRelay insideLeaving Relay items list.Relay outsideRelay resumed" || frames < 18 || ends != 17 { t.Fatalf("observation leaked private text or lost resolved output/boundaries: frames=%d ends=%d suffix=%q", frames, ends, received.String()[max(0, received.Len()-160):]) } }