mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-26 08:42:02 +00:00
fix(ui): restore model and session authority
Move model search into the picker popover and keep the favorites filter beside it. Add an Advanced preference, translated across every locale, that hides only supplemental provider credit balances by default. Reconcile terminal native events against session.active with generation guards so stale completions cannot stop continuing work. Keep inbox admission from prematurely confirming optimistic sends and follow the V2 previous/next message cursor contract in both directions. Cover provider balance visibility, terminal reconciliation races, optimistic send settlement, and message pagination. Validation: 52 focused tests, full UI and Electron typecheck, 287 runnable UI tests, 126 browser integration tests, and release Tauri build.
This commit is contained in:
parent
3c35688a12
commit
430ed2a3a3
25 changed files with 280 additions and 96 deletions
1
.github/workflows/pr-build.yml
vendored
1
.github/workflows/pr-build.yml
vendored
|
|
@ -156,6 +156,7 @@ jobs:
|
|||
node --conditions=browser --import tsx --test --test-force-exit
|
||||
packages/ui/src/components/form-request-tool-target.test.ts
|
||||
packages/ui/src/components/form-request.test.ts
|
||||
packages/ui/src/components/session/provider-usage-panel.test.ts
|
||||
packages/ui/src/lib/hooks/use-active-session-message-load.test.ts
|
||||
packages/ui/src/stores/app-tabs.test.ts
|
||||
packages/ui/src/stores/forms.test.ts
|
||||
|
|
|
|||
|
|
@ -399,33 +399,20 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
}}
|
||||
>
|
||||
<ComboboxInputValue value={inputValue()} />
|
||||
<Combobox.Control class="selector-trigger relative w-full" data-model-selector-control>
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||
<Combobox.Input
|
||||
ref={searchInputRef}
|
||||
class="selector-trigger-primary selector-trigger-primary--align-left bg-transparent flex-1 min-w-0"
|
||||
data-model-selector
|
||||
value={inputValue()}
|
||||
placeholder={currentModelLabel()}
|
||||
aria-label={currentModelAccessibleLabel()}
|
||||
onFocus={() => {
|
||||
if (!isOpen()) searchInputRef.select()
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") queueMicrotask(restoreSelectedInput)
|
||||
}}
|
||||
/>
|
||||
{currentModelValue() && (
|
||||
<span class="selector-trigger-secondary" dir="ltr">
|
||||
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
||||
<Combobox.Control class="relative w-full" data-model-selector-control>
|
||||
<Combobox.Input class="sr-only" data-model-selector aria-label={currentModelAccessibleLabel()} />
|
||||
<Combobox.Trigger class="selector-trigger" aria-label={currentModelAccessibleLabel()}>
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
{currentModelLabel()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Combobox.Trigger
|
||||
class="selector-trigger-icon"
|
||||
aria-label={currentModelAccessibleLabel()}
|
||||
>
|
||||
<Combobox.Icon>
|
||||
{currentModelValue() && (
|
||||
<span class="selector-trigger-secondary" dir="ltr">
|
||||
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Combobox.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Combobox.Icon>
|
||||
</Combobox.Trigger>
|
||||
|
|
@ -433,23 +420,37 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class="selector-popover">
|
||||
<div class="selector-search-container">
|
||||
<div class="selector-input-group">
|
||||
<Combobox.Input
|
||||
ref={searchInputRef}
|
||||
class="selector-search-input flex-1 min-w-0"
|
||||
value={inputValue()}
|
||||
placeholder={t("modelSelector.placeholder.search")}
|
||||
aria-label={t("modelSelector.placeholder.search")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") queueMicrotask(restoreSelectedInput)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-favorites-toggle"
|
||||
aria-label={t("modelSelector.favoritesOnly.toggle.ariaLabel")}
|
||||
aria-pressed={favoritesOnlyEnabled()}
|
||||
disabled={!hasFavorites() || searchActive()}
|
||||
data-active={favoritesOnlyEnabled()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleFavoritesOnly()
|
||||
}}
|
||||
>
|
||||
<Star class="w-4 h-4" fill={favoritesOnlyEnabled() ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Combobox.Listbox ref={listboxRef} class="selector-listbox" />
|
||||
<div class="selector-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-option selector-option-action w-full"
|
||||
aria-label={t("modelSelector.favoritesOnly.toggle.ariaLabel")}
|
||||
aria-pressed={favoritesOnlyEnabled()}
|
||||
disabled={!hasFavorites() || searchActive()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleFavoritesOnly()
|
||||
}}
|
||||
>
|
||||
<Star class="w-4 h-4" fill={favoritesOnlyEnabled() ? "currentColor" : "none"} />
|
||||
<span class="selector-option-label">{t("modelSelector.favoritesOnly.toggle.ariaLabel")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-option selector-option-action w-full"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { shouldShowProviderUsageWindow } from "./provider-usage-panel"
|
||||
|
||||
test("supplemental provider credit balances are opt-in", () => {
|
||||
assert.equal(shouldShowProviderUsageWindow("credits_balance", false), false)
|
||||
assert.equal(shouldShowProviderUsageWindow("credits_balance", true), true)
|
||||
assert.equal(shouldShowProviderUsageWindow("credits", false), true)
|
||||
assert.equal(shouldShowProviderUsageWindow("weekly", false), true)
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Comp
|
|||
import type { ProviderUsageResponse, ProviderUsageWindow } from "../../../../server/src/api-types"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { useConfig } from "../../stores/preferences"
|
||||
|
||||
interface ProviderUsagePanelProps {
|
||||
providerId: string
|
||||
|
|
@ -12,8 +13,12 @@ interface ProviderUsagePanelProps {
|
|||
const REFRESH_INTERVAL_MS = 60_000
|
||||
const usageCache = new Map<string, { value: ProviderUsageResponse | null; updatedAt: number }>()
|
||||
|
||||
export const shouldShowProviderUsageWindow = (label: string, showCreditBalance: boolean) =>
|
||||
label !== "credits_balance" || showCreditBalance
|
||||
|
||||
const ProviderUsagePanel: Component<ProviderUsagePanelProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { preferences } = useConfig()
|
||||
const source = createMemo(() => {
|
||||
const providerId = props.providerId.trim()
|
||||
if (!providerId) return null
|
||||
|
|
@ -65,6 +70,9 @@ const ProviderUsagePanel: Component<ProviderUsagePanelProps> = (props) => {
|
|||
})
|
||||
|
||||
const entries = createMemo(() => Object.entries(usage()?.windows ?? {}))
|
||||
const displayedEntries = createMemo(() =>
|
||||
entries().filter(([label]) => shouldShowProviderUsageWindow(label, preferences().showProviderUsageCreditBalance)),
|
||||
)
|
||||
|
||||
const windowLabel = (label: string) => {
|
||||
const key = ({
|
||||
|
|
@ -126,7 +134,7 @@ const ProviderUsagePanel: Component<ProviderUsagePanelProps> = (props) => {
|
|||
}
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<For each={entries()}>
|
||||
<For each={displayedEntries()}>
|
||||
{([label, window]) => (
|
||||
<div>
|
||||
<div class="mb-1 flex items-baseline justify-between gap-2 text-[11px] text-primary">
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ export const AdvancedSettingsSection: Component = () => {
|
|||
getBehaviorSettings(config).filter(
|
||||
(setting) =>
|
||||
setting.id === "behavior.autoCleanupBlankSessions" ||
|
||||
setting.id === "behavior.keepUnseenSubagentIdleStatus",
|
||||
setting.id === "behavior.keepUnseenSubagentIdleStatus" ||
|
||||
setting.id === "behavior.providerUsageCreditBalance",
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -331,6 +331,8 @@ export const settingsMessages = {
|
|||
"settings.behavior.toolInputsVisibility.subtitle": "Standardsichtbarkeit für Argumente von Tool-Aufrufen festlegen.",
|
||||
"settings.behavior.usageMetrics.title": "Token-Verbrauchsmetriken",
|
||||
"settings.behavior.usageMetrics.subtitle": "Token- und Kostenstatistiken für Assistenten-Nachrichten anzeigen oder ausblenden.",
|
||||
"settings.behavior.providerUsageCreditBalance.title": "Anbieterguthaben",
|
||||
"settings.behavior.providerUsageCreditBalance.subtitle": "Zusätzliche Guthabenstände von Anbietern im Nutzungsbereich anzeigen.",
|
||||
"settings.behavior.autoCleanup.title": "Automatische Bereinigung leerer Sitzungen",
|
||||
"settings.behavior.autoCleanup.subtitle": "Leere Sitzungen beim Erstellen neuer Sitzungen automatisch bereinigen.",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "Statusanzeigen von Unteragenten beibehalten",
|
||||
|
|
|
|||
|
|
@ -331,6 +331,8 @@ export const settingsMessages = {
|
|||
"settings.behavior.toolInputsVisibility.subtitle": "Set default visibility for tool call input arguments.",
|
||||
"settings.behavior.usageMetrics.title": "Token usage metrics",
|
||||
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
|
||||
"settings.behavior.providerUsageCreditBalance.title": "Provider credit balance",
|
||||
"settings.behavior.providerUsageCreditBalance.subtitle": "Show supplemental provider credit balances in the Usage panel.",
|
||||
"settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions",
|
||||
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "Keep subagent idle markers",
|
||||
|
|
|
|||
|
|
@ -330,6 +330,8 @@ export const settingsMessages = {
|
|||
"settings.behavior.toolInputsVisibility.subtitle": "Establece la visibilidad por defecto de los argumentos de entrada de las llamadas de herramientas.",
|
||||
"settings.behavior.usageMetrics.title": "Métricas de uso de tokens",
|
||||
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadísticas de tokens y costo en mensajes del asistente.",
|
||||
"settings.behavior.providerUsageCreditBalance.title": "Saldo de créditos del proveedor",
|
||||
"settings.behavior.providerUsageCreditBalance.subtitle": "Muestra saldos de créditos adicionales del proveedor en el panel Uso.",
|
||||
"settings.behavior.autoCleanup.title": "Limpieza automática de sesiones en blanco",
|
||||
"settings.behavior.autoCleanup.subtitle": "Limpia automáticamente las sesiones en blanco al crear nuevas.",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "Mantener marcadores idle de subagentes",
|
||||
|
|
|
|||
|
|
@ -330,6 +330,8 @@ export const settingsMessages = {
|
|||
"settings.behavior.toolInputsVisibility.subtitle": "Definir la visibilite par defaut des arguments d'entree des appels d'outils.",
|
||||
"settings.behavior.usageMetrics.title": "Metriques d'utilisation des tokens",
|
||||
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
|
||||
"settings.behavior.providerUsageCreditBalance.title": "Solde de crédits du fournisseur",
|
||||
"settings.behavior.providerUsageCreditBalance.subtitle": "Afficher les soldes de crédits supplémentaires du fournisseur dans le panneau Utilisation.",
|
||||
"settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides",
|
||||
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "Garder les marqueurs inactifs des sous-agents",
|
||||
|
|
|
|||
|
|
@ -330,6 +330,8 @@ export const settingsMessages = {
|
|||
"settings.behavior.toolInputsVisibility.subtitle": "הגדר נראות ברירת מחדל לארגומנטים של קריאות כלי.",
|
||||
"settings.behavior.usageMetrics.title": "מדדי שימוש בטוקנים",
|
||||
"settings.behavior.usageMetrics.subtitle": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן.",
|
||||
"settings.behavior.providerUsageCreditBalance.title": "יתרת קרדיטים של הספק",
|
||||
"settings.behavior.providerUsageCreditBalance.subtitle": "הצגת יתרות קרדיטים נוספות של הספק בחלונית השימוש.",
|
||||
"settings.behavior.autoCleanup.title": "ניקוי אוטומטי של סשנים ריקים",
|
||||
"settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "השאר סמני idle של תתי-סוכנים",
|
||||
|
|
|
|||
|
|
@ -330,6 +330,8 @@ export const settingsMessages = {
|
|||
"settings.behavior.toolInputsVisibility.subtitle": "ツール呼び出しの入力引数の既定の表示状態を設定します。",
|
||||
"settings.behavior.usageMetrics.title": "トークン使用量メトリクス",
|
||||
"settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。",
|
||||
"settings.behavior.providerUsageCreditBalance.title": "プロバイダーのクレジット残高",
|
||||
"settings.behavior.providerUsageCreditBalance.subtitle": "使用状況パネルにプロバイダーの追加クレジット残高を表示します。",
|
||||
"settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ",
|
||||
"settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "サブエージェントの idle マーカーを保持",
|
||||
|
|
|
|||
|
|
@ -331,6 +331,8 @@ export const settingsMessages = {
|
|||
"settings.behavior.toolInputsVisibility.subtitle": "उपकरण कल इनपुट तर्कहरूको लागि पूर्वनिर्धारित दृश्यता सेट गर्नुहोस्।",
|
||||
"settings.behavior.usageMetrics.title": "टोकन प्रयोग मेट्रिक्स",
|
||||
"settings.behavior.usageMetrics.subtitle": "सन्देशहरूको लागि टोकन र लागत तथ्याङ्क देखाउनुहोस् वा लुकाउनुहोस्।",
|
||||
"settings.behavior.providerUsageCreditBalance.title": "प्रदायक क्रेडिट मौज्दात",
|
||||
"settings.behavior.providerUsageCreditBalance.subtitle": "प्रयोग प्यानलमा प्रदायकका अतिरिक्त क्रेडिट मौज्दात देखाउनुहोस्।",
|
||||
"settings.behavior.autoCleanup.title": "खाली सत्रहरू स्वतः सफा गर्नुहोस्",
|
||||
"settings.behavior.autoCleanup.subtitle": "नयाँ सत्रहरू सिर्जना गर्दा खाली सत्रहरू स्वतः सफा गर्नुहोस्।",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "उप-एजेन्ट निष्क्रिय (idle) मार्करहरू राख्नुहोस्",
|
||||
|
|
|
|||
|
|
@ -330,6 +330,8 @@ export const settingsMessages = {
|
|||
"settings.behavior.toolInputsVisibility.subtitle": "Задайте видимость по умолчанию для входных аргументов вызовов инструментов.",
|
||||
"settings.behavior.usageMetrics.title": "Метрики использования токенов",
|
||||
"settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.",
|
||||
"settings.behavior.providerUsageCreditBalance.title": "Баланс кредитов провайдера",
|
||||
"settings.behavior.providerUsageCreditBalance.subtitle": "Показывать дополнительные балансы кредитов провайдера на панели использования.",
|
||||
"settings.behavior.autoCleanup.title": "Автоочистка пустых сессий",
|
||||
"settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "Оставлять idle-маркеры субагентов",
|
||||
|
|
|
|||
|
|
@ -330,6 +330,8 @@ export const settingsMessages = {
|
|||
"settings.behavior.toolInputsVisibility.subtitle": "设置工具调用输入参数的默认可见性。",
|
||||
"settings.behavior.usageMetrics.title": "令牌用量指标",
|
||||
"settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。",
|
||||
"settings.behavior.providerUsageCreditBalance.title": "提供商额度余额",
|
||||
"settings.behavior.providerUsageCreditBalance.subtitle": "在用量面板中显示提供商的额外额度余额。",
|
||||
"settings.behavior.autoCleanup.title": "自动清理空会话",
|
||||
"settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。",
|
||||
"settings.behavior.keepUnseenSubagentIdle.title": "保留子智能体 idle 标记",
|
||||
|
|
|
|||
|
|
@ -78,11 +78,14 @@ test("model picker delegates keyboard selection to its accessible Kobalte input"
|
|||
assert.match(source, /onInputChange=\{setInputValue\}/)
|
||||
assert.match(source, /const context = useComboboxContext\(\)[\s\S]{0,100}context\.setInputValue\(props\.value\)/)
|
||||
assert.match(source, /else if \(triggerMode !== "input"\) setInputValue\(""\)/)
|
||||
assert.equal(source.match(/<Combobox\.Input/g)?.length, 1)
|
||||
assert.match(source, /<Combobox\.Input[\s\S]{0,300}data-model-selector/)
|
||||
assert.match(source, /<Combobox\.Input[\s\S]{0,300}value=\{inputValue\(\)\}[\s\S]{0,200}if \(!isOpen\(\)\) searchInputRef\.select\(\)/)
|
||||
assert.equal(source.match(/<Combobox\.Input/g)?.length, 2)
|
||||
assert.match(source, /<Combobox\.Input class="sr-only" data-model-selector/)
|
||||
const search = source.slice(source.indexOf('<div class="selector-search-container">'), source.indexOf("<Combobox.Listbox"))
|
||||
assert.match(search, /<Combobox\.Input[\s\S]{0,300}value=\{inputValue\(\)\}/)
|
||||
assert.match(search, /class="selector-input-group"[\s\S]*class="selector-favorites-toggle"/)
|
||||
assert.match(search, /aria-pressed=\{favoritesOnlyEnabled\(\)\}[\s\S]*disabled=\{!hasFavorites\(\) \|\| searchActive\(\)\}[\s\S]*data-active=\{favoritesOnlyEnabled\(\)\}/)
|
||||
assert.doesNotMatch(search, /<input\b/)
|
||||
assert.match(source, /onKeyDown=\{\(event\) => \{\s*if \(event\.key === "Escape"\) queueMicrotask\(restoreSelectedInput\)/)
|
||||
assert.doesNotMatch(source, /class="sr-only"/)
|
||||
assert.doesNotMatch(source, /dispatchEvent\(new KeyboardEvent/)
|
||||
assert.doesNotMatch(source, /const first = pickerOptions\(\)\.find/)
|
||||
assert.equal(source.match(/aria-label=\{currentModelAccessibleLabel\(\)\}/g)?.length, 2)
|
||||
|
|
@ -100,6 +103,8 @@ test("model picker delegates keyboard selection to its accessible Kobalte input"
|
|||
assert.match(source, /const closePicker = \(\) => \{\s*setIsOpen\(false\)\s*restoreSelectedInput\(\)/)
|
||||
assert.match(source, /if \(!next\) restoreSelectedInput\(\)/)
|
||||
assert.match(source, /closePicker\(\)\s*setProvidersModalOpen\(true\)/)
|
||||
const footer = source.slice(source.indexOf('<div class="selector-footer">'), source.indexOf("</Combobox.Content>"))
|
||||
assert.doesNotMatch(footer, /toggleFavoritesOnly/)
|
||||
})
|
||||
|
||||
test("provider auth keeps its catalog location across deferred operation steps", () => {
|
||||
|
|
|
|||
|
|
@ -302,6 +302,16 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
|
|||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
id: "behavior.providerUsageCreditBalance",
|
||||
titleKey: "settings.behavior.providerUsageCreditBalance.title",
|
||||
subtitleKey: "settings.behavior.providerUsageCreditBalance.subtitle",
|
||||
get: (p) => Boolean(p.showProviderUsageCreditBalance),
|
||||
set: (next) => {
|
||||
updatePreferences?.({ showProviderUsageCreditBalance: next })
|
||||
},
|
||||
},
|
||||
...(isLocalTauriHost() && actions.useTauriNativeEventTransport && actions.setUseTauriNativeEventTransport
|
||||
? [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1925,7 +1925,13 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters<NonNul
|
|||
if (sessionId && (force || event.type.startsWith("session.")) && (
|
||||
activeSessionId().get(instanceId) === sessionId
|
||||
&& isLatestWindow(messageStoreBus.getOrCreate(instanceId).getMessageWindow(sessionId))
|
||||
)) projectOpenCodeMessages(instanceId, sessionId, data, preserveOmitted)
|
||||
)) projectOpenCodeMessages(
|
||||
instanceId,
|
||||
sessionId,
|
||||
data,
|
||||
preserveOmitted,
|
||||
force || event.type !== "session.inbox.enqueued",
|
||||
)
|
||||
}
|
||||
const project = (data: ReturnType<typeof applyOpenCodeDataEvent>, preserveOmitted = true) => {
|
||||
projectMessages(data, preserveOmitted)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export function seedSessionMessagesV2(
|
|||
messageInfos?: Map<string, MessageInfo>,
|
||||
expectedRevision?: number,
|
||||
preserveOmitted = false,
|
||||
confirmPending = true,
|
||||
): boolean {
|
||||
if (!session || !Array.isArray(messages)) return false
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
|
|
@ -72,7 +73,7 @@ export function seedSessionMessagesV2(
|
|||
bumpRevision: false,
|
||||
}))
|
||||
|
||||
store.hydrateMessages(metadata.id, normalizedMessages, messageInfos?.values(), { preserveOmitted })
|
||||
store.hydrateMessages(metadata.id, normalizedMessages, messageInfos?.values(), { preserveOmitted, confirmPending })
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ export interface InstanceMessageStore {
|
|||
state: InstanceMessageState
|
||||
setState: SetStoreFunction<InstanceMessageState>
|
||||
addOrUpdateSession: (input: SessionUpsertInput) => void
|
||||
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>, options?: { preserveOmitted?: boolean }) => void
|
||||
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>, options?: { preserveOmitted?: boolean; confirmPending?: boolean }) => void
|
||||
reconcileEmptyAuthoritativeSnapshot: (sessionId: string) => void
|
||||
reconcileAuthoritativeMessageIds: (sessionId: string, authoritativeIds: ReadonlySet<string>, baselineRevisions: ReadonlyMap<string, number>) => void
|
||||
markSendPending: (messageId: string) => void
|
||||
|
|
@ -493,7 +493,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||
sessionId: string,
|
||||
inputs: MessageUpsertInput[],
|
||||
infos?: Iterable<MessageInfo>,
|
||||
options?: { preserveOmitted?: boolean },
|
||||
options?: { preserveOmitted?: boolean; confirmPending?: boolean },
|
||||
) {
|
||||
if (!Array.isArray(inputs) || inputs.length === 0) return
|
||||
|
||||
|
|
@ -607,12 +607,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||
clearRecordDisplayCacheForMessages(instanceId, omittedIds)
|
||||
}
|
||||
|
||||
// A send that reappears under its own id is confirmed — it is no longer in
|
||||
// flight, so retire its pending marker.
|
||||
serverIds.forEach((id) => {
|
||||
pendingSendIds.delete(id)
|
||||
if (serverIdsWithParts.has(id)) optimisticPartIdsByMessage.delete(id)
|
||||
})
|
||||
// Inbox admission can render before the message endpoint projects the
|
||||
// accepted prompt. Only authoritative projection confirms the send.
|
||||
if (options?.confirmPending !== false) {
|
||||
serverIds.forEach((id) => {
|
||||
pendingSendIds.delete(id)
|
||||
if (serverIdsWithParts.has(id)) optimisticPartIdsByMessage.delete(id)
|
||||
})
|
||||
}
|
||||
|
||||
if (infoList) {
|
||||
for (const info of infoList) {
|
||||
|
|
|
|||
|
|
@ -568,7 +568,13 @@ export function getOpenCodeMutationRevision(instanceId: string, sessionId: strin
|
|||
return mutationRevision(messageRevisionKey(instanceId, sessionId))[0]()
|
||||
}
|
||||
|
||||
export function projectOpenCodeMessages(instanceId: string, sessionId: string, data: Data, preserveOmitted = true): void {
|
||||
export function projectOpenCodeMessages(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
data: Data,
|
||||
preserveOmitted = true,
|
||||
confirmPending = true,
|
||||
): void {
|
||||
const source = data.session.message.list(sessionId).slice(-MESSAGE_WINDOW_PAGE_SIZE)
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
if (source.length) {
|
||||
|
|
@ -580,6 +586,7 @@ export function projectOpenCodeMessages(instanceId: string, sessionId: string, d
|
|||
new Map(normalized.map((item) => [item.info.id, item.info])),
|
||||
undefined,
|
||||
preserveOmitted,
|
||||
confirmPending,
|
||||
)
|
||||
} else if (!preserveOmitted) {
|
||||
store.reconcileEmptyAuthoritativeSnapshot(sessionId)
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export interface UiSettings {
|
|||
toolInputsVisibility: ToolInputsVisibilityPreference
|
||||
showUsageMetrics: boolean
|
||||
usageMetricsExpansion: ExpansionPreference
|
||||
showProviderUsageCreditBalance: boolean
|
||||
autoCleanupBlankSessions: boolean
|
||||
keepUnseenSubagentIdleStatus: boolean
|
||||
modelVisibility: ModelVisibilityPreferences
|
||||
|
|
@ -194,6 +195,7 @@ const defaultUiSettings: UiSettings = {
|
|||
toolInputsVisibility: "collapsed",
|
||||
showUsageMetrics: true,
|
||||
usageMetricsExpansion: "collapsed",
|
||||
showProviderUsageCreditBalance: false,
|
||||
autoCleanupBlankSessions: true,
|
||||
keepUnseenSubagentIdleStatus: false,
|
||||
modelVisibility: {},
|
||||
|
|
@ -300,6 +302,8 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
|||
sanitized.usageMetricsExpansion,
|
||||
usageMetricsFallback,
|
||||
),
|
||||
showProviderUsageCreditBalance:
|
||||
sanitized.showProviderUsageCreditBalance ?? defaultUiSettings.showProviderUsageCreditBalance,
|
||||
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions,
|
||||
keepUnseenSubagentIdleStatus:
|
||||
sanitized.keepUnseenSubagentIdleStatus ?? defaultUiSettings.keepUnseenSubagentIdleStatus,
|
||||
|
|
|
|||
|
|
@ -1336,16 +1336,16 @@ async function loadMessages(
|
|||
...(cursor ? { cursor } : { order: "desc" }),
|
||||
}, options?.signal ? { signal: options.signal } : undefined)
|
||||
if (!isCurrent()) return
|
||||
if (response.cursor?.next === planned.seekNewer) {
|
||||
if (response.cursor?.previous === planned.seekNewer) {
|
||||
resolvedNext = cursor
|
||||
? { kind: "history", resumeCursor: cursor, newerCursors: [null, null] }
|
||||
: emptyLatestWindow()
|
||||
break
|
||||
}
|
||||
const next = response.cursor?.next
|
||||
if (!next || seen.has(next)) throw new Error(tGlobal("messageSection.loadError.detail"))
|
||||
seen.add(next)
|
||||
cursor = next
|
||||
const previous = response.cursor?.previous
|
||||
if (!previous || seen.has(previous)) throw new Error(tGlobal("messageSection.loadError.detail"))
|
||||
seen.add(previous)
|
||||
cursor = previous
|
||||
}
|
||||
responseAscending = false
|
||||
} else {
|
||||
|
|
@ -1355,23 +1355,20 @@ async function loadMessages(
|
|||
...(planned.cursor ? { cursor: planned.cursor } : { order: planned.order ?? "desc" }),
|
||||
}, options?.signal ? { signal: options.signal } : undefined)
|
||||
}
|
||||
const nextCursor = response.cursor?.next ?? undefined
|
||||
if (planned.cursor && nextCursor === planned.cursor) {
|
||||
const olderCursor = response.cursor?.previous ?? undefined
|
||||
const newerCursor = response.cursor?.next ?? undefined
|
||||
const responseCursor = intent === "oldest" || planned.forward ? newerCursor : olderCursor
|
||||
if (planned.cursor && responseCursor === planned.cursor) {
|
||||
throw new Error("Repeated message cursor")
|
||||
}
|
||||
if (!isCurrent()) return
|
||||
|
||||
const latestSession = sessions().get(instanceId)?.get(sessionId)
|
||||
if (latestSession?.runtimeStatusKnown && latestSession.status === "idle") {
|
||||
store.retirePendingSends(sessionId)
|
||||
}
|
||||
|
||||
const forwardPage = intent === "oldest" || planned.forward
|
||||
const nextWindow = forwardPage
|
||||
? nextCursor
|
||||
? { ...resolvedNext, olderCursor: undefined, newerCursors: [nextCursor] }
|
||||
? newerCursor
|
||||
? { ...resolvedNext, olderCursor: undefined, newerCursors: [newerCursor] }
|
||||
: emptyLatestWindow()
|
||||
: withOlderCursor(resolvedNext, nextCursor)
|
||||
: withOlderCursor(resolvedNext, olderCursor)
|
||||
const hasLatestRevisionConflict = () => nextWindow.kind === "latest"
|
||||
&& getOpenCodeMessageRevision(instanceId, sessionId) !== liveMessageRevision
|
||||
const apiMessages = responseAscending ? [...response.data] : [...response.data].reverse()
|
||||
|
|
|
|||
|
|
@ -58,8 +58,20 @@ const pendingSessionFetches = new Map<string, {
|
|||
status: SessionStatus
|
||||
retry?: SessionRetryState | null
|
||||
}>()
|
||||
const nativeLifecycleGenerations = new Map<string, number>()
|
||||
let activeRetryToast: ToastHandle | null = null
|
||||
|
||||
function advanceNativeLifecycle(instanceId: string, sessionId: string): number {
|
||||
const key = `${instanceId}\0${sessionId}`
|
||||
const generation = (nativeLifecycleGenerations.get(key) ?? 0) + 1
|
||||
nativeLifecycleGenerations.set(key, generation)
|
||||
return generation
|
||||
}
|
||||
|
||||
function isCurrentNativeLifecycle(instanceId: string, sessionId: string, generation: number): boolean {
|
||||
return nativeLifecycleGenerations.get(`${instanceId}\0${sessionId}`) === generation
|
||||
}
|
||||
|
||||
function speakCompletedAssistantText(instanceId: string, sessionId: string): void {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const messageId = store.getLastAssistantMessageId(sessionId)
|
||||
|
|
@ -104,20 +116,28 @@ function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent)
|
|||
void fetchSessionInfo(instanceId, event.data.sessionID, event.location?.directory)
|
||||
return
|
||||
case "session.compaction.started":
|
||||
advanceNativeLifecycle(instanceId, event.data.sessionID)
|
||||
ensureSessionStatus(instanceId, event.data.sessionID, "compacting", event.location?.directory)
|
||||
return
|
||||
case "session.compaction.failed":
|
||||
setTerminalNativeSessionStatus(instanceId, event.data.sessionID, true, event.location?.directory)
|
||||
void reconcileTerminalNativeSessionStatus(instanceId, event.data.sessionID, {
|
||||
failed: true,
|
||||
directory: event.location?.directory,
|
||||
})
|
||||
return
|
||||
case "session.execution.interrupted":
|
||||
withSession(instanceId, event.data.sessionID, (session) => { session.generationRecovery = "interrupted" })
|
||||
setTerminalNativeSessionStatus(instanceId, event.data.sessionID, true, event.location?.directory)
|
||||
void loadMessages(instanceId, event.data.sessionID, { force: true }).catch((error) => {
|
||||
log.warn("Failed to refresh interrupted session messages", { instanceId, sessionId: event.data.sessionID, error })
|
||||
void reconcileTerminalNativeSessionStatus(instanceId, event.data.sessionID, {
|
||||
failed: true,
|
||||
interrupted: true,
|
||||
refreshMessages: true,
|
||||
directory: event.location?.directory,
|
||||
})
|
||||
return
|
||||
case "session.execution.succeeded":
|
||||
setTerminalNativeSessionStatus(instanceId, event.data.sessionID, false, event.location?.directory)
|
||||
void reconcileTerminalNativeSessionStatus(instanceId, event.data.sessionID, {
|
||||
failed: false,
|
||||
directory: event.location?.directory,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -131,10 +151,44 @@ function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent)
|
|||
event.type.startsWith("session.reasoning.") ||
|
||||
event.type.startsWith("session.tool.")
|
||||
) {
|
||||
advanceNativeLifecycle(instanceId, sessionId)
|
||||
ensureSessionStatus(instanceId, sessionId, "working", event.location?.directory)
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileTerminalNativeSessionStatus(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
options: { failed: boolean; interrupted?: boolean; refreshMessages?: boolean; directory?: string },
|
||||
): Promise<void> {
|
||||
const generation = advanceNativeLifecycle(instanceId, sessionId)
|
||||
let activeSessions: Record<string, unknown> | null = null
|
||||
try {
|
||||
activeSessions = await getRootClient(instanceId).session.active()
|
||||
} catch (error) {
|
||||
log.warn("Failed to reconcile terminal session state", { instanceId, sessionId, error })
|
||||
}
|
||||
if (!isCurrentNativeLifecycle(instanceId, sessionId, generation)) return
|
||||
|
||||
if (activeSessions && Object.prototype.hasOwnProperty.call(activeSessions, sessionId)) {
|
||||
withSession(instanceId, sessionId, (session) => { session.generationRecovery = null })
|
||||
const existing = sessions().get(instanceId)?.get(sessionId)
|
||||
if (existing) setSessionStatus(instanceId, sessionId, "working", { force: true })
|
||||
else ensureSessionStatus(instanceId, sessionId, "working", options.directory)
|
||||
return
|
||||
}
|
||||
|
||||
if (options.interrupted) {
|
||||
withSession(instanceId, sessionId, (session) => { session.generationRecovery = "interrupted" })
|
||||
}
|
||||
setTerminalNativeSessionStatus(instanceId, sessionId, options.failed, options.directory)
|
||||
if (options.refreshMessages) {
|
||||
void loadMessages(instanceId, sessionId, { force: true }).catch((error) => {
|
||||
log.warn("Failed to refresh interrupted session messages", { instanceId, sessionId, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function setTerminalNativeSessionStatus(instanceId: string, sessionId: string, failed: boolean, directory?: string): void {
|
||||
const existing = sessions().get(instanceId)?.get(sessionId)
|
||||
if (existing) setSessionStatus(instanceId, sessionId, "idle", { force: true })
|
||||
|
|
@ -448,6 +502,7 @@ function handleSessionDeleted(instanceId: string, event: EventSessionDeleted): v
|
|||
if (!sessionId) return
|
||||
|
||||
log.info(`[SSE] Session deleted: ${sessionId}`)
|
||||
nativeLifecycleGenerations.delete(`${instanceId}\0${sessionId}`)
|
||||
removeSessionRuntimeState(instanceId, sessionId)
|
||||
}
|
||||
|
||||
|
|
@ -455,6 +510,7 @@ function handleSessionIdle(instanceId: string, event: SessionIdle): void {
|
|||
const sessionId = event.data.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
advanceNativeLifecycle(instanceId, sessionId)
|
||||
if (shouldSendOsNotificationForSession("idle", instanceId, sessionId)) {
|
||||
const title = getInstanceDisplayName(instanceId)
|
||||
const label = getSessionTitle(instanceId, sessionId)
|
||||
|
|
@ -471,6 +527,7 @@ function handleSessionStatus(instanceId: string, event: SessionStatusUpdated): v
|
|||
const sessionId = event.data.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
advanceNativeLifecycle(instanceId, sessionId)
|
||||
const rawStatus = event.data.status
|
||||
const status = mapSdkSessionStatus(rawStatus)
|
||||
const retry = mapSdkSessionRetry(rawStatus)
|
||||
|
|
@ -502,6 +559,7 @@ function handleSessionCompacted(instanceId: string, event: SessionCompactionEnde
|
|||
if (!sessionID) return
|
||||
|
||||
log.info(`[SSE] Session compacted: ${sessionID}`)
|
||||
advanceNativeLifecycle(instanceId, sessionID)
|
||||
|
||||
const existing = sessions().get(instanceId)?.get(sessionID)
|
||||
if (existing) setSessionStatus(instanceId, sessionID, "working", { force: true })
|
||||
|
|
@ -525,7 +583,12 @@ function handleSessionCompacted(instanceId: string, event: SessionCompactionEnde
|
|||
function handleSessionError(instanceId: string, event: SessionExecutionFailed): void {
|
||||
const error = event.data.error
|
||||
const sessionId = event.data.sessionID
|
||||
if (sessionId) setTerminalNativeSessionStatus(instanceId, sessionId, true, event.location?.directory)
|
||||
if (sessionId) {
|
||||
void reconcileTerminalNativeSessionStatus(instanceId, sessionId, {
|
||||
failed: true,
|
||||
directory: event.location?.directory,
|
||||
})
|
||||
}
|
||||
log.error(`[SSE] Session error:`, error)
|
||||
|
||||
let message = tGlobal("sessionEvents.sessionError.unknown")
|
||||
|
|
|
|||
|
|
@ -148,4 +148,54 @@ describe("native session event reducer", () => {
|
|||
sdkManager.destroyClientsForInstance(instanceId)
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps active work when a terminal event is stale or the session remains active", async () => {
|
||||
const instanceId = "native-terminal-reconciliation"
|
||||
const sessionId = "session"
|
||||
let resolveActive!: (value: Record<string, unknown>) => void
|
||||
const client = {
|
||||
session: {
|
||||
active: () => new Promise<Record<string, unknown>>((resolve) => { resolveActive = resolve }),
|
||||
},
|
||||
} as any
|
||||
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
|
||||
addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client })
|
||||
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, {
|
||||
id: sessionId, instanceId, parentId: null, title: sessionId, agent: "build",
|
||||
model: { providerId: "provider", modelId: "model" }, status: "working", retry: null,
|
||||
idleSince: null, generationRecovery: null, runtimeStatusKnown: true,
|
||||
projectID: "project", location: { directory: "/work" }, time: { created: 1, updated: 1 },
|
||||
} as any]])))
|
||||
|
||||
try {
|
||||
handleNativeSessionEvent(instanceId, {
|
||||
id: "succeeded", created: 2, type: "session.execution.succeeded",
|
||||
durable: { aggregateID: sessionId, seq: 1, version: 1 },
|
||||
data: { sessionID: sessionId }, location: { directory: "/work" },
|
||||
})
|
||||
handleNativeSessionEvent(instanceId, {
|
||||
id: "restarted", created: 3, type: "session.execution.started",
|
||||
durable: { aggregateID: sessionId, seq: 2, version: 1 },
|
||||
data: { sessionID: sessionId }, location: { directory: "/work" },
|
||||
})
|
||||
resolveActive({})
|
||||
await delay(10)
|
||||
assert.equal(sessions().get(instanceId)?.get(sessionId)?.status, "working")
|
||||
|
||||
client.session.active = async () => ({ [sessionId]: {} })
|
||||
handleNativeSessionEvent(instanceId, {
|
||||
id: "interrupted", created: 4, type: "session.execution.interrupted",
|
||||
durable: { aggregateID: sessionId, seq: 3, version: 1 },
|
||||
data: { sessionID: sessionId, reason: "user" }, location: { directory: "/work" },
|
||||
})
|
||||
await delay(10)
|
||||
assert.equal(sessions().get(instanceId)?.get(sessionId)?.status, "working")
|
||||
assert.equal(sessions().get(instanceId)?.get(sessionId)?.generationRecovery, null)
|
||||
} finally {
|
||||
setSessions((previous) => { const next = new Map(previous); next.delete(instanceId); return next })
|
||||
clearInstanceDeletedSessionAuthority(instanceId)
|
||||
removeInstance(instanceId, { authoritative: false })
|
||||
sdkManager.destroyClientsForInstance(instanceId)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ describe("session request authority", () => {
|
|||
if (input.cursor && pendingSecondPage) return pendingSecondPage.promise
|
||||
return input.cursor
|
||||
? { data: [apiMessage("old-2"), apiMessage("old-1")], cursor: {} }
|
||||
: { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { next: "page-2" } }
|
||||
: { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { previous: "page-2" } }
|
||||
} }
|
||||
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
|
||||
|
||||
|
|
@ -287,7 +287,7 @@ describe("session request authority", () => {
|
|||
? { data: history.slice(200, 250), cursor: {} }
|
||||
: failRefresh
|
||||
? Promise.reject(new Error("replacement refresh failed"))
|
||||
: { data: history.slice(0, 200), cursor: { next: "older-page" } } }
|
||||
: { data: history.slice(0, 200), cursor: { previous: "older-page" } } }
|
||||
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
|
||||
|
||||
try {
|
||||
|
|
@ -346,7 +346,7 @@ describe("session request authority", () => {
|
|||
const oldPage = deferred<any>()
|
||||
;(old.client as any).message = { list: (input: any) => input.cursor
|
||||
? oldPage.promise
|
||||
: Promise.resolve({ data: [apiMessage("old-new")], cursor: { next: "old-page" } }) }
|
||||
: Promise.resolve({ data: [apiMessage("old-new")], cursor: { previous: "old-page" } }) }
|
||||
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
|
||||
await loadMessages(instanceId, sessionId)
|
||||
const oldRequest = loadMoreMessages(instanceId, sessionId)
|
||||
|
|
@ -356,7 +356,7 @@ describe("session request authority", () => {
|
|||
const currentPage = deferred<any>()
|
||||
let currentPageCalls = 0
|
||||
;(current.client as any).message = { list: (input: any) => {
|
||||
if (!input.cursor) return Promise.resolve({ data: [apiMessage("current-new")], cursor: { next: "current-page" } })
|
||||
if (!input.cursor) return Promise.resolve({ data: [apiMessage("current-new")], cursor: { previous: "current-page" } })
|
||||
currentPageCalls += 1
|
||||
return currentPage.promise
|
||||
} }
|
||||
|
|
@ -381,7 +381,7 @@ describe("session request authority", () => {
|
|||
const { client, cleanup } = setup(instanceId)
|
||||
let failOlder = false
|
||||
;(client as any).message = { list: async (input: any) => {
|
||||
if (!input.cursor) return { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { next: "page-2" } }
|
||||
if (!input.cursor) return { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { previous: "page-2" } }
|
||||
if (failOlder) throw new Error("older failed")
|
||||
return { data: [apiMessage("old-2"), apiMessage("old-1")], cursor: {} }
|
||||
} }
|
||||
|
|
@ -410,7 +410,7 @@ describe("session request authority", () => {
|
|||
let calls = 0
|
||||
;(client as any).message = { list: (input: any) => {
|
||||
calls += 1
|
||||
if (calls === 1) return Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } })
|
||||
if (calls === 1) return Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } })
|
||||
if (input.cursor === "older") return Promise.resolve({ data: [apiMessage("old")], cursor: {} })
|
||||
if (calls === 3) return staleLatest.promise
|
||||
return Promise.resolve({ data: [apiMessage("fresh")], cursor: {} })
|
||||
|
|
@ -543,7 +543,7 @@ describe("session request authority", () => {
|
|||
const oldest = deferred<any>()
|
||||
;(client as any).message = { list: (input: any) => input.order === "asc"
|
||||
? oldest.promise
|
||||
: Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } }) }
|
||||
: Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } }) }
|
||||
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
|
||||
try {
|
||||
await loadMessages(instanceId, sessionId)
|
||||
|
|
@ -566,7 +566,7 @@ describe("session request authority", () => {
|
|||
const oldest = deferred<any>()
|
||||
;(client as any).message = { list: (input: any) => input.order === "asc"
|
||||
? oldest.promise
|
||||
: Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } }) }
|
||||
: Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } }) }
|
||||
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
|
||||
try {
|
||||
await loadMessages(instanceId, sessionId)
|
||||
|
|
@ -592,7 +592,7 @@ describe("session request authority", () => {
|
|||
const oldest = deferred<any>()
|
||||
;(client as any).message = { list: (input: any) => input.order === "asc"
|
||||
? oldest.promise
|
||||
: Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } }) }
|
||||
: Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } }) }
|
||||
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
|
||||
try {
|
||||
await loadMessages(instanceId, sessionId)
|
||||
|
|
@ -618,7 +618,7 @@ describe("session request authority", () => {
|
|||
const oldest = deferred<any>()
|
||||
;(client as any).message = { list: (input: any) => input.order === "asc"
|
||||
? oldest.promise
|
||||
: Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } }) }
|
||||
: Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } }) }
|
||||
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
|
||||
try {
|
||||
await loadMessages(instanceId, sessionId)
|
||||
|
|
@ -682,7 +682,7 @@ describe("session request authority", () => {
|
|||
? stale.promise
|
||||
: Promise.resolve({ data: [apiMessage(`native-${mutation}`)], cursor: { next: "newer" } })
|
||||
}
|
||||
return Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } })
|
||||
return Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } })
|
||||
} }
|
||||
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
|
||||
try {
|
||||
|
|
@ -710,7 +710,7 @@ describe("session request authority", () => {
|
|||
const stale = deferred<any>()
|
||||
let oldestCalls = 0
|
||||
;(client as any).message = { list: (input: any) => {
|
||||
if (input.order !== "asc") return Promise.resolve({ data: [apiMessage("latest")], cursor: { next: "older" } })
|
||||
if (input.order !== "asc") return Promise.resolve({ data: [apiMessage("latest")], cursor: { previous: "older" } })
|
||||
oldestCalls += 1
|
||||
return oldestCalls === 1 ? stale.promise : Promise.resolve({ data: [apiMessage("authoritative")], cursor: {} })
|
||||
} }
|
||||
|
|
@ -749,7 +749,7 @@ describe("session request authority", () => {
|
|||
? staleNewer.promise
|
||||
: Promise.resolve({ data: [apiMessage("survivor")], cursor: {} })
|
||||
}
|
||||
return Promise.resolve({ data: [apiMessage("removed")], cursor: { next: "older" } })
|
||||
return Promise.resolve({ data: [apiMessage("removed")], cursor: { previous: "older" } })
|
||||
} }
|
||||
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
|
||||
try {
|
||||
|
|
@ -786,7 +786,7 @@ describe("session request authority", () => {
|
|||
}
|
||||
if (input.cursor === "newer-from-start") return { data: [apiMessage("middle-1"), apiMessage("middle-2")], cursor: { next: "newer-middle" } }
|
||||
if (input.cursor === "newer-middle") return { data: [apiMessage("recent")], cursor: {} }
|
||||
return { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { next: "page-2" } }
|
||||
return { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { previous: "page-2" } }
|
||||
} }
|
||||
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
|
||||
try {
|
||||
|
|
@ -817,9 +817,9 @@ describe("session request authority", () => {
|
|||
const { client, cleanup } = setup(instanceId)
|
||||
const latestPage = 40
|
||||
;(client as any).message = { list: async (input: any) => {
|
||||
if (!input.cursor) return { data: [apiMessage(`page-${latestPage}`)], cursor: { next: `c${latestPage - 1}` } }
|
||||
if (!input.cursor) return { data: [apiMessage(`page-${latestPage}`)], cursor: { previous: `c${latestPage - 1}` } }
|
||||
const page = Number(input.cursor.slice(1))
|
||||
return { data: [apiMessage(`page-${page}`)], cursor: page > 0 ? { next: `c${page - 1}` } : {} }
|
||||
return { data: [apiMessage(`page-${page}`)], cursor: page > 0 ? { previous: `c${page - 1}` } : {} }
|
||||
} }
|
||||
setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]])))
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue