diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx
index a9a620ac..b0b521b4 100644
--- a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx
+++ b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx
@@ -6,10 +6,10 @@ import { useI18n } from '@/lib/i18n';
import { useConfigStore } from '@/stores/useConfigStore';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { updateDesktopSettings } from '@/lib/persistence';
-import type { WalkthroughBlockedReason, WalkthroughModel } from '@/lib/walkthrough/types';
+import type { WalkthroughBlockedState, WalkthroughModel } from '@/lib/walkthrough/types';
interface WalkthroughBlockerProps {
- reason: WalkthroughBlockedReason;
+ reason: WalkthroughBlockedState;
model?: WalkthroughModel;
requiredChars?: number;
availableChars?: number;
@@ -102,6 +102,7 @@ export const WalkthroughBlocker = ({
if (reason === 'no-model') return t('walkthrough.blocked.noModel.description');
if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description');
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description');
+ if (reason === 'server-unsupported') return t('walkthrough.blocked.serverUnsupported.description');
if (reason === 'output-exhausted') {
return label
? t('walkthrough.blocked.outputExhausted.description', { model: label })
@@ -125,6 +126,7 @@ export const WalkthroughBlocker = ({
if (reason === 'no-model') return t('walkthrough.blocked.noModel.title');
if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title');
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title');
+ if (reason === 'server-unsupported') return t('walkthrough.blocked.serverUnsupported.title');
if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title');
if (reason === 'structured-output-unsupported') return t('walkthrough.blocked.structuredOutput.title');
return t('walkthrough.blocked.contextTooSmall.title');
@@ -156,7 +158,9 @@ export const WalkthroughBlocker = ({
)}
- {(reason === 'empty-diff' || reason === 'only-generated') && (
+ {/* Retry is the whole remedy once the server is updated, so it stays in
+ reach rather than sending the user back through the panel header. */}
+ {(reason === 'empty-diff' || reason === 'only-generated' || reason === 'server-unsupported') && (
diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx
index 00c8e6dd..9934a276 100644
--- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx
+++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx
@@ -440,6 +440,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|| entry.error?.code === 'empty-diff'
|| entry.error?.code === 'only-generated'
|| entry.error?.code === 'output-exhausted'
+ // Client-detected rather than reported: the server answered something that
+ // was not JSON, so it has no walkthrough routes at all.
+ || entry.error?.code === 'server-unsupported'
? entry.error.code
: entry.readiness && !entry.readiness.ready && !view
&& entry.readiness.reason !== 'no-provider-login'
diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts
index 4784df7b..b6bd1b81 100644
--- a/packages/ui/src/lib/i18n/messages/de.ts
+++ b/packages/ui/src/lib/i18n/messages/de.ts
@@ -2881,6 +2881,8 @@ export const dict = {
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Das kleine Modell hat sein gesamtes Ausgabelimit fürs Nachdenken verbraucht und nichts zurückgegeben. Denkende Modelle tun das bei großen Diffs oft — ein Modell, das weniger denkt, oder ein schmalerer Review-Bereich reicht eher aus.',
'walkthrough.blocked.onlyGenerated.title': 'Nur generierter Inhalt',
'walkthrough.blocked.onlyGenerated.description': 'Es ist nur generierter Inhalt vorhanden.',
+ 'walkthrough.blocked.serverUnsupported.title': 'Dieser Server unterstützt keine Walkthroughs',
+ 'walkthrough.blocked.serverUnsupported.description': 'Der OpenChamber-Server, mit dem diese App verbunden ist, hat die Walkthrough-API nicht beantwortet — er ist also älter als die App. Aktualisieren Sie den Server auf 1.18 oder neuer und aktualisieren Sie dann die Ansicht.',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Das kleine Modell passt in etwa {available}K Zeichen, und dieser Diff braucht etwa {required}K. Nichts wird abgeschnitten — wähle stattdessen ein Modell mit größerem Kontext.',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Das kleine Modell unterstützt die strukturierten Antworten nicht, die ein Walkthrough benötigt.',
'contextRail.surface.plan.description': 'Plankontext',
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts
index 09cba1a6..042c5cf8 100644
--- a/packages/ui/src/lib/i18n/messages/en.ts
+++ b/packages/ui/src/lib/i18n/messages/en.ts
@@ -1162,6 +1162,8 @@ export const dict = {
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'The small model spent its whole output allowance on reasoning and returned nothing. Reasoning models often do this on large diffs — a model that thinks less, or reviewing a narrower scope, will get through.',
'walkthrough.blocked.onlyGenerated.title': 'Only generated files changed',
'walkthrough.blocked.onlyGenerated.description': 'Every change here is a lockfile or other tool-produced output, which the review deliberately skips.',
+ 'walkthrough.blocked.serverUnsupported.title': 'This server has no walkthrough support',
+ 'walkthrough.blocked.serverUnsupported.description': 'The OpenChamber server this app is connected to did not answer the walkthrough API, which means it is older than the app. Update the server to 1.18 or newer, then refresh.',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'The small model fits about {available}K characters and this diff needs about {required}K. Nothing gets truncated — pick a model with a larger context instead.',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'The small model does not support the structured responses a walkthrough needs.',
'contextRail.surface.plan.description': 'View the current plan',
diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts
index dd514138..6209796c 100644
--- a/packages/ui/src/lib/i18n/messages/es.ts
+++ b/packages/ui/src/lib/i18n/messages/es.ts
@@ -1163,6 +1163,8 @@ export const dict: Record = {
"walkthrough.blocked.outputExhausted.descriptionUnknownModel": "El modelo pequeño gastó todo su margen de salida razonando y no devolvió nada. Los modelos de razonamiento suelen hacerlo con diffs grandes: prueba con un modelo que razone menos o revisa un ámbito más reducido.",
"walkthrough.blocked.onlyGenerated.title": "Solo cambiaron archivos generados",
"walkthrough.blocked.onlyGenerated.description": "Todos los cambios son archivos de bloqueo u otra salida generada por herramientas, que la revisión omite a propósito.",
+ "walkthrough.blocked.serverUnsupported.title": "Este servidor no admite walkthroughs",
+ "walkthrough.blocked.serverUnsupported.description": "El servidor de OpenChamber al que está conectada esta app no respondió a la API de walkthrough, así que es más antiguo que la app. Actualiza el servidor a 1.18 o posterior y vuelve a intentarlo.",
"walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "El modelo pequeño admite unos {available} mil caracteres y este diff necesita unos {required} mil. No se recorta nada: elige un modelo con más contexto.",
"walkthrough.blocked.structuredOutput.descriptionUnknownModel": "El modelo pequeño no admite las respuestas estructuradas que necesita un recorrido.",
"contextRail.surface.plan.description": "Ver el plan actual",
diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts
index 7bcdacee..09357712 100644
--- a/packages/ui/src/lib/i18n/messages/fr.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.ts
@@ -987,6 +987,8 @@ export const dict = {
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Le petit modèle a dépensé toute sa marge de sortie en raisonnement et n’a rien renvoyé. Les modèles de raisonnement le font souvent sur de gros diffs : essayez un modèle qui réfléchit moins, ou une portée plus étroite.',
'walkthrough.blocked.onlyGenerated.title': 'Seuls des fichiers générés ont changé',
'walkthrough.blocked.onlyGenerated.description': 'Toutes les modifications concernent des fichiers de verrouillage ou d’autres sorties générées, que la revue ignore délibérément.',
+ 'walkthrough.blocked.serverUnsupported.title': 'Ce serveur ne prend pas en charge les walkthroughs',
+ 'walkthrough.blocked.serverUnsupported.description': "Le serveur OpenChamber auquel cette application est connectée n'a pas répondu à l'API walkthrough : il est donc plus ancien que l'application. Mettez le serveur à jour en 1.18 ou plus récent, puis actualisez.",
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Le petit modèle accepte environ {available} k caractères et ce diff en demande environ {required} k. Rien n’est tronqué : choisissez un modèle au contexte plus large.',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Le petit modèle ne prend pas en charge les réponses structurées nécessaires à un parcours.',
'contextRail.surface.plan.description': 'Voir le plan actuel',
diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts
index d344d9ae..f8aab496 100644
--- a/packages/ui/src/lib/i18n/messages/ja.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.ts
@@ -1159,6 +1159,8 @@ export const dict: Record = {
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'スモールモデルは出力枠をすべて推論に使い、回答を返しませんでした。推論モデルは大きな差分でよくこうなります。推論の少ないモデルを選ぶか、対象範囲を絞ってください。',
'walkthrough.blocked.onlyGenerated.title': '生成ファイルのみが変更されています',
'walkthrough.blocked.onlyGenerated.description': 'ここでの変更はロックファイルなどツールが生成した出力だけで、レビューは意図的にこれらを対象外にしています。',
+ 'walkthrough.blocked.serverUnsupported.title': 'このサーバーはウォークスルーに対応していません',
+ 'walkthrough.blocked.serverUnsupported.description': 'このアプリが接続している OpenChamber サーバーはウォークスルー API に応答しませんでした。つまりアプリより古いバージョンです。サーバーを 1.18 以降に更新してから再読み込みしてください。',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'スモールモデルが扱えるのは約 {available} 千文字ですが、この差分には約 {required} 千文字が必要です。切り詰めは行いません。コンテキストの大きいモデルを選んでください。',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'スモールモデルはウォークスルーに必要な構造化応答をサポートしていません。',
'contextRail.surface.plan.description': '現在のプランを表示',
diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts
index 99244e62..ee0e0464 100644
--- a/packages/ui/src/lib/i18n/messages/ko.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.ts
@@ -1163,6 +1163,8 @@ export const dict: Record = {
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '스몰 모델이 출력 예산을 모두 추론에 쓰고 아무것도 반환하지 않았습니다. 추론 모델은 큰 diff에서 흔히 이렇게 됩니다. 덜 추론하는 모델을 고르거나 범위를 좁혀 보세요.',
'walkthrough.blocked.onlyGenerated.title': '생성된 파일만 변경되었습니다',
'walkthrough.blocked.onlyGenerated.description': '여기의 변경은 모두 잠금 파일이거나 도구가 만든 산출물이며, 리뷰는 이런 파일을 의도적으로 건너뜁니다.',
+ 'walkthrough.blocked.serverUnsupported.title': '이 서버는 워크스루를 지원하지 않습니다',
+ 'walkthrough.blocked.serverUnsupported.description': '이 앱이 연결된 OpenChamber 서버가 워크스루 API에 응답하지 않았습니다. 즉 앱보다 오래된 버전입니다. 서버를 1.18 이상으로 업데이트한 뒤 새로 고치세요.',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '스몰 모델은 약 {available}천 자를 담을 수 있는데 이 diff에는 약 {required}천 자가 필요합니다. 잘라내지 않으니 컨텍스트가 더 큰 모델을 선택하세요.',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '스몰 모델은 워크스루에 필요한 구조화된 응답을 지원하지 않습니다.',
'contextRail.surface.plan.description': '현재 계획 보기',
diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts
index 1dfa3db7..120967fd 100644
--- a/packages/ui/src/lib/i18n/messages/pl.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.ts
@@ -1475,6 +1475,8 @@ export const dict: Record = {
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Mały model zużył cały limit wyjścia na rozumowanie i nic nie zwrócił. Modele rozumujące często tak robią przy dużych różnicach — pomoże model mniej „myślący” albo węższy zakres przeglądu.',
'walkthrough.blocked.onlyGenerated.title': 'Zmieniły się tylko pliki generowane',
'walkthrough.blocked.onlyGenerated.description': 'Wszystkie zmiany to pliki blokad lub inne wyniki pracy narzędzi, które przegląd celowo pomija.',
+ 'walkthrough.blocked.serverUnsupported.title': 'Ten serwer nie obsługuje walkthroughów',
+ 'walkthrough.blocked.serverUnsupported.description': 'Serwer OpenChamber, z którym połączona jest ta aplikacja, nie odpowiedział na API walkthroughu — jest więc starszy niż aplikacja. Zaktualizuj serwer do wersji 1.18 lub nowszej i odśwież.',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Mały model mieści około {available} tys. znaków, a te różnice potrzebują około {required} tys. Nic nie jest obcinane — wybierz model z większym kontekstem.',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Mały model nie obsługuje ustrukturyzowanych odpowiedzi wymaganych przez przewodnik.',
'contextRail.surface.plan.description': 'Zobacz bieżący plan',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts
index 29cf2c9c..c6fc84f4 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts
@@ -1163,6 +1163,8 @@ export const dict: Record = {
"walkthrough.blocked.outputExhausted.descriptionUnknownModel": "O modelo pequeno gastou toda a margem de saída raciocinando e não devolveu nada. Modelos de raciocínio costumam fazer isso em diffs grandes — escolha um modelo que raciocine menos ou revise um escopo menor.",
"walkthrough.blocked.onlyGenerated.title": "Só mudaram arquivos gerados",
"walkthrough.blocked.onlyGenerated.description": "Todas as mudanças são arquivos de lock ou outra saída gerada por ferramentas, que a revisão ignora de propósito.",
+ "walkthrough.blocked.serverUnsupported.title": "Este servidor não oferece walkthroughs",
+ "walkthrough.blocked.serverUnsupported.description": "O servidor OpenChamber ao qual este app está conectado não respondeu à API de walkthrough, ou seja, é mais antigo que o app. Atualize o servidor para 1.18 ou mais recente e atualize a visualização.",
"walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "O modelo pequeno comporta cerca de {available} mil caracteres e este diff precisa de cerca de {required} mil. Nada é cortado — escolha um modelo com contexto maior.",
"walkthrough.blocked.structuredOutput.descriptionUnknownModel": "O modelo pequeno não suporta as respostas estruturadas que um percurso exige.",
"contextRail.surface.plan.description": "Ver o plano atual",
diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts
index f435acec..803b791b 100644
--- a/packages/ui/src/lib/i18n/messages/uk.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.ts
@@ -1163,6 +1163,8 @@ export const dict: Record = {
"walkthrough.blocked.outputExhausted.descriptionUnknownModel": "Small model витратила весь бюджет виводу на роздуми й нічого не повернула. Reasoning-моделі часто так поводяться на великих diff — допоможе модель, яка менше «думає», або вужча область огляду.",
"walkthrough.blocked.onlyGenerated.title": "Змінились лише згенеровані файли",
"walkthrough.blocked.onlyGenerated.description": "Усі зміни тут — це lock-файли чи інший результат роботи інструментів, які розбір свідомо пропускає.",
+ "walkthrough.blocked.serverUnsupported.title": "Цей сервер не підтримує walkthrough",
+ "walkthrough.blocked.serverUnsupported.description": "Сервер OpenChamber, до якого підключено застосунок, не відповів на walkthrough API — отже, він старіший за застосунок. Оновіть сервер до 1.18 або новішої версії та оновіть панель.",
"walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "Small model вміщає близько {available} тис. символів, а цьому diff потрібно близько {required} тис. Нічого не обрізається — оберіть модель із більшим контекстом.",
"walkthrough.blocked.structuredOutput.descriptionUnknownModel": "Small model не підтримує структуровані відповіді, потрібні для розбору.",
"contextRail.surface.plan.description": "Перегляд поточного плану",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts
index f8789892..db955a7e 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts
@@ -1163,6 +1163,8 @@ export const dict: Record = {
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '小模型把全部输出额度用在了推理上,没有返回结果。推理模型在大差异上经常如此——可以换一个少推理的模型,或缩小评审范围。',
'walkthrough.blocked.onlyGenerated.title': '只有生成文件发生了改动',
'walkthrough.blocked.onlyGenerated.description': '这里的改动全部是锁文件或其他工具生成的产物,评审会有意跳过它们。',
+ 'walkthrough.blocked.serverUnsupported.title': '该服务器不支持 walkthrough',
+ 'walkthrough.blocked.serverUnsupported.description': '此应用连接的 OpenChamber 服务器没有响应 walkthrough API,说明它比应用更旧。请将服务器升级到 1.18 或更高版本后刷新。',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '小模型大约可容纳 {available} 千字符,而这份差异约需 {required} 千字符。我们不会截断内容,请改选上下文更大的模型。',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '小模型不支持导读所需的结构化响应。',
'contextRail.surface.plan.description': '查看当前计划',
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts
index bd475161..13cbd616 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts
@@ -1175,6 +1175,8 @@ export const dict: Record = {
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '小模型把全部輸出額度用在推理上,沒有回傳結果。推理模型在大型差異上經常如此——可以改用較少推理的模型,或縮小審閱範圍。',
'walkthrough.blocked.onlyGenerated.title': '只有產生的檔案有變動',
'walkthrough.blocked.onlyGenerated.description': '這裡的變更全部是鎖定檔或其他工具產生的輸出,審閱會刻意略過它們。',
+ 'walkthrough.blocked.serverUnsupported.title': '該伺服器不支援 walkthrough',
+ 'walkthrough.blocked.serverUnsupported.description': '此應用程式連線的 OpenChamber 伺服器沒有回應 walkthrough API,代表它比應用程式更舊。請將伺服器升級到 1.18 或更新版本後重新整理。',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '小模型大約可容納 {available} 千字元,而這份差異約需 {required} 千字元。我們不會截斷內容,請改選上下文更大的模型。',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '小模型不支援導讀所需的結構化回應。',
'contextRail.surface.plan.description': '檢視目前計畫',
diff --git a/packages/ui/src/lib/walkthrough/api.test.ts b/packages/ui/src/lib/walkthrough/api.test.ts
new file mode 100644
index 00000000..0ad57a7e
--- /dev/null
+++ b/packages/ui/src/lib/walkthrough/api.test.ts
@@ -0,0 +1,88 @@
+import { beforeEach, describe, expect, mock, test } from 'bun:test';
+
+// A server older than this client does not answer 404-with-JSON: unmatched
+// `/api/*` reaches the OpenCode proxy, and OpenCode serves its embedded web UI
+// for any unknown path — HTML, status 200. These tests pin that the panel gets
+// an actionable code instead of a JSON parser error.
+
+let nextResponse: Response = new Response('{}', { headers: { 'Content-Type': 'application/json' } });
+
+mock.module('@/lib/runtime-fetch', () => ({
+ runtimeFetch: mock(async () => nextResponse),
+}));
+
+const { fetchWalkthrough, generateWalkthrough } = await import('./api');
+const { WalkthroughError } = await import('./types');
+import type { WalkthroughSource } from './types';
+
+const SOURCE: WalkthroughSource = { kind: 'working-tree', scope: 'all' };
+
+const html = (status: number) =>
+ new Response('OpenCode', {
+ status,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+
+describe('walkthrough api', () => {
+ beforeEach(() => {
+ nextResponse = new Response('{}', { headers: { 'Content-Type': 'application/json' } });
+ });
+
+ test('reads a JSON answer', async () => {
+ nextResponse = new Response(JSON.stringify({ hunkCount: 3 }), {
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ const result = await fetchWalkthrough('/repo', SOURCE);
+
+ expect(result.hunkCount).toBe(3);
+ });
+
+ test('reports HTML served with 200 as a server without the routes', async () => {
+ nextResponse = html(200);
+
+ const error = await fetchWalkthrough('/repo', SOURCE).catch((caught: unknown) => caught);
+
+ expect(error).toBeInstanceOf(WalkthroughError);
+ expect((error as InstanceType).code).toBe('server-unsupported');
+ expect((error as Error).message).not.toContain('JSON');
+ });
+
+ test('reports a non-JSON 404 the same way', async () => {
+ nextResponse = html(404);
+
+ const error = await generateWalkthrough('/repo', SOURCE).catch((caught: unknown) => caught);
+
+ expect((error as InstanceType).code).toBe('server-unsupported');
+ });
+
+ test('keeps a server-side failure rather than blaming the server version', async () => {
+ nextResponse = new Response(JSON.stringify({ error: 'model exploded', code: 'output-exhausted' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ const error = await generateWalkthrough('/repo', SOURCE).catch((caught: unknown) => caught);
+
+ expect((error as InstanceType).code).toBe('output-exhausted');
+ expect((error as Error).message).toBe('model exploded');
+ });
+
+ test('a 5xx that is not JSON is a broken server, not a missing route', async () => {
+ nextResponse = html(502);
+
+ const error = await fetchWalkthrough('/repo', SOURCE).catch((caught: unknown) => caught);
+
+ expect((error as InstanceType).code).toBe(undefined);
+ expect((error as Error).message).toBe('Failed to load walkthrough');
+ });
+
+ test('JSON that does not parse is reported without the parser wording', async () => {
+ nextResponse = new Response('{"walkthrough":', { headers: { 'Content-Type': 'application/json' } });
+
+ const error = await fetchWalkthrough('/repo', SOURCE).catch((caught: unknown) => caught);
+
+ expect(error).toBeInstanceOf(WalkthroughError);
+ expect((error as Error).message).toBe('The server returned a malformed walkthrough response');
+ });
+});
diff --git a/packages/ui/src/lib/walkthrough/api.ts b/packages/ui/src/lib/walkthrough/api.ts
index 344bf853..ef06d2b0 100644
--- a/packages/ui/src/lib/walkthrough/api.ts
+++ b/packages/ui/src/lib/walkthrough/api.ts
@@ -16,9 +16,30 @@ interface ErrorPayload {
availableChars?: unknown;
}
+const isJsonResponse = (response: Response): boolean =>
+ /^application\/(?:[\w.+-]+\+)?json\b/i.test(response.headers.get('content-type') ?? '');
+
+/**
+ * A server without these routes does not answer 404 with JSON. Unmatched
+ * `/api/*` falls through to the OpenCode proxy, and OpenCode serves its embedded
+ * web UI for any path it does not know — HTML, status 200. Parsing that as JSON
+ * surfaced `Unexpected token '<', "
+ new WalkthroughError('This OpenChamber server has no walkthrough API', { code: 'server-unsupported' });
+
+const looksUnsupported = (response: Response): boolean =>
+ !isJsonResponse(response) && (response.ok || response.status === 404);
+
// An authoritative read that fails must never look like "there is nothing
// here" — the caller would clear a perfectly good walkthrough off the screen.
const throwFromResponse = async (response: Response, fallback: string): Promise => {
+ if (looksUnsupported(response)) throw serverUnsupported();
const payload = (await response.json().catch(() => null)) as ErrorPayload | null;
throw new WalkthroughError(typeof payload?.error === 'string' ? payload.error : fallback, {
code: typeof payload?.code === 'string' ? (payload.code as WalkthroughError['code']) : undefined,
@@ -28,6 +49,17 @@ const throwFromResponse = async (response: Response, fallback: string): Promise<
});
};
+const readJson = async (response: Response): Promise => {
+ if (!isJsonResponse(response)) throw serverUnsupported();
+ try {
+ return (await response.json()) as T;
+ } catch {
+ // Declared JSON, arrived truncated or empty: still not an answer, and the
+ // parser's own message says nothing a reader can act on.
+ throw new WalkthroughError('The server returned a malformed walkthrough response');
+ }
+};
+
export async function fetchWalkthrough(
directory: string,
source: WalkthroughSource,
@@ -45,7 +77,7 @@ export async function fetchWalkthrough(
if (!response.ok) {
return throwFromResponse(response, 'Failed to load walkthrough');
}
- return response.json();
+ return readJson(response);
}
export async function generateWalkthrough(
@@ -68,7 +100,7 @@ export async function generateWalkthrough(
if (!response.ok) {
return throwFromResponse(response, 'Failed to generate walkthrough');
}
- return response.json();
+ return readJson(response);
}
/**
diff --git a/packages/ui/src/lib/walkthrough/types.ts b/packages/ui/src/lib/walkthrough/types.ts
index 5f8ef80a..e714a0ef 100644
--- a/packages/ui/src/lib/walkthrough/types.ts
+++ b/packages/ui/src/lib/walkthrough/types.ts
@@ -89,6 +89,7 @@ export interface WalkthroughResult {
*/
export type WalkthroughStage = 'collecting' | 'asking' | 'retrying' | 'assembling';
+/** Reasons the server reports for refusing to generate. */
export type WalkthroughBlockedReason =
| 'no-model'
| 'no-provider-login'
@@ -98,6 +99,13 @@ export type WalkthroughBlockedReason =
| 'structured-output-unsupported'
| 'output-exhausted';
+/**
+ * Everything the panel can render as a blocking screen. `server-unsupported` is
+ * never sent by a server — it is what the client concludes when the answer is
+ * not JSON at all, which is how a server too old to have these routes replies.
+ */
+export type WalkthroughBlockedState = WalkthroughBlockedReason | 'server-unsupported';
+
export interface WalkthroughReadiness {
ready: boolean;
reason?: WalkthroughBlockedReason;
@@ -116,7 +124,7 @@ export interface WalkthroughReadiness {
}
export class WalkthroughError extends Error {
- readonly code?: WalkthroughBlockedReason | 'invalid-walkthrough' | 'github-not-connected' | 'no-github-remote';
+ readonly code?: WalkthroughBlockedState | 'invalid-walkthrough' | 'github-not-connected' | 'no-github-remote';
readonly model?: WalkthroughModel;
readonly requiredChars?: number;
readonly availableChars?: number;
diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md
index 0f23fac5..abeb7e51 100644
--- a/packages/web/server/lib/walkthrough/DOCUMENTATION.md
+++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md
@@ -377,6 +377,21 @@ endpoint nothing calls is a maintenance surface that rots untested.
Registered lazily from `feature-routes-runtime.js`. `/api/walkthrough` is in the
JSON body-parser allowlist in `core-routes.js`.
+## A server that does not have these routes
+
+An `/api/*` path no OpenChamber route claims reaches the OpenCode proxy, and
+OpenCode answers any path it does not know with its embedded web UI — HTML, with
+status **200**. So a client newer than the server it is connected to is not told
+"no such route"; it is handed a web page. Parsing that as JSON is where
+`Unexpected token '<', "