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
+10 -2
View File
@@ -159,6 +159,7 @@ const MAX_MOBILE_COMPOSER_LINES = 16;
*/
const MOBILE_COMPOSER_BOUND_GAP_PX = 4;
const EMPTY_QUEUE: QueuedMessage[] = [];
const EMPTY_SENDING_IDS: string[] = [];
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
const renameFileForAttachmentCitation = (file: File, filename: string): File => {
if (file.name === filename) {
@@ -945,9 +946,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
hasContent: options.presetText.trim().length > 0 || attachedFiles.length > 0 || hasDrafts,
}
: getCurrentInputSnapshot();
const queuedMessagesToSend = queuedMessageId
// A queued item stays in the queue until its own send resolves, so the
// auto-send hook may already be delivering one of these. Merging it here
// would send the same message twice (the window is seconds over a relay).
const sendingIds = messageQueueTarget
? useMessageQueueStore.getState().sendingIds[getMessageQueueKey(messageQueueTarget)] ?? EMPTY_SENDING_IDS
: EMPTY_SENDING_IDS;
const queuedMessagesToSend = (queuedMessageId
? queuedMessages.filter((message) => message.id === queuedMessageId)
: queuedMessages;
: queuedMessages
).filter((message) => !sendingIds.includes(message.id));
if (queuedOnly && autoReviewRunning) {
return;
@@ -35,6 +35,9 @@ interface SkillsSidebarProps {
const BUILT_IN_SKILL_LOCATION = '<built-in>';
const isBuiltInSkill = (skill: DiscoveredSkill | null | undefined): boolean => skill?.path === BUILT_IN_SKILL_LOCATION;
const isRenamableSkill = (skill: DiscoveredSkill | null | undefined): boolean => (
!!skill && !isBuiltInSkill(skill) && skill.renamable === true
);
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
@@ -49,16 +52,16 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
skills,
setSelectedSkill,
setSkillDraft,
createSkill,
deleteSkill,
renameSkill,
getSkillDetail,
} = useSkillsStore(useShallow((s) => ({
selectedSkillName: s.selectedSkillName,
skills: s.skills,
setSelectedSkill: s.setSelectedSkill,
setSkillDraft: s.setSkillDraft,
createSkill: s.createSkill,
deleteSkill: s.deleteSkill,
renameSkill: s.renameSkill,
getSkillDetail: s.getSkillDetail,
})));
@@ -140,14 +143,14 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
};
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
if (isBuiltInSkill(skill)) return;
if (!isRenamableSkill(skill)) return;
setRenameNewName(skill.name);
setRenameDialogSkill(skill);
};
const handleRenameSkill = async () => {
if (!renameDialogSkill) return;
if (isBuiltInSkill(renameDialogSkill)) {
if (!isRenamableSkill(renameDialogSkill)) {
setRenameDialogSkill(null);
return;
}
@@ -169,31 +172,11 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
return;
}
// Get full detail to copy
const detail = await getSkillDetail(renameDialogSkill.name);
if (!detail) {
toast.error(t('settings.skills.sidebar.toast.renameLoadFailed'));
setRenameDialogSkill(null);
return;
}
// Create new skill with new name
const success = await createSkill({
name: sanitizedName,
description: 'Renamed skill', // Will need proper description
scope: renameDialogSkill.scope,
source: renameDialogSkill.source,
});
// Rename in place on disk so SKILL.md body and supporting files are preserved.
const success = await renameSkill(renameDialogSkill.name, sanitizedName);
if (success) {
// Delete old skill
const deleteSuccess = await deleteSkill(renameDialogSkill.name);
if (deleteSuccess) {
toast.success(`Skill renamed to "${sanitizedName}"`);
setSelectedSkill(sanitizedName);
} else {
toast.error(t('settings.skills.sidebar.toast.removeOldAfterRenameFailed'));
}
toast.success(t('settings.skills.sidebar.toast.skillRenamed', { name: sanitizedName }));
setSelectedSkill(sanitizedName);
} else {
toast.error(t('settings.skills.sidebar.toast.renameFailed'));
}
@@ -463,13 +446,16 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
: t('settings.skills.sidebar.badge.opencode');
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
const isBuiltIn = isBuiltInSkill(skill);
const canRename = isRenamableSkill(skill);
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const renderMenuItems = (Item: React.ElementType) => (
<>
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</Item>
{canRename ? (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</Item>
) : null}
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDuplicate(); }}>
<Icon name="file-copy" className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
@@ -221,7 +221,9 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
return;
}
const payload = buildQueuedAutoSendPayload(queueSnapshot);
// Read the queue back at dispatch time and skip anything already being
// delivered, rather than trusting the render-time snapshot.
const payload = buildQueuedAutoSendPayload(useMessageQueueStore.getState().getSendableQueue(target));
if (!payload) {
return;
}
@@ -248,6 +250,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
}
inFlightSessionsRef.current.add(targetKey);
// The ref only guards this hook. Publish the dispatch to the store so the
// composer cannot merge the same item into a parallel send while this one
// is still awaiting the server.
useMessageQueueStore.getState().markSending(target, payload.queuedMessageId);
try {
await sendQueuedAutoSendPayload(sessionId, target.directory, payload, {
@@ -271,6 +277,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
retryScheduler.schedule(nextAttemptAt);
} finally {
inFlightSessionsRef.current.delete(targetKey);
useMessageQueueStore.getState().clearSending(target, payload.queuedMessageId);
}
};
@@ -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));
}
})();
});
+3
View File
@@ -47,9 +47,12 @@ Examples:
- `useProjectsStore.ts`
- `useGlobalSessionsStore.ts`
- `useSessionFoldersStore.ts`
- `messageQueueStore.ts`
These stores coordinate persistent project/session metadata across multiple views.
`messageQueueStore.ts` keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message.
`useGlobalSessionsStore.ts` owns cold/global active and archived session coverage, including `sessionsByDirectory`. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages.
User-visible session ordering is also not owned by the global cache array order. `sync/session-ordering.ts` combines lifecycle rank with timestamp fallbacks, and session surfaces must use that shared comparator instead of independently sorting global sessions by `time.updated`.
@@ -8,7 +8,7 @@ import {
} from "./messageQueueStore"
beforeEach(() => {
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {} })
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} })
})
describe("message queue runtime ownership", () => {
@@ -49,3 +49,48 @@ describe("message queue runtime ownership", () => {
expect(queue[0]?.content).toBe("message-5")
})
})
describe("in-flight queued sends", () => {
test("hides a dispatched message from the sendable queue but keeps it visible", () => {
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
const store = useMessageQueueStore.getState()
store.addToQueue(target, { content: "first" })
store.addToQueue(target, { content: "second" })
const [first] = useMessageQueueStore.getState().getQueueForTarget(target)
useMessageQueueStore.getState().markSending(target, first.id)
expect(useMessageQueueStore.getState().getQueueForTarget(target)).toHaveLength(2)
const sendable = useMessageQueueStore.getState().getSendableQueue(target)
expect(sendable).toHaveLength(1)
expect(sendable[0]?.content).toBe("second")
useMessageQueueStore.getState().clearSending(target, first.id)
expect(useMessageQueueStore.getState().getSendableQueue(target)).toHaveLength(2)
expect(useMessageQueueStore.getState().sendingIds).toEqual({})
})
test("clearQueue retains a message whose send is still awaiting the server", () => {
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
const store = useMessageQueueStore.getState()
store.addToQueue(target, { content: "in flight" })
store.addToQueue(target, { content: "merged by composer" })
const [inFlight] = useMessageQueueStore.getState().getQueueForTarget(target)
useMessageQueueStore.getState().markSending(target, inFlight.id)
useMessageQueueStore.getState().clearQueue(target)
const remaining = useMessageQueueStore.getState().getQueueForTarget(target)
expect(remaining).toHaveLength(1)
expect(remaining[0]?.id).toBe(inFlight.id)
})
test("clearQueue drops everything once no send is in flight", () => {
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
useMessageQueueStore.getState().addToQueue(target, { content: "queued" })
useMessageQueueStore.getState().clearQueue(target)
expect(useMessageQueueStore.getState().getQueueForTarget(target)).toHaveLength(0)
})
})
+59 -1
View File
@@ -85,6 +85,19 @@ interface MessageQueueState {
queuedMessages: Record<string, QueuedMessage[]>; // runtime + directory + session → queue
quarantinedLegacyMessages: Record<string, QueuedMessage[]>;
followUpBehavior: FollowUpBehavior;
/**
* Queued messages whose send is currently awaiting the server, per target.
*
* A queued item is removed only after its send resolves, so between
* dispatch and resolution it is still visible to every other reader and
* a composer submit merges the whole queue into its own send. Over a relay
* that window is seconds, long enough for the same message to be delivered
* twice. Dispatchers must skip entries listed here.
*
* Never persisted: a restart has no in-flight sends, and a stale flag would
* strand a queued message permanently.
*/
sendingIds: Record<string, string[]>;
}
interface MessageQueueActions {
@@ -94,6 +107,9 @@ interface MessageQueueActions {
popToInput: (target: MessageQueueTarget, messageId: string) => QueuedMessage | null;
clearQueue: (target: MessageQueueTarget) => void;
clearAllQueues: () => void;
markSending: (target: MessageQueueTarget, messageId: string) => void;
clearSending: (target: MessageQueueTarget, messageId: string) => void;
getSendableQueue: (target: MessageQueueTarget) => QueuedMessage[];
setFollowUpBehavior: (behavior: FollowUpBehavior) => void;
getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[];
}
@@ -127,6 +143,7 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
queuedMessages: {},
quarantinedLegacyMessages: {},
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
sendingIds: {},
addToQueue: (target, message) => {
const key = getMessageQueueKey(target);
@@ -237,6 +254,14 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
clearQueue: (target) => {
const key = getMessageQueueKey(target);
set((state) => {
// Clearing drops what is still queued, never a message
// already handed to the server: that send will resolve
// and must find its entry to remove or restore.
const sending = state.sendingIds[key] ?? [];
const retained = (state.queuedMessages[key] ?? []).filter((m) => sending.includes(m.id));
if (retained.length > 0) {
return { queuedMessages: { ...state.queuedMessages, [key]: retained } };
}
const { [key]: _removed, ...rest } = state.queuedMessages;
void _removed;
return { queuedMessages: rest };
@@ -244,7 +269,40 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
},
clearAllQueues: () => {
set({ queuedMessages: {} });
set({ queuedMessages: {}, sendingIds: {} });
},
markSending: (target, messageId) => {
const key = getMessageQueueKey(target);
set((state) => {
const current = state.sendingIds[key] ?? [];
if (current.includes(messageId)) return state;
return { sendingIds: { ...state.sendingIds, [key]: [...current, messageId] } };
});
},
clearSending: (target, messageId) => {
const key = getMessageQueueKey(target);
set((state) => {
const current = state.sendingIds[key];
if (!current || !current.includes(messageId)) return state;
const next = current.filter((id) => id !== messageId);
if (next.length === 0) {
const { [key]: _removed, ...rest } = state.sendingIds;
void _removed;
return { sendingIds: rest };
}
return { sendingIds: { ...state.sendingIds, [key]: next } };
});
},
getSendableQueue: (target) => {
const key = getMessageQueueKey(target);
const state = get();
const queue = state.queuedMessages[key] ?? [];
const sending = state.sendingIds[key];
if (!sending || sending.length === 0) return queue;
return queue.filter((message) => !sending.includes(message.id));
},
setFollowUpBehavior: (behavior) => {
@@ -98,9 +98,94 @@ describe('useSkillsStore directory resolution', () => {
source: 'agents',
description: 'Repository local',
group: undefined,
renamable: false,
}]);
});
test('loadSkills maps authoritative renamable from the list response', async () => {
runtimeFetchImpl = async () => new Response(JSON.stringify({
skills: [
{
name: 'managed-skill',
path: `${activeProjectPath}/.opencode/skills/managed-skill/SKILL.md`,
scope: 'project',
source: 'opencode',
renamable: true,
sources: { md: { description: 'Managed' } },
},
{
name: 'cache-skill',
path: '/home/ubuntu/.cache/opencode/skills/hash/cache-skill/SKILL.md',
scope: 'user',
source: 'opencode',
renamable: false,
sources: { md: { description: 'Cache' } },
},
],
}), {
headers: { 'Content-Type': 'application/json' },
});
expect(await useSkillsStore.getState().loadSkills()).toBe(true);
expect(useSkillsStore.getState().skills).toEqual([
{
name: 'managed-skill',
path: `${activeProjectPath}/.opencode/skills/managed-skill/SKILL.md`,
scope: 'project',
source: 'opencode',
description: 'Managed',
group: undefined,
renamable: true,
},
{
name: 'cache-skill',
path: '/home/ubuntu/.cache/opencode/skills/hash/cache-skill/SKILL.md',
scope: 'user',
source: 'opencode',
description: 'Cache',
group: 'hash',
renamable: false,
},
]);
});
test('renameSkill uses getRequestDirectory query and x-opencode-directory header', async () => {
runtimeFetchImpl = async (_url, init) => {
if (init?.method === 'PATCH') {
return new Response(JSON.stringify({
success: true,
requiresReload: false,
}), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({
skills: [{
name: 'new-skill',
path: `${activeProjectPath}/.opencode/skills/new-skill/SKILL.md`,
scope: 'project',
source: 'opencode',
renamable: true,
sources: { md: { description: 'Renamed' } },
}],
}), {
headers: { 'Content-Type': 'application/json' },
});
};
const renamed = await useSkillsStore.getState().renameSkill('old-skill', 'new-skill');
expect(renamed).toBe(true);
const renameCall = runtimeFetchCalls.find((call) => String(call.url).includes('/api/config/skills/old-skill'));
expect(renameCall).toBeTruthy();
expect(renameCall?.url).toContain(`directory=${encodeURIComponent(activeProjectPath)}`);
const headers = new Headers(renameCall?.headers);
expect(headers.get('content-type')).toBe('application/json');
expect(headers.get('x-opencode-directory')).toBe(activeProjectPath);
});
test('invalidateSkillsLoadCache() with no argument clears the active-project cache key used by loadSkills', async () => {
expect(await useSkillsStore.getState().loadSkills()).toBe(true);
expect(runtimeFetchCalls.length).toBe(1);
+52
View File
@@ -76,6 +76,8 @@ export interface DiscoveredSkill {
description?: string;
/** Domain folder parsed from file path, e.g. "automation-ai", "lark-ecosystem" */
group?: string;
/** Authoritative server flag: skill lives under a managed root and can be renamed in place. */
renamable?: boolean;
}
/** Parse the domain group folder from a skill file path.
@@ -99,6 +101,7 @@ interface RawSkillResponse {
path: string;
scope?: SkillScope;
source?: SkillSource;
renamable?: boolean;
sources?: {
md?: {
description?: string;
@@ -149,6 +152,7 @@ interface SkillsStore {
getSkillDetail: (name: string) => Promise<SkillDetail | null>;
createSkill: (config: SkillConfig) => Promise<boolean>;
updateSkill: (name: string, config: Partial<SkillConfig>) => Promise<boolean>;
renameSkill: (name: string, newName: string) => Promise<boolean>;
deleteSkill: (name: string) => Promise<boolean>;
getSkillByName: (name: string) => DiscoveredSkill | undefined;
@@ -245,6 +249,7 @@ export const useSkillsStore = create<SkillsStore>()(
source: s.source ?? 'opencode',
description: s.sources?.md?.description || '',
group: parseSkillGroup(s.path),
renamable: s.renamable === true,
}));
set({ skills: configSkills, isLoading: false });
@@ -399,6 +404,53 @@ export const useSkillsStore = create<SkillsStore>()(
}
},
renameSkill: async (name: string, newName: string) => {
startConfigUpdate("Renaming skill...");
let requiresReload = false;
try {
const directory = getRequestDirectory();
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify({ renameTo: newName }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to rename skill';
throw new Error(message);
}
const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(directory);
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
} catch {
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
deleteSkill: async (name: string) => {
startConfigUpdate("Deleting skill...");
let requiresReload = false;
+1
View File
@@ -232,6 +232,7 @@ Rules:
3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls.
4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected.
5. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
6. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
Examples of global-store updates performed in `session-actions.ts`:
@@ -1014,6 +1014,53 @@ describe("optimisticSend target directory", () => {
expect(targetStore.getState().part[sentMessageID]?.[0]?.id).toBe("server-part")
})
// Relay tunnel aborts carry no HTTP status and no wording the text-matching
// heuristic recognizes. Without the transport tag they were classified as
// definite failures, the accepted prompt was rolled back, and the queue
// re-sent a message the engine was already answering (#2425).
test("confirms a tunnel-tagged transport failure that no text heuristic matches", async () => {
const targetStore = createStore({})
const childStores = createChildStores([["/target/project", targetStore]])
let optimisticRemove: OptimisticRemoveCall | null = null
let optimisticConfirm: OptimisticRemoveCall | null = null
let sentMessageID = ""
const { markAmbiguousTransportFailure } = await import("@/lib/relay/transport-error")
const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project")
setOptimisticRefs(
() => {},
(input) => {
optimisticRemove = input
},
(input) => {
optimisticConfirm = input
},
)
await optimisticSend({
sessionId: "session-tunnel",
directory: "/target/project",
content: "hello",
providerID: "provider",
modelID: "model",
send: async (messageID) => {
sentMessageID = messageID
sessionMessagesResult = {
data: [{
info: { id: messageID, role: "user", sessionID: "session-tunnel", time: { created: 1 } } as Message,
parts: [{ id: "server-part", type: "text", text: "hello" } as Part],
}],
}
throw markAmbiguousTransportFailure(new Error("stream aborted by host"))
},
})
expect(optimisticRemove).toBe(null)
expect((optimisticConfirm as OptimisticRemoveCall | null)?.messageID).toBe(sentMessageID)
expect(targetStore.getState().message["session-tunnel"]?.[0]?.id).toBe(sentMessageID)
})
test("rolls back an ambiguous send failure when recent messages do not contain the sent ID", async () => {
const targetStore = createStore({})
const childStores = createChildStores([["/target/project", targetStore]])
+27 -3
View File
@@ -29,11 +29,21 @@ import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/l
import { getImperativeSessionMessageLoader } from "./session-message-loader"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error"
const MESSAGE_REFETCH_LIMIT = 100
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
const SEND_CONFIRMATION_REFETCH_ATTEMPTS = 2
const SEND_CONFIRMATION_REFETCH_RETRY_MS = 150
// A relay-tunnel send fails when the tunnel drops, and the confirming refetch
// then has to travel over that same tunnel to answer "did my message land?".
// Two attempts 150ms apart always answered "no" on a remote connection, so an
// accepted prompt looked like a failed one and got re-sent — two AI responses
// for one user message. Wait for the connection to actually come back (an
// authoritative signal, not a blind sleep), then retry with backoff. A healthy
// connection skips the wait and answers on the first attempt.
const SEND_CONFIRMATION_REFETCH_ATTEMPTS = 3
const SEND_CONFIRMATION_REFETCH_BASE_RETRY_MS = 250
const SEND_CONFIRMATION_RECONNECT_TIMEOUT_MS = 3000
const SEND_CONFIRMATION_RECONNECT_POLL_MS = 100
const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const UNREVERT_REFETCH_ATTEMPTS = 3
const UNREVERT_REFETCH_RETRY_MS = 150
@@ -360,6 +370,13 @@ function getErrorStatus(error: unknown): number | null {
}
function isAmbiguousSendFailure(error: unknown): boolean {
// Authoritative first: the transport that lost the request says whether it
// had already been dispatched. The text matching below only covers direct
// fetch/HTTP failures, whose wording we do not control either — relay tunnel
// aborts ("stream aborted by host", "relay keepalive timeout", …) match none
// of those patterns and used to be misread as definite failures.
if (isAmbiguousTransportFailure(error)) return true
const status = getErrorStatus(error)
if (status === 503 || status === 504 || status === 408) return true
if (error instanceof TypeError) return true
@@ -1255,8 +1272,15 @@ async function fetchRecentSendConfirmationRecords(
messageID: string,
directory?: string | null,
): Promise<Array<{ info: Message; parts?: Part[] }> | null> {
// Bounded: a connection that never returns must still let the send fail
// rather than hang the composer.
const reconnectDeadline = Date.now() + SEND_CONFIRMATION_RECONNECT_TIMEOUT_MS
while (!useConfigStore.getState().isConnected && Date.now() < reconnectDeadline) {
await wait(SEND_CONFIRMATION_RECONNECT_POLL_MS)
}
for (let attempt = 0; attempt < SEND_CONFIRMATION_REFETCH_ATTEMPTS; attempt += 1) {
if (attempt > 0) await wait(SEND_CONFIRMATION_REFETCH_RETRY_MS)
if (attempt > 0) await wait(SEND_CONFIRMATION_REFETCH_BASE_RETRY_MS * 2 ** (attempt - 1))
try {
const result = await sdk().session.messages({
sessionID: sessionId,
+1
View File
@@ -17,6 +17,7 @@ export type QuotaProviderId =
| 'wafer'
| 'opencode-go'
| 'crof'
| 'deepseek'
| 'neuralwatt';
export interface UsageWindow {