Skip to content

feat(tts): add kotoba_tts_python extension (Kotoba TTS on fal) - #2357

Open
q-cheng wants to merge 20 commits into
TEN-framework:mainfrom
q-cheng:feat/kotoba-tts-python
Open

q-cheng wants to merge 20 commits into
TEN-framework:mainfrom
q-cheng:feat/kotoba-tts-python

Conversation

@q-cheng

@q-cheng q-cheng commented Oct 15, 2026 •

Copy link
Copy Markdown

Summary

Adds kotoba_tts_python, a TTS extension for Kotoba Japanese text-to-speech served on fal (Kotoba-Technologies/kotoba-tts).

  • WebSocket implementation on AsyncTTS2BaseExtension, built on the official kotoba-sdk client (>= 0.3.0).
  • Streams LLM text as sentences: deltas are buffered per request_id and each complete sentence becomes one service request; the remainder is sent on text_input_end.
  • One reused connection with reconnect after the service idle timeout; a sentence is retried once when the connection drops before any audio for it arrived.
  • Flush cancels the in-flight response (response.cancel), drops buffered text and reports tts_audio_end with INTERRUPTED.
  • Pending text is capped per request (params.max_pending_chars, default 5000 characters, about 15 minutes of speech): past it the request ends with a non-fatal pending_text_limit error and tts_audio_end(ERROR), and the next request starts normally.
  • PCM16 mono at 8 / 16 / 24 kHz (params.sample_rate), tts_audio_end duration computed once from emitted bytes, event interval from tts_audio_start.
  • Fatal errors: rejected key (fal closes with 1008 Unauthorized), unsupported language / sample rate. Non-fatal: worker and transport failures, a 429 on the handshake (rate_limited, retried once), a text the service rejects (invalid_text, not retried); the next request starts on a fresh connection.
  • One terminal sequence (_finalize) shared by completion, flush and shutdown: error, tts_audio_end, usage, state transition, dump flush. Each step records its completion, so a flush or stop that interrupts the worker completes only what is left; a request stays registered until its dump is closed, so on_stop can finish a dump that is still being written after the next request has started.
  • TTFB, request and usage metrics; per-request PCM dumps (unique file per request id, truncated on open).
  • Diagnostics never carry credentials: the key is fingerprinted, every params value and every URL query value is masked, userinfo is stripped.

Configuration

"params": {
  "key": "${env:KOTOBA_TTS_KEY}",
  "language": "ja",
  "speaker_id": "male-01",
  "sample_rate": 16000
}

See ai_agents/agents/ten_packages/extension/kotoba_tts_python/README.md.

Language. The public kotoba-tts app serves Japanese (language: "ja"). The guarder's built-in texts are English; the run below passes because the Japanese voice reads them, but the intended input is Japanese. The extension-local tests use Japanese text.

Tests

Extension-local tests (tests/) run against an in-process fake of the Kotoba service (tests/fake_kotoba_server.py) so the real SDK client is exercised: lifecycle, append / interleaved / late input (including late input while a request's usage is still in flight), exact audio-end accounting under a controlled clock, flush before and after audio, flush while the send or handshake is blocked, double flush with a pending cancel drain, stray audio after cancel, flush racing the terminal path (events landing in time, and a hung worker taken over with usage still reported), flush during streaming with the dump write held, stop during a dump flush that outlasts the join bound, a next request overlapping the previous request's dump flush then stop, flush and stop while the write itself is suspended, stop while a flush takeover cancels the worker shutdown is joining, late old-session cleanup with a replacement connection, idle close reported before the next text, reconnect without replay, failing status callbacks, split-sample alignment (never across a response boundary), unusable dump path, colliding and stale dump files, a 2,000-character input and a 30-sentence backlog with flush and recovery, endpoint secrets under arbitrary query names, init failure across several deltas, transient and persistent 429 on the handshake, service-side text rejection, error classification and secret redaction in logs and event metadata, metrics, dump.

tests/bin/start
120 passed, 1 xfailed

task lint-extension EXTENSION=kotoba_tts_python: 10.00/10. black -l 80 clean.

Outside the extension directory, the PR adds kotoba_tts_python to the TTS guarder's WebSocket allowlist (tests/bin/start, test_connection_status.py) so test_connection_status runs for it.

Guarder evidence

Extension: kotoba_tts_python, HEAD f6b492966728cca2b542d0794f2682c121cce6d8

task tts-guarder-test EXTENSION=kotoba_tts_python
31 passed, 2 skipped: all 16 extension tests pass; the other 15 are offline unit tests for other vendors' timing hooks; both skips are subtitle alignment (the service exposes no word timestamps)

Screenshot and .pcm output (16 kHz, 16-bit, mono) attached below.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T03:19:07.918395Z 96bc0d4 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@q-cheng

This comment was marked as outdated.

@github-actions

Copy link
Copy Markdown

[P1] Wait for the finishing worker before acknowledging flush

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:320

_end_request() sets request.ended = True before awaiting error reporting and _send_audio_end(). With dumping enabled, a flush can arrive while that worker awaits recorder.flush(). This early return lets the base emit tts_flush_end and clear metadata while the separate worker continues; it subsequently sends the old tts_audio_end after the flush boundary. Its later usage reset can also consume counters from a newly started request. Serialize or await the finishing worker before completing flush, with a regression that blocks the final dump write and flushes during it.

[P1] Complete the required guarder evidence before merge

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment promises a screenshot in a subsequent comment, but none is attached in the current comments. The PCM download is accessible and non-empty; the log alone does not satisfy the new-extension screenshot gate. Also, test_connection_status was skipped because the vendor is absent from the harness allowlist, although this extension implements connection-status callbacks and its local tests assert those events; that is not an absent capability. Attach the redacted command/result screenshot and provide passing connection-status evidence for the current head. These are merge blockers under the repository's TTS guarder evidence requirements.

[P2] Propagate cancellation while awaiting the previous response drain

ai_agents/agents/ten_packages/extension/kotoba_tts_python/kotoba_tts.py:364

Flush request A, start B while A's cancellation drain is pending, then flush B. Cancelling B's worker propagates cancellation into the awaited drain, but this handler swallows CancelledError. connect() therefore continues, reuses the incompletely drained socket, and submits B despite cancellation. Because _stop_worker() awaits that worker, the second flush can then wait for B's first audio or the full response timeout (30 seconds by default). Re-raise caller cancellation and ensure an interrupted drain cannot leave a reusable socket containing A's response; cover this double-flush ordering deterministically.

[P2] Flush request usage counters before starting the next request

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:327

The normal/error terminal path calls send_usage_metrics(), which resets the base's request counters, but cancellation only sends audio end. The base flush clears request state and metadata without resetting these counters. Consequently, flushing A after some audio and then completing B reports A+B's input_characters and recv_audio_duration under B's metadata, while A receives no final usage. Emit/reset A's request usage during cancellation and assert that B's usage contains only B's text and emitted PCM.

[P2] Capture the audio interval origin at audio-start emission

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:287

now is captured before awaiting send_tts_ttfb_metrics(), then reused as audio_started_at. Any delay sending that metrics event is therefore included in request_event_interval_ms, even though tts_audio_start has not been sent yet. Capture a fresh monotonic timestamp at the audio-start call. Add the required controlled-clock assertions for success, partial-error and interrupted paths; the current non-negative/positive interval assertions cannot establish the required start-to-end boundary.

[P2] Add the mandatory transport shutdown and ownership tests

ai_agents/agents/ten_packages/extension/kotoba_tts_python/tests/test_reconnect.py:16

Beyond the regressions above, the local suite does not exercise flush/shutdown while handshake or send is blocked, failure in the disconnect-reporting callback, or cancellation of old-session work after a replacement connection exists. These are explicit merge-gating cases in ten-tts-review/references/tests.md; completed-handshake flushes and sequential reconnect tests do not cover them. Add controllable transport/callback blockers and assert bounded cleanup, no old audio or terminal event after flush, and preservation of the replacement connection for a successful subsequent request.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f86171c01d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@q-cheng
q-cheng marked this pull request as draft October 16, 2026 02:05
@q-cheng
q-cheng force-pushed the feat/kotoba-tts-python branch from f86171c to 4c8ef0a Compare October 16, 2026 02:39
@github-actions

Copy link
Copy Markdown

[P1] Mark the response active before awaiting the text send

ai_agents/agents/ten_packages/extension/kotoba_tts_python/kotoba_tts.py:277

If flush cancels the worker while session.synthesize(text) is suspended after writing to the socket, CancelledError bypasses except Exception and _response_active remains false. cancel() then skips cancellation/draining, so the next request can reuse a socket containing the interrupted response and emit its audio under the new request ID. Mark the operation active before sending, or discard the session when sending is cancelled; test cancellation with a blocked send.

[P1] Await the terminalizing worker before acknowledging flush

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:320

_end_request() sets request.ended before awaiting error delivery, recorder flushing, and audio-end delivery. A flush during those awaits takes this early return without stopping or joining the separate worker. The base can therefore emit tts_flush_end and clear metadata before the worker emits the old request's terminal event; its later usage reset can also consume a new request's counters. Track terminal delivery separately and await or cancel the worker before completing flush.

[P1] Supply current guarder evidence and the required screenshot

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment cites f86171c, whereas the current head is 4c8ef0a, and the promised result screenshot is still absent. The linked PCM is accessible and non-empty, but it belongs to that older run. Also, connection-status coverage was skipped because of the harness allowlist even though this extension implements that capability. The new-extension evidence gate requires a passing run for the current head, a redacted command/result screenshot, downloadable PCM with playback settings, and justified capability skips. This remains a merge blocker.

[P2] Propagate caller cancellation while awaiting a response drain

ai_agents/agents/ten_packages/extension/kotoba_tts_python/kotoba_tts.py:364

Flush A, start B while A's cancellation drain is pending, then flush B. Cancelling B propagates cancellation into the awaited drain, but this handler swallows it and lets connect() continue on the incompletely drained socket. B can submit text despite cancellation, and the second flush waits for that worker to reach audio or its response timeout. Re-raise caller cancellation and ensure an interrupted drain leaves no reusable stale session; add a deterministic double-flush regression.

[P2] Redact retained credentials and signed URLs before logging configuration

ai_agents/agents/ten_packages/extension/kotoba_tts_python/config.py:110

to_str() masks only key and api_key. A signed/custom base_url, or credentials supplied in retained unknown parameters such as token or authorization, remain in the representation logged by on_init(). This writes those credentials to runtime logs even when sensitive handling is enabled. Sanitize URL credentials and sensitive retained parameter values, and cover those inputs in the redaction tests.

[P2] Emit and reset interrupted usage before accepting the next request

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:327

Cancellation sends audio end without calling send_usage_metrics(), unlike normal/error completion. The base flush clears state and metadata but does not reset its character and audio-byte counters. After flushing A and completing B, B's request usage therefore contains A+B, while A receives no final usage. Emit/reset A's usage before cancellation returns and assert that the following request contains only its own text and emitted PCM.

[P2] Capture the event-interval origin at audio-start emission

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:287

now is captured before awaiting send_tts_ttfb_metrics() and then reused as audio_started_at. Any metrics-delivery delay is included in request_event_interval_ms before tts_audio_start has been emitted, violating the required start-to-end boundary. Capture a fresh monotonic timestamp at audio-start emission and add controlled-clock assertions for success, partial-error, and interrupted completion; the existing positive/non-negative checks cannot detect this.

[P2] Start TTFB timing at the vendor text request

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:245

This timestamp precedes both the awaited request-metrics emission and the lazy generator's connection/authentication handshake. First requests and reconnects therefore report local metrics backpressure and connection setup as synthesis TTFB. Capture the origin at the client's actual text-send boundary and carry it back to the extension; independently delay handshake, metrics delivery, and first audio in the test.

[P2] Add the mandatory blocked-transport and connection-ownership cases

ai_agents/agents/ten_packages/extension/kotoba_tts_python/tests/test_reconnect.py:16

The local suite does not exercise shutdown/flush during a blocked handshake or send, failure while reporting a disconnect, or cancellation of old-session work after a replacement connection exists. These are explicit merge-gating cases in .agents/skills/ten-tts-review/references/tests.md; completed-handshake flushes and sequential reconnects do not cover them. Add controllable transport/callback blockers and assert bounded cleanup, no stale audio/end after flush, and preservation of the replacement connection for a successful next request.

@github-actions

Copy link
Copy Markdown

[P1] Supply guarder evidence for the current head and finish the screenshot upload

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The evidence comment identifies 4c8ef0a, while the current head is dc2c879. Its screenshot is still an empty ![Uploading …]() placeholder. The PCM download is accessible and non-empty, but belongs to the older run, which also skipped connection-status coverage despite this extension supporting it. The new-extension gate in .agents/skills/ten-tts-review/references/guarder.md therefore remains a merge blocker. Run the full suite against the current head, including connection status, and attach the completed result screenshot and that run's PCM with playback settings.

[P2] Sanitize the endpoint before returning vendor metadata

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:132

For a custom endpoint such as the credential-bearing URL already used in test_params.py, this returns the original userinfo and signature. The base's update_metadata() only applies JSON-key redaction; it does not sanitize a URL stored under url. Consequently, request metrics, connection events, audio events, and their logs expose those credentials even though config.to_str() masks them. Return a sanitized endpoint snapshot and assert that emitted metadata contains neither URL credentials nor query secrets.

[P2] Handle dump-directory failures without stranding the request queue

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:207

With dumping enabled, an unwritable dump_path, or a path that names an existing file, makes os.makedirs() raise before _request and its worker are installed. The exception escapes request_tts(); the base queue logs it but leaves _processing_request_id active. This request receives no terminal event, and subsequent request IDs remain buffered until a flush. Catch dump setup failures and either disable recording or finish the request with an error. Add a regression that fails directory creation and then completes another request.

[P2] Report remote idle closure without waiting for another text request

ai_agents/agents/ten_packages/extension/kotoba_tts_python/kotoba_tts.py:248

After reporting connected, the client does not observe the SDK receiver's termination while idle. When the service closes an idle socket, _session remains set and the base stays CONNECTED; disconnection is reported only when a later synthesis attempts to use the dead session, or during shutdown. This leaves connection-status consumers with stale availability for the entire idle period. Observe transport termination, retire only that session, and report disconnected promptly. Test that an idle close produces the event before sending any subsequent text.

[P2] Exercise actual blocked writes and replacement-connection ownership

ai_agents/agents/ten_packages/extension/kotoba_tts_python/tests/test_cancellation.py:62

hold_response_index stalls the server response after accepting the text; it does not suspend AsyncTTSSession.synthesize() or the underlying write. Thus this test does not exercise the new except CancelledError send-cleanup branch. The suite also lacks old-session cancellation after a replacement is already connected. These remain mandatory transport cases under .agents/skills/ten-tts-review/references/tests.md. Gate the actual send coroutine, cover flush and shutdown during the write, and assert that delayed old-session cleanup preserves the replacement and cannot publish stale audio.

[P2] Complete exact accounting coverage for error and interrupted termination

ai_agents/agents/ten_packages/extension/kotoba_tts_python/tests/test_accounting.py:90

The controlled-clock test covers successful completion only. The partial-error and interrupted cases here assert duration but never assert the matching start-to-end interval; the other flush test only checks that it is positive. All audio fixtures also contain complete, millisecond-aligned samples, leaving alignment and rounding accounting untested. The mandatory local-test matrix requires exact assertions for these paths. Add controlled elapsed-time checks for error/interruption and split-sample or sub-millisecond chunks, checking duration against the PCM actually emitted and ensuring no residual sample crosses requests.

@github-actions

Copy link
Copy Markdown

[P1] Refresh guarder evidence for the current head

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment identifies dc2c879, while the current head is 9b460c3. Its attached screenshot actually shows 15 passed, 2 skipped, including test_connection_status SKIPPED, contradicting the stated 16 passed, 1 skipped. The PCM download is accessible and non-empty, but also identifies the older run. This remains a merge blocker under the repository's new-extension evidence gate: run the full guarder suite against the current head, including connection status, and attach that run's matching screenshot and PCM with playback settings.

[P2] Sanitize SDK error messages before forwarding them

ai_agents/agents/ten_packages/extension/kotoba_tts_python/kotoba_tts.py:100

For a signed or credential-bearing custom endpoint that fails DNS resolution or refuses the connection, SDK 0.2.3 raises APIError("Could not reach <raw URL>: ..."). This branch preserves that URL verbatim; it reaches disconnect vendor_info, retry logs, and the final error message. Neither redacted_base_url() nor the base's metadata redaction sanitizes those strings, so endpoint credentials leak on an ordinary transport failure despite being masked on successful requests. Sanitize SDK diagnostics before logging/emitting them and add a failed-connection regression using a credential-bearing URL.

[P2] Retire a closed session after its active response finishes

ai_agents/agents/ten_packages/extension/kotoba_tts_python/kotoba_tts.py:271

If the server sends response.done followed by a clean close while the consumer is still draining queued events, the receiver finishes with _response_active true and this watcher exits without retiring the session. synthesize() subsequently consumes the successful done/sentinel, clears _response_active, and returns without checking the closed receiver. Consequently, _session and connection status remain connected until another text request or shutdown. Recheck transport termination when the active response finishes, and test an immediate close after done while audio consumption is delayed.

[P2] Exercise old-session cleanup after the replacement is connected

ai_agents/agents/ten_packages/extension/kotoba_tts_python/tests/test_cancellation.py:495

This test delays the server's cancelled response, but B's connect() first awaits A's drain. The drain times out, closes A's SDK session and cancels its receiver before B's connection is created. A later server write therefore cannot exercise client cleanup against an already-connected replacement, so the test does not protect the session-identity guards it claims to cover. The mandatory transport matrix requires this ordering: use a controllable session/cleanup gate to release old-session cleanup or cancellation after the replacement connects, then assert that the replacement remains usable and emits no stale audio/events.

[P2] Cover dump finalization on large streams and partial errors

ai_agents/agents/ten_packages/extension/kotoba_tts_python/tests/test_dump.py:22

The dump-enabled tests cover small successful responses and early flushes; none forces output past the writer's 64 KiB automatic-flush threshold or enables dumping during a vendor failure after partial audio. Thus the mandatory long-stream and error-terminal dump checks are absent, leaving buffered-tail preservation and cleanup during an outstanding write unverified. Add deterministic large-stream/slow-writer cases and a partial vendor error, asserting that finalized files exactly match emitted PCM and that a subsequent request remains isolated.

@github-actions

Copy link
Copy Markdown

[P1] Refresh guarder evidence for the current head and attach the screenshot

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment identifies 9b460c3, while the current head is 57ede877. It says the screenshot follows in another comment, but no current comment contains that screenshot. The linked PCM downloads successfully and is non-empty, but belongs to the older run. This remains a merge blocker under .agents/skills/ten-tts-review/references/guarder.md: run the full guarder suite against the current head and attach its result screenshot and matching PCM, identifying the SHA and playback settings.

[P2] Sanitize SDK diagnostics before forwarding them

ai_agents/agents/ten_packages/extension/kotoba_tts_python/kotoba_tts.py:100

For a credential-bearing custom endpoint that fails DNS resolution or refuses the connection, SDK 0.2.3 raises APIError("Could not reach <raw URL>: ..."). This branch preserves that URL verbatim, and connect() forwards it in disconnect vendor_info; retries also log it, and terminal errors expose it again. Sanitizing vendor_metadata() does not sanitize these strings, so URL passwords and signed query values leak on an ordinary transport failure. Sanitize diagnostics before logging or emitting them, and add a failed-connection regression using a credential-bearing endpoint.

[P2] Retire a closed transport after its active response finishes

ai_agents/agents/ten_packages/extension/kotoba_tts_python/kotoba_tts.py:267

If the server sends response.done and then closes cleanly while the consumer is still draining queued audio, the SDK receiver ends while _response_active is true. This watcher exits without retiring the session. synthesize() then consumes the successful done/sentinel, clears _response_active, and returns without rechecking the receiver. Consequently, _session and connection status remain connected until another request or shutdown. Recheck transport termination when the response finishes, preserving session ownership, and cover an immediate close after done with delayed audio consumption.

[P2] Finalize the recorder when flush takes over terminal cleanup

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:395

With dumping enabled, let the worker enter _end_request() and stall in send_tts_audio_end() longer than _TERMINAL_JOIN_SECONDS, then flush. _join_terminal_worker() cancels the worker before it reaches _close_recorder(). This branch emits the replacement end event, clears _request, and returns without flushing the recorder; shutdown can no longer find it. For a short request, all emitted PCM is still buffered and the dump is lost entirely. Close the recorder on the takeover path, including cancellation-safe cleanup. Extend the existing hanging-terminal test with dumping enabled and assert exact finalized PCM.

[P2] Exercise old-session cleanup after the replacement connects

ai_agents/agents/ten_packages/extension/kotoba_tts_python/tests/test_cancellation.py:495

This test delays the server's cancelled response, but B's connect() first awaits A's drain. That drain times out, closes A's SDK session, and cancels its receiver before B's connection is created. A later server write therefore cannot exercise client cleanup against an already-connected replacement. The mandatory transport matrix in .agents/skills/ten-tts-review/references/tests.md remains uncovered. Gate old-session cleanup or cancellation itself, release it after B connects, and assert that B remains usable with no stale audio or disconnect events.

[P2] Cover dump finalization while an automatic write is pending

ai_agents/agents/ten_packages/extension/kotoba_tts_python/tests/test_dump.py:22

The dump-enabled local tests use short responses or interrupt after a few chunks; none reaches the writer's 64 KiB automatic-flush boundary or holds an actual write pending during finalization. The new partial-error test also uses only 3,200 bytes. This leaves the required large-stream and cancellation-under-load behavior unprotected, particularly whether buffered tails survive completion or flush while a write is outstanding. Add deterministic output beyond that threshold with a controlled slow writer, assert finalized files exactly match emitted PCM, and verify a subsequent request stays isolated.

@q-cheng
q-cheng marked this pull request as ready for review October 16, 2026 06:39

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 57ede877a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -0,0 +1,72 @@
{
"type": "extension",
"name": "kotoba_tts_python",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Attach current live guarder evidence for Kotoba

I inspected the supplied PR comments for this new TTS extension, but none provides current-HEAD evidence for the full live task tts-guarder-test EXTENSION=kotoba_tts_python run: the final counts, result screenshot, downloadable non-empty PCM, and its sample format are all absent. Add those artifacts before merging so real-service compatibility is established for the reviewed commit.

AGENTS.md reference: AGENTS.md:L65-L68

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot is attached in the comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

@q-cheng
q-cheng marked this pull request as draft October 16, 2026 06:51
@github-actions

Copy link
Copy Markdown

[P1] Refresh guarder evidence for the current head and attach its screenshot

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment identifies 57ede877, while the current head is 5765cce6. Its linked log reports 16 passed and 1 skipped, and the PCM downloads successfully (929,280 bytes), but both identify the older run. The promised result screenshot is also absent from the current issue and review comments. This remains a merge blocker under the new-extension evidence gate: run the full guarder suite against the current head and attach that run's command/result screenshot and matching PCM, identifying the tested SHA and playback settings.

[P2] Preserve recorder ownership when terminal cleanup is cancelled

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:537

_close_recorder() clears request.recorder before awaiting the flush. For a large response, an automatic write can still be pending while later emitted PCM remains in the writer's buffer. If shutdown arrives during terminal cleanup and that write exceeds _TERMINAL_JOIN_SECONDS, _join_terminal_worker() cancels the worker and its awaited flush; the subsequent _close_recorder() in on_stop() sees None, so the buffered tail is never written. Retain ownership until finalization completes and make pending-write cleanup survive cancellation. Extend the shutdown test with an outstanding automatic write held beyond the join timeout, and assert the finalized file equals all emitted PCM.

[P2] Complete usage cleanup when flush takes over a terminal worker

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:421

Flush can arrive after _send_audio_end() sets audio_end_sent, while its later send_usage_metrics() is suspended. If the join times out, the worker is cancelled before the base helper reaches metrics_reset(). This branch then skips _send_audio_end() because the audio-end flag is already set and returns without completing usage cleanup; base flush clears request state but leaves those counters intact. The next request consequently reports the interrupted request's characters/audio in its own usage. Track terminal-event delivery and usage completion separately, and ensure takeover emits/resets the old request's usage exactly once. Add a regression that blocks usage delivery through the join timeout and then completes a second request.

[P2] Cover the required vendor rejection and recovery cases locally

ai_agents/agents/ten_packages/extension/kotoba_tts_python/tests/test_error_msg.py:122

The local error tests cover authentication, unsupported language, transport failures, and worker_unavailable, but none exercises quota/rate limiting or a vendor rejection of otherwise valid text input. These are mandatory cases in the extension-local error matrix; invalid configuration tests do not cover service-side input rejection. Add deterministic failures for both categories and assert severity/vendor information, bounded retry, exactly one terminal event, clean request counters, and a successful following request.

@github-actions

Copy link
Copy Markdown

[P1] Refresh guarder evidence for the current PR head

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment identifies 5765cce6, while the current head is 32ad1a1b. The screenshot is accessible, the log reports 16 passed and 1 skipped, and the PCM downloads successfully (1,003,520 bytes), but this evidence still identifies the older implementation. This remains a merge blocker under the new-extension evidence gate. Run the full guarder suite against the current head and attach that run's command/result screenshot and matching PCM, identifying the tested SHA and playback settings.

[P2] Prevent queued requests from starting during shutdown cleanup

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:150

With dumping enabled, keep P in a slow terminal dump flush, have A currently synthesizing and B buffered, then stop. This snapshot includes P and A, but the base input loop keeps running until super().on_stop(). While shutdown awaits P, A can complete and its finish_request() releases B. B starts outside the snapshot; the later unconditional self._request = None also prevents base flush from finding it. Its worker is never explicitly stopped and its recorder is never finalized, losing buffered PCM. Block new request starts as soon as shutdown begins and retain ownership of every worker until cleanup completes. Add a regression with a slow previous dump and a queued successor that starts while shutdown waits.

[P2] Give each dump a collision-resistant filename

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:274

Distinct valid request IDs such as a/b and a_b both resolve to kotoba_tts_dump_a_b.pcm. PCMWriter appends to existing files, so sequential requests concatenate their audio into one dump; overlapping finalization can also write both requests to that file. Reusing an ID in a later extension session has the same problem. The resulting dump no longer equals the PCM emitted for one request. Use a filename that preserves distinct request identities and distinguishes new request instances, and cover colliding sanitized IDs plus reuse of an existing dump directory.

[P2] Cover long text through the extension adapter

ai_agents/agents/ten_packages/extension/kotoba_tts_python/tests/test_state_machine.py:24

The adapter tests send only a few short sentences; the hard-limit test exercises SentenceBuffer alone with 23 characters. No local case drives text beyond the actual 240-character hard boundary, or many queued text segments, through the extension while synthesis is slow. Consequently, preservation and draining of long input, bounded segment buffering, and cancellation/recovery with a text backlog remain unverified. These are required by the local robustness matrix. Add deterministic long single-delta and streamed-delta cases that assert complete ordered vendor text, bounded buffering, no replay after flush, and successful subsequent synthesis.

@github-actions

Copy link
Copy Markdown

[P1] Refresh guarder evidence for the current PR head

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment identifies 5765cce6, while the current head is da754268. Its screenshot is accessible, the log reports 16 passed and 1 skipped, and its PCM downloads successfully (1,003,520 bytes), but that run predates the terminal-path and shutdown changes. This remains a merge blocker under the new-extension evidence gate. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, and attach that run's command/result screenshot and matching downloadable PCM, identifying the tested SHA and playback settings.

[P2] Give each request instance a collision-resistant dump filename

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:298

With dumping enabled, distinct valid request IDs such as a/b and a_b both resolve to kotoba_tts_dump_a_b.pcm. PCMWriter opens files in append mode, so sequential requests concatenate their PCM into one dump; overlapping finalization can also interleave their writes. Reusing an ID after restarting with the same dump directory has the same effect. The file therefore no longer represents one request's emitted audio. Include a collision-resistant request identity and a unique request-instance suffix, and cover sanitized-ID collisions and an existing dump directory.

[P2] Cover long text through the extension adapter

ai_agents/agents/ten_packages/extension/kotoba_tts_python/tests/test_state_machine.py:24

The adapter tests use only a few short sentences; the hard-limit test exercises SentenceBuffer alone with 23 characters. No local test drives text beyond the default 240-character hard boundary, or a substantial queued text backlog, through the extension while synthesis is slow. This leaves long-input preservation, buffering bounds, and cancellation/recovery with pending text unverified, contrary to the required local robustness matrix. Add deterministic long single-delta and streamed-delta cases asserting complete ordered vendor text, bounded buffering, no replay after flush, and successful subsequent synthesis.

@q-cheng

q-cheng commented Oct 16, 2026

Copy link
Copy Markdown
Author

Round 4: terminal path restructured

Codex's re-review of 5765cce6 listed five remaining items. Rather than patch each window, this round replaces the three partial cleanups (worker, flush, shutdown) with one terminal sequence and adds the missing error cases. Commits 32ad1a1b and da754268.

# Finding Change
1 Usage counters leak when the join times out before send_usage_metrics _finalize() records each step (error_sent, audio_end_sent, usage_sent, finished) right after its await. A flush or stop that cancels the worker completes only the steps still unset, so usage is always emitted exactly once for the request that owns it. Test: test_flush_takeover_after_audio_end_still_reports_usage.
2 Recorder detached before its flush, so a cancelled cleanup loses PCM _close_recorder() detaches the writer only after the flush loop, and each flush() is shielded so a cancelled caller does not cancel the writer's own task (which has already taken the bytes out of the buffer). Test: test_stop_during_gated_dump_flush_completes_the_file.
3 A finishing worker becomes unreachable once _request is replaced by the next request while its dump is still writing Requests stay in _finishing from the start of their terminal path until the dump is closed; on_stop joins all of them, not only _request. Flush waits on a per-request settled event (events out) instead of the whole worker, so it never waits on, or cancels, a dump flush. Test: test_next_request_during_previous_dump_flush_then_stop.
4 TTFB includes the awaited connect-delay metric send The clock is read in _emit_audio() before any await. The connect-delay value is parked on the request before its send so an interrupted send is repeated, not lost. Controlled-clock tests unchanged and passing.
5 No tests for quota / rate limit or service-side text rejection A 429 handshake maps to rate_limited (non-fatal, retried once on a fresh socket); request-level codes (invalid_text, text_too_long) are non-fatal and not retried. Fake server knob handshake_status_count. Tests: test_transient_rate_limit_on_handshake_is_retried_silently, test_persistent_rate_limit_is_non_fatal_and_recoverable, test_text_rejected_by_the_service_is_non_fatal_and_not_retried.

Flush and shutdown can run concurrently (the runtime dispatches on_data and on_stop as independent tasks), so _finalize() holds a per-request lock; a second entrant finds every step done. The interaction of worker, flush, shutdown and next request at every await is written out in the extension's design notes and was checked against the final code.

Two more items from the same pass:

  • Shutdown emits no terminal events for a request that never reached its end (the base class emits none either); the first live run of this round showed the guarder's append test relies on that.
  • Agora's bot on the first push of this round: a request released by the base class while on_stop waits (A flushing its dump, B completing, C queued) could start behind the snapshot. on_stop now sets _stopping first, which request_tts honours, and loops until no request is left. Test: test_request_released_during_shutdown_never_starts.
  • The stray .coverage data file is removed from the package.

Verification on da754268: tests/bin/start 100 passed, 1 xfailed, five consecutive runs identical; pylint with the TEN rc file 10.00/10; black -l 80 clean. Live guarder on da754268: 16 passed, 1 skipped (389.98 s); evidence comment above updated. For the record, the first live run on this HEAD failed test_invalid_text_handling because two of its 27 fal handshakes did not complete within the test's 5 s window (no error from fal, the other 25 took 0.6 to 1.0 s); nothing in that path changed this round, and the rerun passed. The SDK's handshake timeout is 15 s, so a shorter, configurable connect timeout with the existing one-retry path is a possible follow-up, not part of this PR.

@github-actions

Copy link
Copy Markdown

[P1] Refresh the guarder evidence and attach the required screenshot

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment identifies da754268, while the current head is 6bd6e2b0. Its log reports 16 passed and 1 skipped, and its PCM downloads successfully (1,244,160 bytes), but these artifacts identify the older run. The comment also promises a subsequent screenshot, which is absent from the current issue comments, review comments, and reviews. This remains a merge blocker under the new-extension evidence gate. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, and attach that run's redacted command/result screenshot and matching downloadable PCM, identifying the tested SHA and playback settings.

[P2] Let active-request flush finish before waiting for dump I/O

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:486

With dumping enabled, flush an actively streaming request while an automatic PCM write is still pending or the final file write is slow. _interrupt() awaits _finalize() here, including its _close_recorder() call, which awaits the disk write without a time bound. The settled event and _TERMINAL_JOIN_SECONDS only avoid this wait when flush finds a request already terminalizing. On this active-request path, the base cannot emit tts_flush_end or reopen its input gate until the dump finishes, so slow or stalled debug storage stalls subsequent text processing despite synthesis already being cancelled. Run dump cleanup in a tracked terminal task and let flush wait only for terminal-event settlement, retaining the shutdown join. Add a regression that holds the dump write pending, flushes during streaming, and checks that tts_flush_end arrives before releasing the write and that shutdown still finalizes the exact PCM.

@q-cheng

q-cheng commented Oct 16, 2026

Copy link
Copy Markdown
Author

Round 5: four findings on da754268, fixed in 6bd6e2b0

# Finding Change
1 Custom URL credentials can leak through the query-name list _redact_url() no longer uses a name list: every query value is masked (access_token=***, X-Amz-Signature=***), userinfo becomes ***@, key names stay visible. The list _SENSITIVE_KEYS is gone; params values were already all masked. Tests: test_redacted_base_url_masks_every_query_value (unit, parametrized) and test_event_metadata_never_carries_endpoint_secrets now carries access_token and X-Amz-Signature values and asserts none reach any event payload.
2 Initialization failure emits duplicate terminal events per delta The init-failed branch now runs after the finished-id check and records the id with _remember_completed() after its one error and one audio_end(ERROR); later deltas of the same id take the late-input path, which only releases the base queue. Test: test_init_failure_reports_each_request_once_across_deltas (three deltas, one error, one end, next request still reported).
3 PCM dump filenames collide; PCMWriter appends dump_file_name() = sanitized id + 8 hex of a SHA-256 of the raw id, and the file is truncated when the recorder opens. Tests: test_colliding_request_ids_get_separate_dump_files (a/b vs a_b, each file exact) and test_existing_dump_file_is_replaced_not_appended.
4 Long-input coverage tests/test_long_input.py: one 2,000+ character input segmented into 40 sentences and spoken in order on one connection; 60 streamed deltas forming 30 sentences, order preserved under backlog; 30 sentences queued then flushed while the first streams: one INTERRUPTED end before tts_flush_end, at most two texts reached the service, no late frames, next request recovers on the same socket.

Verification: tests/bin/start 110 passed, 1 xfailed, three consecutive runs identical; pylint 10.00/10; black -l 80 clean. Live guarder on 6bd6e2b0: 16 passed, 1 skipped (387.57 s); evidence comment above updated.

@github-actions

Copy link
Copy Markdown

[P1] Refresh guarder evidence for the current head and attach the screenshot

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment identifies 6bd6e2b0, while the current head is fd719c3a, which changes dump cleanup and flush behavior. The linked log reports 16 passed and 1 skipped, and its PCM downloads successfully (926,720 bytes), but those artifacts belong to the older implementation. The promised result screenshot is also absent from the current issue comments, review comments, and reviews. This remains a merge blocker under the new-extension evidence gate. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, then attach that run's redacted command/result screenshot and matching downloadable PCM, identifying the tested SHA and playback settings.

[P2] Handle cancellation of the joined worker during shutdown

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:613

If a worker stalls in its terminal events, flush starts _wait_settled(), and shutdown starts shortly afterward, on_stop() waits for that same worker here. Flush's earlier timeout calls _stop_worker() and cancels it. Shielding prevents cancellation from propagating into the worker, but cancellation of the worker itself still raises CancelledError in this await; neither the timeout handler nor except Exception catches it. Consequently, shutdown exits before recorder/client/base cleanup, and the runtime's stop wrapper treats the escaping cancellation as an uncaught exception and calls os._exit(1). Treat cancellation of the joined task as a completed join while preserving cancellation directed at shutdown itself. Add a regression that gates a terminal event, starts flush before stop, and verifies that flush takeover does not abort shutdown and that dumps and transport are closed.

@q-cheng

q-cheng commented Oct 16, 2026

Copy link
Copy Markdown
Author

Round 6: flush no longer waits on dump I/O

Finding: with dumping enabled, flush of an actively streaming request awaited the dump close inside _finalize, so a slow or stalled debug disk delayed tts_flush_end and held the base class's input gate although synthesis was already cancelled. Fixed in fd719c3a.

  • The dump close runs as a tracked task on the request (_close_dump), started once every terminal event is out. Flush returns at settled on both of its paths. The request stays in _finishing until the task completes.
  • Shutdown keeps the join: worker, then _finalize, then the dump task (bounded by _TERMINAL_JOIN_SECONDS), then an inline _close_recorder for anything left. It is the one caller that waits for the file, and a cancelled task leaves the recorder attached so the inline close joins the writer's still-running flush.
  • Test: test_flush_during_streaming_does_not_wait_for_dump_io holds the first dump write, flushes while streaming, asserts tts_flush_end within the flush budget while the write is still held, runs a second request to completion meanwhile, releases the write, and checks both PCM files are exact after shutdown.

Verification: tests/bin/start 111 passed, 1 xfailed, three consecutive runs identical; pylint 10.00/10; black -l 80 clean. Live guarder on fd719c3a: 16 passed, 1 skipped (389.62 s); evidence comment above updated.

Kotoba TTS (Japanese) over the fal-hosted WebSocket API, built on
AsyncTTS2BaseExtension and the official kotoba-sdk client. Sentence
buffering for streamed LLM text, one reused connection with reconnect,
cancel on flush, PCM16 at 8/16/24 kHz, fatal/non-fatal error mapping
(including fal's 1008 Unauthorized close), TTFB and usage metrics,
per-request dumps.

Extension-local tests run against an in-process fake of the service
(tests/fake_kotoba_server.py); tests/configs_fake lets the TTS guarder
run without a service key.
…ounting, redaction)

- Mark the response active before the text send and drop the socket when
  the send is interrupted, so a flush cannot leave a response on a socket
  the next request reuses.
- Flush joins a worker that is already emitting its terminal events
  (bounded), and audio_end_sent is set only after the event is out, so no
  event of the old request lands after tts_flush_end and there is exactly
  one audio_end per request.
- Propagate caller cancellation out of the cancel drain and drop the
  socket when a drain is interrupted (double flush no longer waits on the
  response timeout).
- Emit usage metrics on interrupt so the next request's counters are
  clean; start the event interval at tts_audio_start emission; measure
  TTFB from the text send and report handshake time as connect_delay;
  count bytes only after the frame is sent; a dump write failure no longer
  fails the request.
- Redact every retained parameter value and URL credentials in to_str().
- Status callbacks that raise are logged, never propagated.
- Tests: cancellation under blocked send / slow handshake / double flush /
  stray audio / terminal race / hung worker / usage isolation / stop while
  blocked / failing callback; accounting under a controlled clock;
  redaction; dump failure. 65 passed.

Also list kotoba_tts_python in the TTS guarder WebSocket allowlist so
test_connection_status runs for it.
…etup, idle close, alignment)

- vendor_metadata() returns the endpoint with credentials and signed query
  values masked, so no event metadata carries URL secrets.
- A dump_path that cannot be created disables recording for the request
  instead of raising out of request_tts() and stranding the base queue.
- A watcher per connection observes the SDK receiver ending while idle and
  reports disconnected promptly, retiring only that session.
- Only whole samples are emitted and counted; a trailing half sample is
  held until the next chunk and never crosses requests.
- Tests: the actual write is gated and flushed / stopped while suspended;
  late old-session cleanup preserves the replacement connection; idle
  close is reported before the next text; controlled-clock interval on
  error and interrupt paths; split-sample chunks; unusable dump_path;
  endpoint secrets absent from event metadata. 74 passed.
…-driven tests

- Drop the two redundant `except CancelledError: raise` clauses that
  pylint W0706 flagged; cancellation already passes through `except
  Exception`. Keep the intent as comments.
- Remove the unused `connected` property and `SentenceBuffer.clear()`.
- Tests from the coverage and matrix audit: unterminated tail sent at
  text_input_end, completed-id history eviction, unexpected negotiated
  format is fatal, unexpected worker exception ends the request and
  recovers, failing on_connecting / on_connected callbacks are logged
  only, blank / zero config fields and boolean-for-float are rejected,
  dump finalized on the error path, very fast response. 85 passed; pylint
  10.00/10.
…s, dump lifecycle, metrics)

- Scrub endpoint credentials from every diagnostic string the client logs
  or emits (SDK errors quote the URL they tried); init errors are built
  from pydantic's error types only, never rejected input values; config
  type errors name the type, not the value; unexpected exceptions emit
  only their type.
- A request that fails because initialization failed now gets a
  zero-duration ERROR terminal event before finish_request.
- The handshake delay is attributed to the request that opened the
  connection even when that request ends without audio.
- A server close right after response.done retires the session and
  reports disconnected once the response is consumed.
- The recorder is finalized on the flush takeover path and when the app
  stops during terminal cleanup; _close_recorder flushes until the
  PCMWriter buffer is empty so no tail is lost behind a pending write.
- Tests: connection refused on a credential-bearing endpoint, malformed
  property with a marker value, init-failed terminal event, connect_delay
  attribution, close after done, old/new connection ordering proof,
  takeover and stop-during-flush dumps, 96 KB dump with a slowed writer
  and flush under load. 92 passed; pylint 10.00/10.
…imits and text rejections

The worker, flush and shutdown each ran a different subset of the terminal
sequence, and a flush or stop that cancelled the worker partway could leave
usage counters unsent, lose the tail of a dump, or orphan a request whose dump
was still being written after the next request had started.

- extension.py: a single _finalize() runs error, audio_end, usage,
  finish_request and the dump flush under a per-request lock; each step sets
  its flag right after its await, so a caller that takes over repeats only
  what was cut off. Requests stay in _finishing until their dump is closed and
  on_stop joins all of them. Flush waits on a `settled` event (events out)
  instead of the whole worker, so it never waits on the dump. The recorder is
  detached only after its flush, and each flush is shielded so a cancelled
  caller does not cancel the writer's task. TTFB reads the clock before any
  await. The connect-delay value is parked on the request before its send.
- kotoba_tts.py: a 429 handshake maps to code rate_limited (retryable);
  request-level codes (invalid_text, text_too_long) are not retried.
- tests: handshake_status_count knob; tests for transient and persistent
  429, service-side text rejection, usage after a flush takeover, stop during
  a gated dump flush, a next request overlapping the previous dump flush then
  stop, and flush waiting on terminal events that land in time.
- remove the stray .coverage data file.
…hed request; refuse requests once stopping

- on_stop: a request that never reached its end (no text_input_end) gets its
  worker stopped and its dump closed, without tts_audio_end; the base class
  emits none on shutdown either and the guarder's append test relies on it.
  Requests already in their terminal path are still completed.
- on_stop sets _stopping first and request_tts refuses new input while it is
  set, so a request the base class releases during the shutdown waits (A
  flushing its dump, B completing, C queued) cannot start behind the snapshot;
  the snapshot is re-read until no request is left.
- test: test_request_released_during_shutdown_never_starts.
…the joined worker

on_stop joined a worker with wait_for(shield(task)). When a concurrent
flush timed out and cancelled that same worker, the worker's
CancelledError escaped on_stop; the runtime treated it as an uncaught
exception and exited the process before the dump, the socket and the base
class were cleaned up.

- aio.py: wait_done(task, timeout) and cancel_and_wait(task), built on
  asyncio.wait. They never raise the awaited task's outcome, including
  its cancellation by someone else, and let a cancellation of the caller
  propagate (Python 3.10 cannot tell the two apart after `await task`).
- _join_bounded uses wait_done: a task cancelled by a flush takeover is a
  completed join; on_stop then finishes the terminal sequence once under
  the per-request lock, closes the dump and the client.
- Same fix wherever a task the caller does not own is awaited:
  _stop_worker and the timeout branch of _join_bounded no longer swallow a
  cancellation aimed at the caller; KotobaTTSClient.close() (drain task),
  _await_drain (drain cancelled by close()), _watch_remote_close (receiver
  cancelled by session.close()).
- test: test_flush_takeover_during_shutdown_does_not_abort_stop.
@q-cheng
q-cheng force-pushed the feat/kotoba-tts-python branch from fd719c3 to 88dccfb Compare October 20, 2026 13:45
@github-actions

Copy link
Copy Markdown

[P1] Refresh current-head guarder evidence and attach the result screenshot

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment identifies fd719c3a, while the current head is 88dccfb3, which changes shutdown and cancellation handling. The linked log reports 16 passed and 1 skipped, and its PCM downloads successfully (929,280 bytes), but those artifacts identify the older run. The promised result screenshot is also absent from the current issue comments, review comments, and reviews. This remains a merge blocker under the new-extension evidence gate. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, then attach that run's redacted command/result screenshot and matching downloadable PCM, identifying the tested SHA and playback settings.

@q-cheng

q-cheng commented Oct 20, 2026

Copy link
Copy Markdown
Author

Round 7: rebased on main; shutdown no longer aborts when flush cancels the joined worker

Rebase. The branch is rebased onto main (7b160c32). The only conflict was the guarder allowlist in tests/bin/start, where main added fish_audio_tts_python on the same line; both entries are kept. The extension package is byte-identical before and after the rebase, and outside it this PR still changes only the two allowlist lines (tests/bin/start, test_connection_status.py). The new guarder adapter from #2355 registers word-timing hooks per vendor; Kotoba has no word timestamps, so test_subtitle_alignment keeps skipping. The transcript-log classification from #2339 needs no change here: this extension never logs input text.

[P2] Handle cancellation of the joined worker during shutdown. Confirmed and reproduced: with a terminal event held, flush's bounded wait cancelled the worker that on_stop was joining; the worker's CancelledError escaped on_stop, the runtime logged it as an uncaught exception and exited the process. Fixed in 88dccfb3:

  • New aio.py with two helpers built on asyncio.wait: wait_done(task, timeout) and cancel_and_wait(task). They never raise the awaited task's outcome, including its cancellation by someone else, and they let a cancellation of the caller propagate. On Python 3.10, await task / await asyncio.shield(task) cannot distinguish the two.
  • _join_bounded (shutdown join of a worker or dump-close task) uses wait_done: a task cancelled by a concurrent flush takeover counts as a completed join. on_stop then completes the terminal sequence once under the per-request lock, closes the dump and the client, and returns.
  • The same pattern was fixed everywhere else a task the caller does not own is awaited: _stop_worker and the timeout branch of _join_bounded no longer swallow a cancellation aimed at the caller; KotobaTTSClient.close() (drain task), _await_drain (drain cancelled by close()), and _watch_remote_close (receiver cancelled by session.close()).
  • Test: test_flush_takeover_during_shutdown_does_not_abort_stop holds the worker's tts_audio_end, flushes, and stops while flush is still inside its bounded wait, so flush's timeout cancels the worker on_stop is joining. It asserts the run ends normally within its budget, the dump file equals the emitted audio, and the socket was closed. It fails on the previous head (the process exits on the uncaught CancelledError) and passes now.

Verification on 88dccfb3: tests/bin/start 112 passed, 1 xfailed, three consecutive runs identical; pylint 10.00/10; black -l 80 clean. Live guarder on 88dccfb3: 30 passed, 2 skipped (386.54 s); the 16 extension tests all pass, the other 14 are main's new offline timing-hook unit tests, and both skips are the now-parametrized subtitle alignment; the evidence comment above is updated, and the screenshot follows.

0.3.0 reads the WebSocket close code from ConnectionClosed.rcvd instead of
the deprecated ConnectionClosed.code / .reason, which removes the
DeprecationWarning websockets printed when fal closes with 1008 for a bad
key. The error message keeps its code=... reason=... format. No extension
code changes: the other 0.3.0 changes (response-scoped failed responses and
invalid_request errors, optional speaker_id, deprecated max_length) are
compatible with how the extension uses the SDK.
@github-actions

Copy link
Copy Markdown

[P1] Refresh guarder evidence for the SDK update and attach the screenshot

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment identifies 88dccfb3, while the current head is 96bc0d43, which raises the minimum kotoba-sdk version to 0.3.0. The linked log confirms 30 passed and 2 skipped, and the PCM downloads successfully (1,077,760 bytes), but those artifacts identify the earlier run. The promised result screenshot is also absent from the current issue comments, review comments, and reviews. This remains a merge blocker under the new-extension evidence gate. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, then attach that run's redacted command/result screenshot and matching downloadable PCM, identifying the tested SHA and playback settings.

@q-cheng
q-cheng marked this pull request as ready for review October 21, 2026 03:09

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 96bc0d432d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

self.request_id = request_id
self.generation = generation
self.buffer = SentenceBuffer(soft_limit_chars)
self.segments: asyncio.Queue[str | None] = asyncio.Queue()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound the queued sentence backlog

When streamed input arrives faster than vendor synthesis, or one large delta contains many terminators, this unbounded queue accepts every completed sentence without backpressure; segment_soft_limit_chars only bounds the unfinished fragment, so pending sentence strings can grow with the entire request and exhaust memory before the worker catches up or a flush arrives. Enforce a finite backlog or otherwise reject/backpressure excessive queued text.

AGENTS.md reference: AGENTS.md:L65-L68

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this queue unbounded, deliberately. The text that reaches it has already passed through AsyncTTS2BaseExtension.input_queue (an unbounded AsyncQueue, a plain deque) and, for interleaved requests, _pending_messages, so bounding this queue would move the same strings back upstream rather than bound memory. What it holds is one request's text split into sentences, which is bounded by one LLM turn. A blocking bound would also be unsafe here: the base class awaits request_tts() inline, so a full queue would stall its input loop, and a flush that cancels the worker would leave the blocked put waiting forever. Rejecting text instead would fail legitimate long responses. The other TTS extensions in the repo do not bound this either; they forward text to the vendor socket as it arrives.

…e boundary

The half sample held back so that only whole samples are emitted lived on
the request, so it carried from one sentence response into the next and
was joined to that response's first byte, fabricating one sample; at the
end of a request it was dropped silently. Each response is encoded
independently, so the remainder is now cleared (and logged) whenever a
response ends: completion, failure, retry or abandonment.

- test: test_half_sample_never_crosses_response_boundary.
@github-actions

Copy link
Copy Markdown

[P1] Refresh guarder evidence for the current PR head

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment identifies 96bc0d43, while the current head is edf47273, which changes PCM handling between responses. The screenshot is now accessible, the linked log reports 30 passed and 2 skipped, and the PCM downloads successfully (1,052,160 bytes), but the evidence still identifies the previous implementation. This remains a merge blocker under the new-extension evidence gate. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, then update the comment with that run's SHA, result screenshot, and matching downloadable PCM with playback settings.

[P2] Keep late input from releasing a request before usage settles

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:249

_finalize() adds A to _completed_ids immediately after audio-end, before awaiting send_usage_metrics(A). If that send is delayed and another delta for A arrives while B is buffered, this branch calls finish_request(A) and releases B even though A still owns the shared usage counters. B can start and add text/audio counters; when A's usage send returns, its metrics_reset() clears B's counters, corrupting B's usage. The terminal lock does not protect this separate call to finish_request(). Leave completion to the existing terminal sequence while A is still settling, and add a regression that gates A's usage send, delivers late A input with B queued, and verifies B's usage contains exactly its own text and PCM.

…r releases the next one

_finalize() records a request as completed right after its audio_end, while
its usage send and finish_request() are still to come. A late delta for
that request took the finished-request shortcut and called
finish_request() itself, releasing the next queued request while the first
one's usage was still in flight; the second request's counters were then
reset by the first one's usage send. A late delta that arrived after
text_input_end while the request was still speaking was counted in its
usage but never spoken.

- request_tts ignores input for the current request once its input has
  ended (new input_ended flag) or its terminal sequence is running: it is
  neither spoken nor counted, and the request's own _finalize() remains
  the only place that releases the next request. The shortcut for fully
  finished ids is unchanged.
- tests: test_late_input_while_usage_settles_does_not_release_the_next_request
  and test_late_input_after_input_end_is_not_spoken_or_counted.
- tests: ThreadGate in helpers.py. The tester and the extension run
  separate event loops on separate threads; gates shared between them were
  asyncio.Event, whose wake-up is not thread-safe. All twelve such gates
  now use ThreadGate.
@github-actions

Copy link
Copy Markdown

[P1] Refresh guarder evidence for the current PR head

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner’s evidence comment identifies edf47273, while the current head is 92b0073d, which changes late-input handling and request release. The linked log confirms 30 passed, 2 skipped, and the PCM downloads successfully (990,720 bytes), but those artifacts cover the earlier commit. The accessible screenshot also shows a different run (385.24 s versus 390.25 s in the linked log). This remains a merge blocker under the repository’s new-extension evidence gate. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, then update the evidence comment with the tested SHA, that run’s command/result screenshot, and matching downloadable PCM with playback settings.

@q-cheng

q-cheng commented Oct 21, 2026

Copy link
Copy Markdown
Author

Re: [P2] Keep late input from releasing a request before usage settles. Confirmed and fixed in 92b0073d.

request_tts() now ignores input for the request still in progress once its input has ended (text_input_end seen, tracked as input_ended) or its terminal sequence is running. Such input is neither spoken nor counted, and it no longer calls finish_request(); the request's own _finalize() stays the only place that releases the next request, after its usage is out. The shortcut that releases the queue for a fully finished id is unchanged. The same check also closes the neighbouring case: a late delta after text_input_end while the request is still speaking was previously counted in its usage but never spoken.

Tests:

  • test_late_input_while_usage_settles_does_not_release_the_next_request holds A's usage send after its counters are read, queues B, delivers a late delta for A, and asserts B produces no audio until A's usage is out and B's usage holds exactly its own characters and PCM duration. On the previous head B had streamed 24,000 bytes while A's usage was in flight.
  • test_late_input_after_input_end_is_not_spoken_or_counted asserts the late text never reaches the service and the request's usage counts only its real text (18, not 23, characters on the previous head).

While writing these I also replaced the test gates shared between the tester and the extension: they run separate event loops on separate threads, and asyncio.Event is not safe to set across threads. All twelve such gates now use a threading.Event-backed ThreadGate (tests/helpers.py).

Verification on 92b0073d: tests/bin/start 115 passed, 1 xfailed, three consecutive runs identical; pylint 10.00/10. Live guarder: 30 passed, 2 skipped (387.35 s), no warnings; the evidence comment is updated.

@q-cheng

q-cheng commented Oct 21, 2026 •

Copy link
Copy Markdown
Author

Test result screenshot:
Screenshot 2026-10-21 at 14 25 00

@github-actions

Copy link
Copy Markdown

[P1] Refresh guarder evidence for the current PR head

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner’s evidence comment still identifies 92b0073d, while the current PR head is 7523f92c after the merge from main. The downloadable log confirms 30 passed and 2 skipped, and the PCM downloads successfully (1,315,840 bytes), but those artifacts identify the earlier commit. The attached screenshot also shows a different run duration (387.51 s versus 387.35 s in the linked log). This remains a merge blocker under the repository’s new-extension evidence gate, which explicitly requires the current HEAD SHA. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, then update the evidence comment with the tested SHA, that run’s command/result screenshot, and matching downloadable PCM with playback settings.

@github-actions

Copy link
Copy Markdown

[P1] Refresh live guarder evidence for the current PR head

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner’s evidence comment identifies 7523f92c, while the current PR head is d7755e04. The downloadable log reports 30 passed and 2 skipped, and the PCM downloads successfully (1,239,040 bytes), but these artifacts cover the earlier commit. The attached screenshot also shows a different run duration (387.51 s versus 389.98 s in the linked log). This remains a merge blocker under the repository’s new-extension evidence gate, which explicitly requires the current HEAD SHA. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, then update the evidence comment with the tested SHA, that run’s command/result screenshot, and matching downloadable PCM with playback settings.

…ing_chars)

request_tts() hands complete sentences to the request's worker queue and
returns at once, so when synthesis falls behind the input, text accepted
but not yet sent to the service could grow without bound.

- New params.max_pending_chars (default 5000, about 15 minutes of Japanese
  speech; validated >= 1). The request tracks queued_chars (sentences
  queued for the worker and not yet taken) plus its buffered fragment.
- Past the limit, the request ends with an explicit non-fatal
  pending_text_limit error, then tts_audio_end(ERROR) with the audio
  emitted so far, then usage and finish_request(), so the next request
  starts normally. Nothing is dropped silently: later deltas for the ended
  request take the existing late-input path and are neither spoken nor
  counted.
- _abort() now holds the stop-worker / cancel-response / _finalize
  sequence shared by flush (_interrupt) and the limit.
- tests: test_pending_text_limit_ends_the_request_cleanly (slow service,
  limit reached while the first sentence streams, one error and one
  audio_end, no further text sent, flush still responsive on the next
  request, the request after that succeeds); max_pending_chars default
  and validation in test_params.py.
@github-actions

Copy link
Copy Markdown

[P1] Refresh live guarder evidence for the current PR head

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment identifies 7523f92c, while the current head is 2c822b48, which adds the pending-text limit and its abort path. The linked log confirms 30 passed and 2 skipped, and the PCM downloads successfully (1,239,040 bytes), but those artifacts cover the earlier implementation. This remains a merge blocker under the repository's new-extension evidence gate. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, then update the comment with the tested SHA, that run's command/result screenshot, and matching downloadable PCM with playback settings.

[P2] Track overflow finalization so flush can take it over

ai_agents/agents/ten_packages/extension/kotoba_tts_python/extension.py:334

This overflow path awaits _abort() directly from request_tts(), which the base runs on its input-loop task. _abort() clears request.worker before entering _finalize(). If audio-end or usage delivery then stalls while holding finalize_lock, a concurrent flush reaches _wait_settled()'s timeout, but _stop_worker() has no task left to cancel. The takeover's _finalize() instead waits on the lock still held by the input-loop task, so tts_flush_end and further input remain blocked for as long as the original send stalls, defeating the bounded takeover. Run overflow termination in a tracked task that flush can cancel and join. Add a regression that crosses max_pending_chars, holds its terminal send, flushes, and asserts bounded acknowledgement and recovery; the current overflow test only flushes a subsequent request.

… in a task flush can take over

The pending-text limit awaited _abort() from request_tts(), on the base
class's input-loop task: the terminal sequence held the request's
finalize_lock on a task the extension cannot cancel, and request.worker was
already empty, so a flush that timed out waiting could not take over and
stayed blocked for as long as a send stalled. The same shape existed in the
superseded-request branch, and a flush that interrupted a speaking request
ran the sequence on its own handler task with no takeover at all.

- _abort() stops the worker and cancels the in-flight response, then starts
  the terminal sequence as the request's own task in request.worker
  (_run_terminal, which logs a failure instead of leaving it unobserved),
  and returns at once.
- Callers that must not return before the events are out wait with
  _await_terminal(): up to _TERMINAL_JOIN_SECONDS for `settled`, then cancel
  the task and complete the rest as INTERRUPTED. Flush (cancel_tts and
  _interrupt) and the superseded branch use it; the limit path returns at
  once, so request_tts() no longer runs terminal sends inline.
- tests: test_flush_takes_over_a_stalled_pending_limit_end and
  test_flush_is_bounded_when_its_own_audio_end_stalls (both fail on the
  previous head: the flush is not acknowledged within the bound).
@github-actions

Copy link
Copy Markdown

[P1] Refresh live guarder evidence for the current PR head

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment identifies 7523f92c, while the current head is f6b49296, which includes the pending-text limit and revised abort/flush finalization. The linked log reports 30 passed, 2 skipped, and its PCM downloads successfully (1,239,040 bytes), but those artifacts cover the earlier implementation. The accessible screenshot also shows a different run duration (387.51 s versus 389.98 s in the log). This remains a merge blocker under the repository's new-extension evidence gate, which requires the current HEAD SHA. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, then update the evidence comment with the tested SHA, that run's command/result screenshot, and matching downloadable PCM with playback settings.

@q-cheng

q-cheng commented Oct 28, 2026

Copy link
Copy Markdown
Author

Test result screenshot:
Screenshot 2026-10-28 at 13 37 28

…k flush can take over

_abort() awaited the worker's cancellation and client.cancel() on the
caller's task before it started the terminal task. With the pending-text
limit that caller is the base class's input loop: if the response.cancel
write stalled, request.worker was empty, a flush took over after its bound,
and when the write resumed the abort started the terminal task anyway and
emitted the old request's error (and finish_request) after tts_flush_end.
A flush that interrupted a speaking request was also unbounded while its
own cancel write stalled.

- _abort() no longer awaits: it marks the request ended, cancels the
  worker and starts the abort as request.worker at once. _run_terminal()
  waits for the cancelled worker, cancels the in-flight response, then runs
  the terminal sequence, so a takeover cancels every step.
- KotobaTTSClient.cancel() drops the socket when the response.cancel write
  is itself cancelled: the service may never have seen it, so the old
  response can still be streaming on that socket.
- tests: test_flush_takes_over_while_the_limit_cancel_send_stalls and
  test_flush_is_bounded_when_its_own_cancel_send_stalls (both fail on the
  previous head: a late error after tts_flush_end, and a 4 s flush; without
  the socket drop the next request gets stale audio or an error).
@github-actions

Copy link
Copy Markdown

[P1] Refresh live guarder evidence for the current PR head

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment, downloadable log, and latest screenshot identify f6b4929, while the current head is f5ca38a, which changes abort-task ownership and cancellation cleanup. I verified the earlier run reports 31 passed and 2 skipped and its PCM downloads successfully (949,760 bytes), but it does not cover the current implementation. This remains a merge blocker under the new-extension evidence gate, which requires the tested SHA to match HEAD. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, then update the comment with that SHA, command/results screenshot, and matching downloadable PCM with playback settings.

[P2] Surface length-truncated synthesis as an error

ai_agents/agents/ten_packages/extension/kotoba_tts_python/kotoba_tts.py:425

The released SDK documents that reaching the server's generation limit returns status="completed" with finish_reason="length". This branch accepts every done event without checking that reason, so truncated speech becomes a normal tts_audio_end(REQUEST_END) with no error and the remaining text is lost. A long sentence arriving in one delta can reach this path because SentenceBuffer.push() releases complete sentences before applying its oversized-fragment cuts. Handle a length stop as a non-fatal, non-retryable incomplete response, and add a fake-service regression asserting one ERROR end with the duration of the PCM actually emitted.

…ength limit as an error

The service ends a response that reaches its generation length limit with
response.done(status=completed, finish_reason=length), and the SDK passes it
on as a normal done event. The client accepted every done event, so a
truncated response became tts_audio_end(REQUEST_END) with no error and the
rest of the text was silently lost.

- KotobaTTSClient.synthesize() reads finish_reason from the response in the
  done event (the SDK lifts only status out of it) and raises a non-fatal,
  non-retryable KotobaTTSError(code="response_truncated") after the audio
  already yielded. The request ends with one error and tts_audio_end(ERROR)
  whose duration is the audio actually emitted; the socket is kept.
- fake server: length_response_index / length_after_chunks.
- test: test_length_truncated_response_is_a_non_fatal_error (fails on the
  previous head: the request ends with REQUEST_END).
- README: the error is documented with the others.
@github-actions

Copy link
Copy Markdown

[P1] Refresh live guarder evidence for the current PR head

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment and downloadable log identify f6b4929, while the current head is f01a6f7, which includes subsequent changes to abort cancellation and length-truncated responses. The log reports 31 passed, 2 skipped, and both the screenshot and non-empty PCM are accessible (949,760 PCM bytes), but this evidence covers the earlier implementation. This remains a merge blocker under the repository's new-extension evidence gate, which requires the tested SHA to match HEAD. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, then update the evidence comment with that SHA, the run's command/results screenshot, and matching downloadable PCM with playback settings.

@github-actions

Copy link
Copy Markdown

[P1] Refresh live guarder evidence for the current PR head

ai_agents/agents/ten_packages/extension/kotoba_tts_python/manifest.json:3

The owner's evidence comment and downloadable log identify f6b492966728cca2b542d0794f2682c121cce6d8, while the current head is 06e8053510591aba3044a997a3a7dd269e307c08, which includes subsequent response-cancellation cleanup and length-truncation handling. I verified that the linked run reports 31 passed, 2 skipped, the screenshot is accessible, and the PCM downloads successfully (949,760 bytes), but these artifacts cover the earlier implementation. This remains a merge blocker under the repository's new-extension evidence gate, which requires the tested SHA to match HEAD. Run task tts-guarder-test EXTENSION=kotoba_tts_python against the current head with real service credentials, then update the evidence comment with that SHA, the run's command/results screenshot, and matching downloadable PCM with playback settings.

@q-cheng

q-cheng commented Oct 29, 2026

Copy link
Copy Markdown
Author

Test result screenshot:
Screenshot 2026-10-29 at 14 45 44

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant