Merge remote-tracking branch 'origin/main' into performance-improvements
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user