Merge remote-tracking branch 'origin/main' into performance-improvements
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,7 @@ export type QuotaProviderId =
|
||||
| 'wafer'
|
||||
| 'opencode-go'
|
||||
| 'crof'
|
||||
| 'deepseek'
|
||||
| 'neuralwatt';
|
||||
|
||||
export interface UsageWindow {
|
||||
|
||||
Reference in New Issue
Block a user