Merge remote-tracking branch 'origin/main' into performance-improvements

This commit is contained in:
Bohdan Triapitsyn
2026-08-03 23:40:41 +03:00
67 changed files with 1982 additions and 368 deletions
@@ -666,9 +666,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" erfolgreich gelöscht',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Skill konnte nicht gelöscht werden',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Skill-Details für Duplizierung konnten nicht geladen werden',
'settings.skills.sidebar.toast.renameLoadFailed': 'Skill-Details konnten nicht geladen werden',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Alter Skill konnte nach Umbenennung nicht entfernt werden',
'settings.skills.sidebar.toast.renameFailed': 'Skill konnte nicht umbenannt werden',
'settings.skills.sidebar.toast.skillRenamed': 'Skill umbenannt in "{name}"',
'settings.skills.sidebar.deleteDialog.title': 'Skill löschen',
'settings.skills.sidebar.deleteDialog.description': 'Möchten Sie den Skill "{name}" wirklich löschen?',
'settings.skills.sidebar.renameDialog.title': 'Skill umbenennen',
@@ -718,9 +718,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" deleted successfully',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Failed to delete skill',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Failed to load skill details for duplication',
'settings.skills.sidebar.toast.renameLoadFailed': 'Failed to load skill details',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Failed to remove old skill after rename',
'settings.skills.sidebar.toast.renameFailed': 'Failed to rename skill',
'settings.skills.sidebar.toast.skillRenamed': 'Skill renamed to "{name}"',
'settings.skills.sidebar.deleteDialog.title': 'Delete Skill',
'settings.skills.sidebar.deleteDialog.description': 'Are you sure you want to delete skill "{name}"?',
'settings.skills.sidebar.renameDialog.title': 'Rename Skill',
@@ -685,9 +685,8 @@ export const settingsDict = {
"settings.skills.sidebar.toast.skillDeleted": "Habilidad \"{name}\" eliminada con éxito",
"settings.skills.sidebar.toast.deleteSkillFailed": "No se pudo eliminar la habilidad",
"settings.skills.sidebar.toast.duplicateLoadFailed": "No se pudo cargar la información de la habilidad para duplicarla",
"settings.skills.sidebar.toast.renameLoadFailed": "No se pudo cargar la información de la habilidad",
"settings.skills.sidebar.toast.removeOldAfterRenameFailed": "No se pudo eliminar la habilidad antigua después del cambio de nombre",
"settings.skills.sidebar.toast.renameFailed": "No se pudo cambiar el nombre de la habilidad",
"settings.skills.sidebar.toast.skillRenamed": "Habilidad renombrada a \"{name}\"",
"settings.skills.sidebar.deleteDialog.title": "Eliminar habilidad",
"settings.skills.sidebar.deleteDialog.description": "¿Estás seguro de que quieres eliminar la habilidad \"{name}\"?",
"settings.skills.sidebar.renameDialog.title": "Cambiar nombre habilidad",
@@ -606,9 +606,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" supprimé avec succès',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Échec de la suppression du skill',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Échec du chargement des détails du skill pour la duplication',
'settings.skills.sidebar.toast.renameLoadFailed': 'Échec du chargement des détails du skill',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Échec de la suppression de l\'ancien skill après le renommage',
'settings.skills.sidebar.toast.renameFailed': 'Échec du renommage du skill',
'settings.skills.sidebar.toast.skillRenamed': 'Skill renommé en "{name}"',
'settings.skills.sidebar.deleteDialog.title': 'Supprimer le skill',
'settings.skills.sidebar.deleteDialog.description': 'Êtes-vous sûr de vouloir supprimer le skill « {name} » ?',
'settings.skills.sidebar.renameDialog.title': 'Renommer le skill',
@@ -718,9 +718,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" を削除しました',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Skill の削除に失敗しました',
'settings.skills.sidebar.toast.duplicateLoadFailed': '複製用の Skill 詳細の読み込みに失敗しました',
'settings.skills.sidebar.toast.renameLoadFailed': 'Skill 詳細の読み込みに失敗しました',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '名前変更後に古い Skill の削除に失敗しました',
'settings.skills.sidebar.toast.renameFailed': 'Skill の名前変更に失敗しました',
'settings.skills.sidebar.toast.skillRenamed': 'Skill の名前を「{name}」に変更しました',
'settings.skills.sidebar.deleteDialog.title': 'Skill を削除',
'settings.skills.sidebar.deleteDialog.description': 'Skill "{name}" を削除してもよろしいですか?',
'settings.skills.sidebar.renameDialog.title': 'Skill の名前変更',
@@ -685,9 +685,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': '스킬 "{name}"을 삭제했습니다',
'settings.skills.sidebar.toast.deleteSkillFailed': '스킬을 삭제하지 못했습니다',
'settings.skills.sidebar.toast.duplicateLoadFailed': '복제를 위한 스킬 세부 정보를 로드하지 못했습니다',
'settings.skills.sidebar.toast.renameLoadFailed': '스킬 세부 정보를 로드하지 못했습니다',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '이름 변경 후 이전 스킬을 제거하지 못했습니다',
'settings.skills.sidebar.toast.renameFailed': '스킬 이름을 변경하지 못했습니다',
'settings.skills.sidebar.toast.skillRenamed': '스킬 이름이 "{name}"(으)로 변경되었습니다',
'settings.skills.sidebar.deleteDialog.title': '스킬 삭제',
'settings.skills.sidebar.deleteDialog.description': '스킬 "{name}"을 삭제하시겠습니까?',
'settings.skills.sidebar.renameDialog.title': '스킬 이름 변경',
@@ -1922,10 +1922,9 @@ export const settingsDict = {
'settings.skills.sidebar.title': 'Umiejętności',
'settings.skills.sidebar.toast.deleteSkillFailed': 'Nie udało się usunąć umiejętności',
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Nie udało się załadować szczegółów umiejętności do duplikacji',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Nie udało się usunąć starej umiejętności po zmianie nazwy',
'settings.skills.sidebar.toast.renameFailed': 'Nie udało się zmienić nazwy umiejętności',
'settings.skills.sidebar.toast.renameLoadFailed': 'Nie udało się załadować szczegółów umiejętności',
'settings.skills.sidebar.toast.skillDeleted': 'Umiejętność „{name}” została usunięta',
'settings.skills.sidebar.toast.skillRenamed': 'Zmieniono nazwę umiejętności na „{name}”',
'settings.skills.sidebar.total': 'Suma: {count}',
'settings.usage.pace.prediction': 'Prognoza: {prediction}',
'settings.usage.pace.predictionLabel': 'Prognoza: ',
@@ -685,9 +685,8 @@ export const settingsDict = {
"settings.skills.sidebar.toast.skillDeleted": "Habilidade \"{name}\" excluída com sucesso",
"settings.skills.sidebar.toast.deleteSkillFailed": "Não foi possível excluir a habilidade",
"settings.skills.sidebar.toast.duplicateLoadFailed": "Não foi possível carregar as informações da habilidade para duplicá-la",
"settings.skills.sidebar.toast.renameLoadFailed": "Não foi possível carregar as informações da habilidade",
"settings.skills.sidebar.toast.removeOldAfterRenameFailed": "Não foi possível excluir a habilidade antiga depois da renomeação",
"settings.skills.sidebar.toast.renameFailed": "Não foi possível renomear da habilidade",
"settings.skills.sidebar.toast.skillRenamed": "Habilidade renomeada para \"{name}\"",
"settings.skills.sidebar.deleteDialog.title": "Excluir habilidade",
"settings.skills.sidebar.deleteDialog.description": "Tem certeza de que deseja excluir a habilidade \"{name}\"?",
"settings.skills.sidebar.renameDialog.title": "Renomear habilidade",
@@ -685,9 +685,8 @@ export const settingsDict = {
"settings.skills.sidebar.toast.skillDeleted": "Навичку \"{name}\" успішно видалено",
"settings.skills.sidebar.toast.deleteSkillFailed": "Не вдалося видалити навичку",
"settings.skills.sidebar.toast.duplicateLoadFailed": "Не вдалося завантажити деталі навичок для дублювання",
"settings.skills.sidebar.toast.renameLoadFailed": "Не вдалося завантажити деталі навичок",
"settings.skills.sidebar.toast.removeOldAfterRenameFailed": "Не вдалося видалити стару навичку після перейменування",
"settings.skills.sidebar.toast.renameFailed": "Не вдалося перейменувати навичку",
"settings.skills.sidebar.toast.skillRenamed": "Навичку перейменовано на \"{name}\"",
"settings.skills.sidebar.deleteDialog.title": "Видалити навичку",
"settings.skills.sidebar.deleteDialog.description": "Ви впевнені, що бажаєте видалити навичку «{name}»?",
"settings.skills.sidebar.renameDialog.title": "Перейменувати навичку",
@@ -685,9 +685,8 @@ export const settingsDict = {
'settings.skills.sidebar.toast.skillDeleted': '技能“{name}”已删除',
'settings.skills.sidebar.toast.deleteSkillFailed': '删除技能失败',
'settings.skills.sidebar.toast.duplicateLoadFailed': '加载技能详情以复制失败',
'settings.skills.sidebar.toast.renameLoadFailed': '加载技能详情失败',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '重命名后移除旧技能失败',
'settings.skills.sidebar.toast.renameFailed': '重命名技能失败',
'settings.skills.sidebar.toast.skillRenamed': '技能已重命名为“{name}”',
'settings.skills.sidebar.deleteDialog.title': '删除技能',
'settings.skills.sidebar.deleteDialog.description': '确定要删除技能“{name}”吗?',
'settings.skills.sidebar.renameDialog.title': '重命名技能',
@@ -682,9 +682,8 @@
'settings.skills.sidebar.toast.skillDeleted': 'skill「{name}」已刪除',
'settings.skills.sidebar.toast.deleteSkillFailed': '刪除 skill 失敗',
'settings.skills.sidebar.toast.duplicateLoadFailed': '複製 skill 的詳細資訊載入失敗',
'settings.skills.sidebar.toast.renameLoadFailed': '載入 skill 詳情失敗',
'settings.skills.sidebar.toast.removeOldAfterRenameFailed': '重新命名後移除舊 skill 失敗',
'settings.skills.sidebar.toast.renameFailed': '重新命名 skill 失敗',
'settings.skills.sidebar.toast.skillRenamed': 'skill 已重新命名為「{name}」',
'settings.skills.sidebar.deleteDialog.title': '刪除 Skill',
'settings.skills.sidebar.deleteDialog.description': '確定要刪除 skill「{name}」嗎?',
'settings.skills.sidebar.renameDialog.title': '重新命名 Skill',
+8 -1
View File
@@ -12,6 +12,7 @@ import type {
TextPartInput,
FilePartInput,
} from "@opencode-ai/sdk/v2";
import { isAmbiguousTransportFailure, markAmbiguousTransportFailure } from "@/lib/relay/transport-error";
import type { PermissionRequest } from "@/types/permission";
import type { QuestionRequest } from "@/types/question";
@@ -878,7 +879,13 @@ class OpencodeService {
// failure) — there is no HTTP response to report. Never fabricate a
// status: surface it as a transport error so callers treat it like
// any other network failure instead of a server 500.
throw new Error(`Message send transport failure: ${formatSdkError(result.error)}`);
// Preserve the transport's "dispatched, outcome unknown" tag through
// the wrap: without it the caller cannot tell a lost response from a
// send that never reached the server, and re-sends a running prompt.
const transportError = new Error(`Message send transport failure: ${formatSdkError(result.error)}`);
throw isAmbiguousTransportFailure(result.error)
? markAmbiguousTransportFailure(transportError)
: transportError;
}
response = new Response(JSON.stringify(result.error), { status });
} else {
@@ -22,5 +22,6 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
{ id: 'wafer', name: 'Wafer.ai' },
{ id: 'opencode-go', name: 'OpenCode Go' },
{ id: 'crof', name: 'CrofAI' },
{ id: 'deepseek', name: 'DeepSeek' },
{ id: 'neuralwatt', name: 'NeuralWatt' },
];
@@ -0,0 +1,42 @@
/**
* Ambiguous transport failures.
*
* When a request dies after it was already handed to the transport, the client
* knows the response was lost — it does NOT know whether the server processed
* the request. Over the relay tunnel this is the common case: a reconnect, a
* host-side stream abort, or a dead channel all fail an in-flight POST that may
* already be running server-side.
*
* Callers must be able to tell that state apart from a definite failure, and
* string-matching the message text is not a contract — a renamed abort reason
* silently reclassifies a send. Transports therefore tag these errors, and
* callers read the tag (see `isAmbiguousTransportFailure`).
*
* `prompt_async` is the motivating case: treating an ambiguous failure as a
* definite one rolls back the user message and lets the queue re-send a prompt
* the engine is already answering, producing two independent AI responses.
*/
const AMBIGUOUS_TRANSPORT_FLAG = '__openchamberAmbiguousTransport';
/**
* Mark an error as "dispatched, outcome unknown". Returns the same error so it
* can be thrown inline.
*/
export const markAmbiguousTransportFailure = <T extends Error>(error: T): T => {
Object.defineProperty(error, AMBIGUOUS_TRANSPORT_FLAG, {
value: true,
enumerable: false,
configurable: true,
});
return error;
};
/**
* True when a transport tagged this error as dispatched-but-unconfirmed.
* Deliberately tag-only: text heuristics belong to the caller that owns them.
*/
export const isAmbiguousTransportFailure = (error: unknown): boolean => {
if (!error || typeof error !== 'object') return false;
return (error as Record<string, unknown>)[AMBIGUOUS_TRANSPORT_FLAG] === true;
};
@@ -11,6 +11,7 @@ import {
} from './crypto';
import { createHostHandshake } from './handshake';
import { TunnelFrameType } from './protocol';
import { isAmbiguousTransportFailure } from './transport-error';
import {
createFragmentAssembler,
decodeFrameBatch,
@@ -339,6 +340,24 @@ describe('createRelayTunnelClient', () => {
await expect(reader.read()).rejects.toThrow();
});
// A POST that dies after dispatch may already have been processed by the
// server. Callers must be able to tell that apart from a definite failure —
// a prompt re-sent on this error produces a second AI response (#2425).
test('tags an in-flight request killed by reconnect as an ambiguous failure', async () => {
const { client, killWire } = await setupClient({ silent: true });
track(client);
const pending = client.fetch('/api/session/s1/prompt_async', { method: 'POST', body: '{}' });
let caught: unknown = null;
const settled = pending.catch((error: unknown) => {
caught = error;
});
await wait(20);
killWire();
await settled;
expect(caught).toBeInstanceOf(Error);
expect(isAmbiguousTransportFailure(caught)).toBe(true);
});
test('opens, echoes, and closes a tunneled WebSocket', async () => {
const { client } = await setupClient();
track(client);
+17 -5
View File
@@ -35,6 +35,7 @@ import {
isWsClosePayload,
normalizeTunnelRequest,
} from './tunnel-payloads';
import { markAmbiguousTransportFailure } from './transport-error';
const EMPTY_PAYLOAD = new Uint8Array(0);
const textEncoder = new TextEncoder();
@@ -721,6 +722,13 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
}
};
// The request head is written to the channel below before any of these
// failures can fire, so losing the stream never proves the server did
// not process the request — only that the response was lost. Callers
// that would otherwise retry (prompt sends) must see that distinction.
const dispatchedFailure = (message: string): Error =>
markAmbiguousTransportFailure(new Error(message));
onAbort = () => {
sendAbort('aborted');
finishError(abortError());
@@ -735,7 +743,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
head = decodeJsonPayload(payload, isHttpResponsePayload);
} catch (error) {
sendAbort('malformed response head');
finishError(toError(error));
finishError(dispatchedFailure(toError(error).message));
return;
}
const nullBody = head.status === 204 || head.status === 205 || head.status === 304;
@@ -773,7 +781,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
if (frameType === TunnelFrameType.StreamEnd) {
if (finished) return;
if (!responseDelivered) {
finishError(new Error('tunnel stream ended before response head'));
finishError(dispatchedFailure('tunnel stream ended before response head'));
return;
}
finished = true;
@@ -792,11 +800,15 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
} catch {
// Keep the generic reason.
}
finishError(new Error(reason));
finishError(dispatchedFailure(reason));
}
},
fail(error) {
finishError(error);
// Channel death (reconnect, keepalive timeout) with this stream still
// open — same rule as above: dispatched, outcome unknown. A fresh
// error is tagged instead of the shared one so the tag cannot leak to
// waiters whose request never reached the wire.
finishError(dispatchedFailure(error.message));
},
});
@@ -824,7 +836,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela
}
} catch (error) {
sendAbort('request body failed');
finishError(toError(error));
finishError(dispatchedFailure(toError(error).message));
}
})();
});