fix(runtime): address stream-end client review findings

Address PR #6649 review comments 4112230294, 4112230296, and 4112230301.
Mobile stream statuses bypassed the locale catalog. Render them through
shared MessageIds in all 15 complete packs, reading settings off the async
runtime and quoting the injected JSON safely. Exercise the rendered JS
against each locale. Other mobile shell copy remains English.

The SDK's open-ended journal event names prevented stream.end narrowing.
Export and document isThreadStreamEnd; cover runtime classification and all
three TypeScript iterator overloads without closing the journal name set.

Expose x-codewhale-stream-end through the existing CORS layer. The focused
regression exercises the middleware in-process for default, configured, and
rejected origins. Update and synchronize the existing Unreleased bullet.

Validation (all local/offline):
CARGO_BUILD_JOBS=4 CARGO_NET_OFFLINE=true cargo test -p codewhale-tui --lib -- mobile_stream_statuses_use_selected_locale cors_layer replay_failure_over_http_sends_stream_end_then_eof
  test result: FAILED. 4 passed; 2 failed; 0 ignored; 0 measured; 13472 filtered out; finished in 0.05s
  The mobile locale regression passed. The two failures were socket binds
  denied by the sandbox. Three older CORS tests returned early on that same
  restriction; their reported passes do not establish HTTP coverage.
  Reworked the new CORS test to use the actual middleware without sockets.
  The existing HTTP replay acceptance test remains unverified here.

CARGO_BUILD_JOBS=4 CARGO_NET_OFFLINE=true cargo test -p codewhale-tui --lib -- cors_layer_exposes_stream_end_for_allowed_origins
  test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 13477 filtered out; finished in 0.01s

CARGO_BUILD_JOBS=4 CARGO_NET_OFFLINE=true cargo test -p codewhale-localization --lib -- message_id_list_english_pack_stay_in_exact_sync shipped_complete_packs_have_raw_key_parity_with_english
  test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 48 filtered out; finished in 0.06s

npm --offline --prefix npm/runtime-sdk test
  # tests 16
  # pass 16
  # fail 0
  # skipped 0

tsc --noEmit --strict --target ES2022 --module NodeNext npm/runtime-sdk/test/thread-events.types.ts
  Exit 0. The same fixture with the predicate changed to boolean fails
  with TS2322/TS2339 (exit 2), confirming the narrowing regression.

node --input-type=module (inline VM check against the original mobile HTML)
  Confirmed the pre-fix Japanese replay status returns English.
python3 scripts/check-tui-locale-parity.py
  [tui-locale-parity] PASS (2393 keys in all 15 complete packs)
cargo fmt --all; sh scripts/sync-changelog.sh; git diff --check completed.
The TUI test linker warned about the large compact-unwind table.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-26 22:34:55 -07:00
1 parent 8d3c1b7d81
commit 60a2c9887c
26 files changed
+342 -18

No files matched your search

+4 -1
View File
@@ -65,7 +65,10 @@ quieter, and Fleet runs can be checked before they spend anything.
on shutdown, so clients can tell a Runtime error from a dropped connection
and resume from the right event. A replay worker crash no longer ends
history early and skips events without a detectable gap, and the mobile
page resumes from its last event instead of replaying from zero.
page resumes from its last event instead of replaying from zero, with stream
statuses in the configured UI language. Cross-origin clients can read the
stream-end capability header, and the SDK exports `isThreadStreamEnd` for
TypeScript narrowing.
- `codewhale exec --auto` no longer exits 141 with no output when a child
it writes to, such as a stdio MCP server, closes its pipe early. Headless
exec now ignores SIGPIPE while it runs, as the interactive TUI already did,
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "El Runtime no ha pogut llegir l'historial d'aquesta conversa — tornant-ho a provar",
"MobileStreamCatchUpFailed": "El Runtime s'ha endarrerit i no s'ha pogut posar al dia — tornant-ho a provar",
"MobileStreamRuntimeShutdown": "El Runtime s'està tancant — reconnectant",
"MobileStreamEnded": "El Runtime ha finalitzat el flux d'esdeveniments — reconnectant",
"MobileStreamClosed": "El Runtime ha tancat el flux d'esdeveniments d'aquesta conversa; torna a obrir-la",
"MobileStreamReconnecting": "Connexió perduda; reconnectant",
"MobileStreamConnected": "Connectat",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Un hàbitat compartit",
"PetHabitatHints": "{back} enrere · {sound} so · {browser} aparença · {window} finestra",
"PetHabitatQueued": "Enviat a l’hàbitat compartit. /pet on obre la vista completa.",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "Runtime konnte den Verlauf dieser Unterhaltung nicht lesen — erneuter Versuch",
"MobileStreamCatchUpFailed": "Runtime ist zurückgefallen und konnte nicht aufholen — erneuter Versuch",
"MobileStreamRuntimeShutdown": "Runtime wird heruntergefahren — Verbindung wird wiederhergestellt",
"MobileStreamEnded": "Runtime hat den Ereignisstrom beendet — Verbindung wird wiederhergestellt",
"MobileStreamClosed": "Runtime hat den Ereignisstrom dieser Unterhaltung geschlossen; öffne die Unterhaltung erneut",
"MobileStreamReconnecting": "Verbindung verloren; Verbindung wird wiederhergestellt",
"MobileStreamConnected": "Verbunden",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Ein gemeinsamer Lebensraum",
"PetHabitatHints": "{back} zurück · {sound} Ton · {browser} Aussehen · {window} Fenster",
"PetHabitatQueued": "An den gemeinsamen Lebensraum gesendet. /pet on öffnet die Vollansicht.",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "Runtime could not read this conversation's history — retrying",
"MobileStreamCatchUpFailed": "Runtime fell behind and could not catch up — retrying",
"MobileStreamRuntimeShutdown": "Runtime is shutting down — reconnecting",
"MobileStreamEnded": "Runtime ended the event stream — reconnecting",
"MobileStreamClosed": "Runtime closed this conversation's event stream; reopen the thread",
"MobileStreamReconnecting": "Connection lost; reconnecting",
"MobileStreamConnected": "Connected",
"PetHabitatTitle": "CODEWHALE / WHALESONG · One shared habitat",
"PetHabitatHints": "{back} back · {sound} sound · {browser} appearance · {window} window",
"PetHabitatQueued": "Sent to the shared habitat. /pet on opens the full view.",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "El Runtime no pudo leer el historial de esta conversación — reintentando",
"MobileStreamCatchUpFailed": "El Runtime se atrasó y no pudo ponerse al día — reintentando",
"MobileStreamRuntimeShutdown": "El Runtime se está cerrando — reconectando",
"MobileStreamEnded": "El Runtime terminó el flujo de eventos — reconectando",
"MobileStreamClosed": "El Runtime cerró el flujo de eventos de esta conversación; vuelve a abrirla",
"MobileStreamReconnecting": "Se perdió la conexión; reconectando",
"MobileStreamConnected": "Conectado",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Un hábitat compartido",
"PetHabitatHints": "{back} volver · {sound} sonido · {browser} apariencia · {window} ventana",
"PetHabitatQueued": "Enviado al hábitat compartido. /pet on abre la vista completa.",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "Le Runtime n’a pas pu lire l’historique de cette conversation — nouvelle tentative",
"MobileStreamCatchUpFailed": "Le Runtime a pris du retard et n’a pas pu le rattraper — nouvelle tentative",
"MobileStreamRuntimeShutdown": "Le Runtime est en cours d’arrêt — reconnexion",
"MobileStreamEnded": "Le Runtime a terminé le flux d’événements — reconnexion",
"MobileStreamClosed": "Le Runtime a fermé le flux d’événements de cette conversation ; rouvrez-la",
"MobileStreamReconnecting": "Connexion perdue ; reconnexion",
"MobileStreamConnected": "Connecté",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Un habitat partagé",
"PetHabitatHints": "{back} retour · {sound} son · {browser} apparence · {window} fenêtre",
"PetHabitatQueued": "Envoyé à l’habitat partagé. /pet on ouvre la vue complète.",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "Runtime इस बातचीत का इतिहास नहीं पढ़ सका — फिर से कोशिश हो रही है",
"MobileStreamCatchUpFailed": "Runtime पीछे रह गया और छूटी हुई घटनाएँ प्राप्त नहीं कर सका — फिर से कोशिश हो रही है",
"MobileStreamRuntimeShutdown": "Runtime बंद हो रहा है — फिर से कनेक्ट हो रहा है",
"MobileStreamEnded": "Runtime ने इवेंट स्ट्रीम समाप्त कर दी — फिर से कनेक्ट हो रहा है",
"MobileStreamClosed": "Runtime ने इस बातचीत की इवेंट स्ट्रीम बंद कर दी; बातचीत फिर से खोलें",
"MobileStreamReconnecting": "कनेक्शन टूट गया; फिर से कनेक्ट हो रहा है",
"MobileStreamConnected": "कनेक्ट हो गया",
"PetHabitatTitle": "CODEWHALE / WHALESONG · एक साझा आवास",
"PetHabitatHints": "{back} वापस · {sound} ध्वनि · {browser} रूप · {window} विंडो",
"PetHabitatQueued": "साझा आवास को भेजा गया। /pet on से पूरा दृश्य खुलता है।",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "Runtime tidak dapat membaca riwayat percakapan ini — mencoba lagi",
"MobileStreamCatchUpFailed": "Runtime tertinggal dan tidak dapat mengejar — mencoba lagi",
"MobileStreamRuntimeShutdown": "Runtime sedang dimatikan — menghubungkan kembali",
"MobileStreamEnded": "Runtime mengakhiri aliran peristiwa — menghubungkan kembali",
"MobileStreamClosed": "Runtime menutup aliran peristiwa percakapan ini; buka kembali percakapan",
"MobileStreamReconnecting": "Koneksi terputus; menghubungkan kembali",
"MobileStreamConnected": "Terhubung",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Satu habitat bersama",
"PetHabitatHints": "{back} kembali · {sound} suara · {browser} tampilan · {window} jendela",
"PetHabitatQueued": "Dikirim ke habitat bersama. /pet on membuka tampilan penuh.",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "Runtime がこの会話の履歴を読み込めませんでした — 再試行中",
"MobileStreamCatchUpFailed": "Runtime のイベント処理が遅れ、追いつけませんでした — 再試行中",
"MobileStreamRuntimeShutdown": "Runtime は終了処理中です — 再接続中",
"MobileStreamEnded": "Runtime がイベントストリームを終了しました — 再接続中",
"MobileStreamClosed": "Runtime がこの会話のイベントストリームを閉じました。スレッドを開き直してください",
"MobileStreamReconnecting": "接続が切れました。再接続中",
"MobileStreamConnected": "接続済み",
"PetHabitatTitle": "CODEWHALE / WHALESONG · 共有ハビタット",
"PetHabitatHints": "{back} 戻る · {sound} 音 · {browser} 外観 · {window} ウィンドウ",
"PetHabitatQueued": "共有ハビタットに送信しました。/pet on で全画面表示を開きます。",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "Runtime이 이 대화의 기록을 읽지 못했습니다 — 다시 시도 중",
"MobileStreamCatchUpFailed": "Runtime의 이벤트 처리가 지연되어 따라잡지 못했습니다 — 다시 시도 중",
"MobileStreamRuntimeShutdown": "Runtime을 종료하고 있습니다 — 다시 연결 중",
"MobileStreamEnded": "Runtime이 이벤트 스트림을 종료했습니다 — 다시 연결 중",
"MobileStreamClosed": "Runtime이 이 대화의 이벤트 스트림을 닫았습니다. 스레드를 다시 여세요",
"MobileStreamReconnecting": "연결이 끊어졌습니다. 다시 연결 중",
"MobileStreamConnected": "연결됨",
"PetHabitatTitle": "CODEWHALE / WHALESONG · 하나의 공유 서식지",
"PetHabitatHints": "{back} 뒤로 · {sound} 소리 · {browser} 외관 · {window} 창",
"PetHabitatQueued": "공유 서식지로 보냈습니다. /pet on 명령으로 전체 화면을 엽니다.",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "O Runtime não conseguiu ler o histórico desta conversa — tentando novamente",
"MobileStreamCatchUpFailed": "O Runtime ficou para trás e não conseguiu recuperar os eventos — tentando novamente",
"MobileStreamRuntimeShutdown": "O Runtime está sendo encerrado — reconectando",
"MobileStreamEnded": "O Runtime encerrou o fluxo de eventos — reconectando",
"MobileStreamClosed": "O Runtime fechou o fluxo de eventos desta conversa; reabra a conversa",
"MobileStreamReconnecting": "Conexão perdida; reconectando",
"MobileStreamConnected": "Conectado",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Um habitat compartilhado",
"PetHabitatHints": "{back} voltar · {sound} som · {browser} aparência · {window} janela",
"PetHabitatQueued": "Enviado ao habitat compartilhado. /pet on abre a visualização completa.",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "Runtime не удалось прочитать историю этой беседы — повторная попытка",
"MobileStreamCatchUpFailed": "Runtime отстал и не смог получить пропущенные события — повторная попытка",
"MobileStreamRuntimeShutdown": "Runtime завершает работу — переподключение",
"MobileStreamEnded": "Runtime завершил поток событий — переподключение",
"MobileStreamClosed": "Runtime закрыл поток событий этой беседы; откройте её заново",
"MobileStreamReconnecting": "Соединение потеряно; переподключение",
"MobileStreamConnected": "Подключено",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Общая среда обитания",
"PetHabitatHints": "{back} назад · {sound} звук · {browser} внешний вид · {window} окно",
"PetHabitatQueued": "Отправлено в общую среду обитания. /pet on открывает полный вид.",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "Runtime не вдалося прочитати історію цієї розмови — повторна спроба",
"MobileStreamCatchUpFailed": "Runtime відстав і не зміг отримати пропущені події — повторна спроба",
"MobileStreamRuntimeShutdown": "Runtime завершує роботу — повторне підключення",
"MobileStreamEnded": "Runtime завершив потік подій — повторне підключення",
"MobileStreamClosed": "Runtime закрив потік подій цієї розмови; відкрийте її знову",
"MobileStreamReconnecting": "З’єднання втрачено; повторне підключення",
"MobileStreamConnected": "Підключено",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Спільне середовище",
"PetHabitatHints": "{back} назад · {sound} звук · {browser} вигляд · {window} вікно",
"PetHabitatQueued": "Надіслано до спільного середовища. /pet on відкриває повний вигляд.",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "Runtime không thể đọc lịch sử cuộc trò chuyện này — đang thử lại",
"MobileStreamCatchUpFailed": "Runtime bị chậm và không thể bắt kịp — đang thử lại",
"MobileStreamRuntimeShutdown": "Runtime đang tắt — đang kết nối lại",
"MobileStreamEnded": "Runtime đã kết thúc luồng sự kiện — đang kết nối lại",
"MobileStreamClosed": "Runtime đã đóng luồng sự kiện của cuộc trò chuyện này; hãy mở lại cuộc trò chuyện",
"MobileStreamReconnecting": "Mất kết nối; đang kết nối lại",
"MobileStreamConnected": "Đã kết nối",
"PetHabitatTitle": "CODEWHALE / WHALESONG · Một môi trường sống chung",
"PetHabitatHints": "{back} quay lại · {sound} âm thanh · {browser} giao diện · {window} cửa sổ",
"PetHabitatQueued": "Đã gửi đến môi trường sống chung. /pet on mở chế độ xem toàn màn hình.",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "Runtime 无法读取此对话的历史记录 — 正在重试",
"MobileStreamCatchUpFailed": "Runtime 处理落后且无法追赶 — 正在重试",
"MobileStreamRuntimeShutdown": "Runtime 正在关闭 — 正在重新连接",
"MobileStreamEnded": "Runtime 已结束事件流 — 正在重新连接",
"MobileStreamClosed": "Runtime 已关闭此对话的事件流;请重新打开会话",
"MobileStreamReconnecting": "连接已断开;正在重新连接",
"MobileStreamConnected": "已连接",
"PetHabitatTitle": "CODEWHALE / WHALESONG · 共享栖息地",
"PetHabitatHints": "{back} 返回 · {sound} 声音 · {browser} 外观 · {window} 窗口",
"PetHabitatQueued": "已发送到共享栖息地。使用 /pet on 打开全屏视图。",
+7
View File
@@ -1,4 +1,11 @@
{
"MobileStreamReplayFailed": "Runtime 無法讀取此對話的歷史記錄 — 正在重試",
"MobileStreamCatchUpFailed": "Runtime 處理落後且無法追趕 — 正在重試",
"MobileStreamRuntimeShutdown": "Runtime 正在關閉 — 正在重新連線",
"MobileStreamEnded": "Runtime 已結束事件串流 — 正在重新連線",
"MobileStreamClosed": "Runtime 已關閉此對話的事件串流;請重新開啟對話",
"MobileStreamReconnecting": "連線已中斷;正在重新連線",
"MobileStreamConnected": "已連線",
"PetHabitatTitle": "CODEWHALE / WHALESONG · 共享棲息地",
"PetHabitatHints": "{back} 返回 · {sound} 聲音 · {browser} 外觀 · {window} 視窗",
"PetHabitatQueued": "已傳送至共享棲息地。使用 /pet on 開啟全螢幕檢視。",
+14
View File
@@ -120,6 +120,13 @@ impl Locale {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageId {
MobileStreamReplayFailed,
MobileStreamCatchUpFailed,
MobileStreamRuntimeShutdown,
MobileStreamEnded,
MobileStreamClosed,
MobileStreamReconnecting,
MobileStreamConnected,
SessionArchiveExported,
SessionArchiveSizes,
SessionArchiveNoArtifacts,
@@ -2623,6 +2630,13 @@ pub enum MessageId {
#[allow(dead_code)]
pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::MobileStreamReplayFailed,
MessageId::MobileStreamCatchUpFailed,
MessageId::MobileStreamRuntimeShutdown,
MessageId::MobileStreamEnded,
MessageId::MobileStreamClosed,
MessageId::MobileStreamReconnecting,
MessageId::MobileStreamConnected,
MessageId::SessionArchiveExported,
MessageId::SessionArchiveSizes,
MessageId::SessionArchiveNoArtifacts,
+4 -1
View File
@@ -65,7 +65,10 @@ quieter, and Fleet runs can be checked before they spend anything.
on shutdown, so clients can tell a Runtime error from a dropped connection
and resume from the right event. A replay worker crash no longer ends
history early and skips events without a detectable gap, and the mobile
page resumes from its last event instead of replaying from zero.
page resumes from its last event instead of replaying from zero, with stream
statuses in the configured UI language. Cross-origin clients can read the
stream-end capability header, and the SDK exports `isThreadStreamEnd` for
TypeScript narrowing.
- `codewhale exec --auto` no longer exits 141 with no output when a child
it writes to, such as a stdio MCP server, closes its pipe early. Headless
exec now ignores SIGPIPE while it runs, as the interactive TUI already did,
+33 -6
View File
@@ -1696,18 +1696,22 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.with_state(state)
}
async fn mobile_page(State(state): State<RuntimeApiState>, req: Request) -> Response {
async fn mobile_page(State(state): State<RuntimeApiState>) -> Result<Response, ApiError> {
if !state.mobile_enabled {
return (
return Ok((
StatusCode::NOT_FOUND,
"mobile control is disabled; start with `codewhale serve --mobile`",
)
.into_response();
.into_response());
}
let _ = req;
let mut response = Html(MOBILE_HTML).into_response();
let settings = tokio::task::spawn_blocking(crate::settings::Settings::load_read_only)
.await
.map_err(|err| ApiError::internal(format!("mobile settings task failed: {err}")))?
.map_err(|err| ApiError::internal(format!("mobile settings unavailable: {err}")))?;
let locale = codewhale_localization::resolve_locale(&settings.locale);
let mut response = Html(mobile_html(locale)).into_response();
secure_mobile_response(&mut response);
response
Ok(response)
}
#[derive(Serialize)]
@@ -10234,6 +10238,28 @@ async fn clear_memory(
const MOBILE_HTML: &str = include_str!("runtime_mobile.html");
// Only stream statuses are localized here; the rest of the mobile shell is
// still English. Reload the page after changing the Runtime's UI locale.
fn mobile_html(locale: codewhale_localization::Locale) -> String {
use codewhale_localization::{MessageId, tr};
let messages = json!({
"replay_failed": tr(locale, MessageId::MobileStreamReplayFailed),
"catch_up_failed": tr(locale, MessageId::MobileStreamCatchUpFailed),
"runtime_shutdown": tr(locale, MessageId::MobileStreamRuntimeShutdown),
"ended": tr(locale, MessageId::MobileStreamEnded),
"closed": tr(locale, MessageId::MobileStreamClosed),
"reconnecting": tr(locale, MessageId::MobileStreamReconnecting),
"connected": tr(locale, MessageId::MobileStreamConnected),
});
// JSON quoting protects JS strings; escaping '<' also prevents a catalog
// value from closing the enclosing script element.
MOBILE_HTML.replace(
"__CODEWHALE_STREAM_MESSAGES__",
&messages.to_string().replace('<', "\\u003c"),
)
}
/// Built-in dev origins always allowed by the runtime API (whalescale#255).
const DEFAULT_CORS_ORIGINS: &[&str] = &[
"http://localhost:3000",
@@ -10278,6 +10304,7 @@ fn cors_layer(extra_origins: &[String]) -> CorsLayer {
HeaderName::from_static("x-codewhale-runtime-token"),
HeaderName::from_static("x-deepseek-runtime-token"),
])
.expose_headers([HeaderName::from_static("x-codewhale-stream-end")])
}
fn map_task_err(err: anyhow::Error) -> ApiError {
+93
View File
@@ -7878,6 +7878,41 @@ fn cors_layer_skips_invalid_origins() {
let _ = cors_layer(&extras);
}
#[tokio::test]
async fn cors_layer_exposes_stream_end_for_allowed_origins() -> Result<()> {
use axum::handler::Handler;
let handler = (|| async { ([("x-codewhale-stream-end", "1")], "event: stream.end\n\n") })
.layer(cors_layer(&["http://localhost:5173".to_string()]));
for origin in ["http://localhost:1420", "http://localhost:5173"] {
let request = Request::builder()
.uri("/events")
.header("Origin", origin)
.body(axum::body::Body::empty())?;
let response = handler.clone().call(request, ()).await;
assert_eq!(response.headers()["access-control-allow-origin"], origin);
assert_eq!(response.headers()["x-codewhale-stream-end"], "1");
assert!(
response.headers()["access-control-expose-headers"]
.to_str()?
.split(',')
.any(|name| name.trim().eq_ignore_ascii_case("x-codewhale-stream-end"))
);
}
let request = Request::builder()
.uri("/events")
.header("Origin", "http://malicious.example")
.body(axum::body::Body::empty())?;
let rejected = handler.call(request, ()).await;
assert!(
rejected
.headers()
.get("access-control-allow-origin")
.is_none()
);
Ok(())
}
/// #562 / whalescale#256 — `PATCH /v1/threads/{id}` accepts the new
/// fields (allow_shell, trust_mode, auto_approve, model, mode, title,
/// system_prompt). Legacy mode aliases remain accepted as one-way inputs and
@@ -8941,6 +8976,64 @@ async fn mobile_runtime_router_starts_without_duplicate_method_routes() -> Resul
Ok(())
}
#[test]
fn mobile_stream_statuses_use_selected_locale() -> Result<()> {
use codewhale_localization::{Locale, MessageId, tr};
for &locale in Locale::shipped_complete() {
let html = mobile_html(locale);
let script = html
.split_once("<script>")
.context("mobile script")?
.1
.split_once("boot().catch")
.context("mobile startup")?
.0;
let runtime = rquickjs::Runtime::new()?;
let context = rquickjs::Context::full(&runtime)?;
context.with(|ctx| -> Result<()> {
ctx.eval::<(), _>(script)?;
for (expression, message) in [
(
"streamEndStatus('replay_failed')",
MessageId::MobileStreamReplayFailed,
),
(
"streamEndStatus('catch_up_failed')",
MessageId::MobileStreamCatchUpFailed,
),
(
"streamEndStatus('runtime_shutdown')",
MessageId::MobileStreamRuntimeShutdown,
),
(
"streamEndStatus('future_reason')",
MessageId::MobileStreamEnded,
),
("streamMessages.closed", MessageId::MobileStreamClosed),
(
"streamMessages.reconnecting",
MessageId::MobileStreamReconnecting,
),
("streamMessages.connected", MessageId::MobileStreamConnected),
] {
let actual: String = ctx.eval(expression)?;
assert_eq!(actual, tr(locale, message), "{}: {message:?}", locale.tag());
if locale != Locale::En {
assert_ne!(
actual,
tr(Locale::En, message),
"{}: {message:?}",
locale.tag()
);
}
}
Ok(())
})?;
}
Ok(())
}
#[tokio::test]
async fn mobile_page_is_available_only_when_enabled() -> Result<()> {
let tmp = tempfile::tempdir()?;
+10 -9
View File
@@ -260,6 +260,7 @@
</main>
<script>
const streamMessages = __CODEWHALE_STREAM_MESSAGES__;
const $ = (id) => document.getElementById(id);
const MAX_VISIBLE_EVENTS = 100;
const LEGACY_RUNTIME_TOKEN_STORAGE_KEY = "codewhale_runtime_token";
@@ -584,13 +585,13 @@
function streamEndStatus(reason) {
switch (reason) {
case "replay_failed":
return "Runtime could not read this conversation's history \u2014 retrying";
return streamMessages.replay_failed;
case "catch_up_failed":
return "Runtime fell behind and could not catch up \u2014 retrying";
return streamMessages.catch_up_failed;
case "runtime_shutdown":
return "Runtime is shutting down \u2014 reconnecting";
return streamMessages.runtime_shutdown;
default:
return "Runtime ended the event stream \u2014 reconnecting";
return streamMessages.ended;
}
}
@@ -610,7 +611,7 @@
openThreadEvents(id, cursor).catch((err) => {
if (state.threadId !== id) return;
if (err instanceof TypeError) {
setStatus("Connection lost; reconnecting", "warn");
setStatus(streamMessages.reconnecting, "warn");
scheduleResume(id, cursor);
} else {
setStatus(err.message, "bad");
@@ -672,7 +673,7 @@
state.lastSeq = Math.max(state.lastSeq, data.last_seq);
}
if (data.retryable !== true) {
setStatus("Runtime closed this conversation's event stream; reopen the thread", "bad");
setStatus(streamMessages.closed, "bad");
return;
}
setStatus(streamEndStatus(data.reason), "warn");
@@ -684,7 +685,7 @@
state.openedAt = Date.now();
window.setTimeout(() => {
if (state.source === source && source.readyState === EventSource.OPEN) {
setStatus("Connected", "ok");
setStatus(streamMessages.connected, "ok");
}
}, 400);
};
@@ -693,7 +694,7 @@
source.onerror = () => {
if (state.source !== source || state.threadId !== id) return;
source.close();
setStatus("Connection lost; reconnecting", "warn");
setStatus(streamMessages.reconnecting, "warn");
scheduleResume(id, state.lastSeq > 0 ? state.lastSeq : resumeFrom);
};
}
@@ -751,7 +752,7 @@
$("steer").onclick = () => steerTurn().catch((err) => setStatus(err.message, "bad"));
$("interrupt").onclick = () => interruptTurn().catch((err) => setStatus(err.message, "bad"));
await loadThreads();
setStatus("Connected", "ok");
setStatus(streamMessages.connected, "ok");
}
boot().catch((err) => setStatus(err.message, "bad"));
+16
View File
@@ -108,3 +108,19 @@ Runtimes that send it advertise `x-codewhale-stream-end: 1`. If the iterator
ends without it, the connection was lost: resume from the last `seq` you
accepted. The full client rule is in
[`docs/RUNTIME_API.md`](../../docs/RUNTIME_API.md#ending-and-resuming-a-thread-stream).
Use `isThreadStreamEnd` to narrow iterator items in TypeScript. Journal event
names remain open-ended, so checking `event === "stream.end"` alone cannot
narrow the union:
```ts
import { isThreadStreamEnd } from "@codewhale/runtime-sdk";
for await (const item of client.threadEvents(threadId)) {
if (isThreadStreamEnd(item)) {
console.log(item.reason, item.last_seq, item.retryable);
} else {
console.log(item.seq, item.payload);
}
}
```
+5
View File
@@ -337,6 +337,11 @@ export interface ThreadStreamEnd {
retryable: boolean;
}
/** Narrow an item yielded by threadEvents; journal event names are open-ended. */
export function isThreadStreamEnd(
event: ThreadRuntimeEvent | ThreadStreamProgress | ThreadStreamEnd,
): event is ThreadStreamEnd;
export interface ThreadEventOptions {
sinceSeq?: number;
replayLimit?: number;
+5
View File
@@ -199,6 +199,11 @@ export function createRuntimeClient(options = {}) {
return new CodeWhaleRuntimeClient(options);
}
/** Distinguish a stream-end transport frame from thread journal/progress events. */
export function isThreadStreamEnd(event) {
return event.event === "stream.end";
}
function normalizeBaseUrl(value) {
return value.endsWith("/") ? value : `${value}/`;
}
+12 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CodeWhaleRuntimeClient } from '../index.js';
import { CodeWhaleRuntimeClient, isThreadStreamEnd } from '../index.js';
function clientFor(chunks, inspect = () => {}, headers = {}) {
return new CodeWhaleRuntimeClient({ token: 'fixture-token', fetch: async (url, init) => {
@@ -62,3 +62,14 @@ test('a server-ended thread stream yields its typed stream.end frame last', asyn
// The journal frame's SSE id becomes its cursor; stream.end carries none.
assert.deepEqual(await collect(client.threadEvents('t', { sinceSeq: 9 })), [{ ...record, cursor: '17' }, end]);
});
test('isThreadStreamEnd distinguishes end frames from journal and progress events', async () => {
const record = { seq: 17, event: 'future.journal.event', thread_id: 't', timestamp: '2026-09-12T00:00:00Z', payload: {} };
const progress = { event: 'stream.progress', thread_id: 't', seq: 17, state: 'live' };
const end = { schema_version: 1, event: 'stream.end', kind: 'stream.end', thread_id: 't', reason: 'runtime_shutdown', last_seq: 17, retryable: true };
const raw = [record, progress, end].map(value => `data: ${JSON.stringify(value)}\n\n`).join('');
const client = clientFor([raw], () => {}, { 'x-codewhale-event-progress': '1' });
const events = await collect(client.threadEvents('t', { includeProgress: true }));
assert.deepEqual(events.map(isThreadStreamEnd), [false, false, true]);
assert.deepEqual(events.filter(isThreadStreamEnd), [end]);
});
@@ -0,0 +1,41 @@
// Run with: tsc --noEmit --strict --target ES2022 --module NodeNext test/thread-events.types.ts
import { CodeWhaleRuntimeClient, isThreadStreamEnd } from '../index.js';
import type { ThreadRuntimeEvent, ThreadStreamEnd } from '../index.js';
async function checkThreadEvents(client: CodeWhaleRuntimeClient, includeProgress: boolean) {
for await (const item of client.threadEvents('thread')) {
if (isThreadStreamEnd(item)) {
const end: ThreadStreamEnd = item;
const cursor: number = end.last_seq;
const retryable: boolean = end.retryable;
if (retryable) client.threadEvents(end.thread_id, { sinceSeq: cursor });
} else {
const journal: ThreadRuntimeEvent = item;
journal.payload;
}
}
for await (const item of client.threadEvents('thread', { includeProgress: true })) {
if (isThreadStreamEnd(item)) {
const end: ThreadStreamEnd = item;
end.last_seq;
end.retryable;
} else {
const cursor: number = item.seq;
client.threadEvents(item.thread_id, { sinceSeq: cursor });
}
}
for await (const item of client.threadEvents('thread', { includeProgress })) {
if (isThreadStreamEnd(item)) {
const end: ThreadStreamEnd = item;
end.last_seq;
end.retryable;
}
}
}
// Future journal names must remain accepted without closing the protocol union.
const future: ThreadRuntimeEvent = {
event: 'future.journal.event', seq: 1, thread_id: 'thread', timestamp: '', payload: {},
};
isThreadStreamEnd(future);
void checkThreadEvents;