mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-26 00:31:54 +00:00
fix(v2): make queue mutations reconnect-safe
Remove queue reordering because the native inbox contract cannot perform it atomically and suffix replacement could duplicate or prematurely execute prompts. Serialize visible queue actions and keep edit replacement limited to the native admit-then-cancel safety guarantee. Rebind mounted inbox observers after a reconnect generation, isolate per-session inbox refresh failures, avoid duplicate Shell refreshes, and exclude hidden queued prompts from transcript search. Queue an alternate submission when another admission is still in flight. Validated with UI and server typechecks, 84 focused session/data tests, 46 shortcut/scroll tests, and a production UI build.
This commit is contained in:
parent
b2fa1ba7de
commit
36be70ac38
18 changed files with 66 additions and 102 deletions
|
|
@ -143,7 +143,7 @@ During stabilization, CodeNomad implements the public native V2 contract and rem
|
|||
| Delete-to-boundary / undo | `session.revert.stage` and `session.revert.clear` use V2 staged-revert semantics instead of arbitrary deletion. | Undo uses `revert.stage`. Redo/clear remains a CodeNomad UI gap, not a reason to restore V1 deletion. | Expose `revert.clear` through the existing native contract. |
|
||||
| Compaction | Native checkpoint compaction summarizes the older head and retains a server-selected recent tail controlled by `compaction.keep.tokens`. | Uses `session.compact`; there is no message-level selective compaction. CodeNomad displays the terminal summary without rendering every streamed delta. | Add scoped controls only if V2 defines scoped compaction semantics. |
|
||||
| Full-session search | Native message cursors exist, but there is no server search endpoint that returns message identity and position. | Retained through bounded cursor traversal while keeping only the 200-message resident window and collected matches. Large searches still fetch every page. | V2 exposes server search plus a rank/cursor navigation target. |
|
||||
| Queued prompt management | Native inbox list, cancel, steer, and queue operations are available. | Implemented with authoritative queue/steer switching, cancellation, safe edit replacement, draft preservation, order-preserving suffix rewrites, and queue admission for follow-up prompts. | Replace suffix rewrites if V2 adds atomic inbox update and reorder operations. |
|
||||
| Queued prompt management | Native inbox list, cancel, steer, and queue operations are available. | Implemented with authoritative steering, cancellation, safe edit replacement, draft preservation, and queue admission for follow-up prompts. | Add in-place editing and reordering when V2 exposes atomic inbox mutations; replacement currently moves an edited prompt to the queue tail. |
|
||||
| Background execution | Native Shell resources support listing, bounded output, and removal, but do not reproduce every custom V1 process-manager control. | Replaced the custom manager with native Shells and removed unsupported controls such as rename. | Add controls only when native Shell APIs support them. |
|
||||
| Service lifecycle | V2 uses one shared externally owned service rather than one runtime per workspace. | CodeNomad discovers or starts the service but never exposes workspace stop or stops the daemon on shutdown. | No parity work planned unless V2 changes service ownership semantics. |
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ import {
|
|||
syncLoadedSessionInboxes,
|
||||
syncPendingRequests,
|
||||
} from "./stores/instances"
|
||||
import { shellStore } from "./stores/shells"
|
||||
import {
|
||||
getSessions,
|
||||
getSessionRoot,
|
||||
|
|
@ -333,7 +332,6 @@ const App: Component = () => {
|
|||
syncPendingRequests(id, (invalidate) => { invalidatePendingRequests = invalidate }),
|
||||
refreshVolatileInstanceState(id),
|
||||
syncLoadedSessionInboxes(id),
|
||||
shellStore.refreshForEvent(id, { type: "server.connected" }),
|
||||
])
|
||||
if (sessionError) throw sessionError
|
||||
})(),
|
||||
|
|
|
|||
|
|
@ -792,7 +792,8 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
isLatest: () => isLatestWindow(store().getMessageWindow(sessionId)),
|
||||
loadOldest: props.onLoadOldestMessages ?? (() => Promise.resolve()),
|
||||
loadNewer: props.onLoadNewerMessages ?? (() => Promise.resolve()),
|
||||
visit: () => buildSessionSearchMatches({ store: store(), sessionId, query, includeThinking }),
|
||||
visit: () => buildSessionSearchMatches({ store: store(), sessionId, query, includeThinking })
|
||||
.filter((match) => !props.queuedMessageIds?.has(match.messageId)),
|
||||
}).then((matches) => {
|
||||
if (!matches) {
|
||||
if (isCurrentSearch()) setIsSearchPending(false)
|
||||
|
|
@ -828,6 +829,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
const includeThinking = Boolean(preferences().showThinkingBlocks)
|
||||
const currentResidentIds = messageIds()
|
||||
const currentMatches = buildSessionSearchMatches({ store: store(), sessionId: props.sessionId, query, includeThinking })
|
||||
.filter((match) => !props.queuedMessageIds?.has(match.messageId))
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (isSearchPending() || !hasMessageSearchAuthority(searchQuery(), query)) return
|
||||
const activeId = activeSearchMatch()?.id
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
let wrapperRef: HTMLDivElement | undefined
|
||||
let fieldContainerRef: HTMLDivElement | undefined
|
||||
let resizeDragState: ResizeDragState | undefined
|
||||
let submissionsInFlight = 0
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (mode() === "shell") {
|
||||
|
|
@ -519,6 +520,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
focusConversationStream(wrapperRef?.closest(".session-view"))
|
||||
}
|
||||
|
||||
submissionsInFlight += 1
|
||||
try {
|
||||
if (isShellMode) {
|
||||
if (props.onRunShell) {
|
||||
|
|
@ -546,6 +548,8 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
textareaRef?.focus()
|
||||
}
|
||||
return
|
||||
} finally {
|
||||
submissionsInFlight -= 1
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -737,7 +741,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
getAttachments: attachments,
|
||||
removeAttachment: (attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId),
|
||||
submitOnEnter,
|
||||
onSend: (alternate) => void handleSend(alternate && props.isSessionBusy ? "queue" : "steer"),
|
||||
onSend: (alternate) => void handleSend(alternate && (props.isSessionBusy || submissionsInFlight > 0) ? "queue" : "steer"),
|
||||
selectPreviousHistory: (force) =>
|
||||
selectPreviousHistory({ force, isPickerOpen: showPicker(), getTextarea: () => textareaRef ?? null }),
|
||||
selectNextHistory: (force) =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { SessionInboxUser } from "@opencode-ai/client"
|
||||
import { For, Show } from "solid-js"
|
||||
import { ArrowDown, ArrowUp, Pencil, Play, Trash2, X } from "lucide-solid"
|
||||
import { Pencil, Play, Trash2, X } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
interface PromptQueueProps {
|
||||
|
|
@ -11,7 +11,6 @@ interface PromptQueueProps {
|
|||
onEdit: (item: SessionInboxUser) => void
|
||||
onCancelEdit: () => void
|
||||
onRemove: (item: SessionInboxUser) => void
|
||||
onMove: (item: SessionInboxUser, direction: -1 | 1) => void
|
||||
}
|
||||
|
||||
export default function PromptQueue(props: PromptQueueProps) {
|
||||
|
|
@ -26,8 +25,8 @@ export default function PromptQueue(props: PromptQueueProps) {
|
|||
<section class="prompt-queue" aria-label={t("promptQueue.title", { count: props.items.length })}>
|
||||
<header class="prompt-queue-header">{t("promptQueue.title", { count: props.items.length })}</header>
|
||||
<div class="prompt-queue-list">
|
||||
<For each={props.items}>{(item, index) => {
|
||||
const busy = () => props.busyId === item.id
|
||||
<For each={props.items}>{(item) => {
|
||||
const busy = () => Boolean(props.busyId)
|
||||
const editing = () => props.editingId === item.id
|
||||
const attachmentCount = () => item.payload.files?.length ?? 0
|
||||
return (
|
||||
|
|
@ -51,12 +50,6 @@ export default function PromptQueue(props: PromptQueueProps) {
|
|||
>
|
||||
<Play aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" disabled={busy() || index() === 0} title={t("promptQueue.actions.moveUp")} aria-label={t("promptQueue.actions.moveUp")} onClick={() => props.onMove(item, -1)}>
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" disabled={busy() || index() === props.items.length - 1} title={t("promptQueue.actions.moveDown")} aria-label={t("promptQueue.actions.moveDown")} onClick={() => props.onMove(item, 1)}>
|
||||
<ArrowDown aria-hidden="true" />
|
||||
</button>
|
||||
<Show
|
||||
when={!editing()}
|
||||
fallback={
|
||||
|
|
|
|||
|
|
@ -422,19 +422,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
? undefined
|
||||
: forceSubmittedExchangeToBottom(submittedExchangeTargetCount, { createdMessageCount: messageCount })
|
||||
try {
|
||||
const queueOrder = editing ? queuedPrompts().map((item) => item.id) : []
|
||||
const admittedId = await sendMessage(props.instanceId, props.sessionId, prompt, attachments, editing
|
||||
await sendMessage(props.instanceId, props.sessionId, prompt, attachments, editing
|
||||
? { delivery: editing.delivery, replace: editing }
|
||||
: { delivery })
|
||||
if (editing) {
|
||||
cancelQueuedPromptEdit()
|
||||
try {
|
||||
await rewriteQueuedPrompts(queueOrder.map((id) => id === editing.id ? admittedId : id))
|
||||
} catch (error) {
|
||||
log.error("Failed to restore edited prompt position", error)
|
||||
showQueueError(error)
|
||||
}
|
||||
}
|
||||
if (editing) cancelQueuedPromptEdit()
|
||||
if (!initialPinIntent) return
|
||||
const latestMessageCount = visibleMessageCount()
|
||||
if (latestMessageCount < submittedExchangeTargetCount && !sessionStreamingActive()) {
|
||||
|
|
@ -483,58 +474,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
})
|
||||
}
|
||||
|
||||
async function rewriteQueuedPrompts(inboxIds: string[]) {
|
||||
const client = instances().get(props.instanceId)?.client
|
||||
if (!client) throw new Error("Instance not ready")
|
||||
const pending = await client.session.inbox.list({ sessionID: props.sessionId })
|
||||
if (pending.some((item) => item.delivery === "queue" && item.type !== "user")) {
|
||||
throw new Error("Queued control items prevent reordering")
|
||||
}
|
||||
const current = pending.filter((item): item is SessionInboxUser => item.type === "user" && item.delivery === "queue")
|
||||
const ordered = inboxIds.flatMap((id) => current.filter((item) => item.id === id))
|
||||
if (ordered.length !== current.length) throw new Error("Prompt queue changed before reordering")
|
||||
const changed = ordered.findIndex((item, index) => item.id !== current[index]?.id)
|
||||
if (changed < 0) return
|
||||
|
||||
for (const item of ordered.slice(changed)) {
|
||||
await client.session.prompt({
|
||||
sessionID: props.sessionId,
|
||||
text: item.payload.text,
|
||||
files: item.payload.files?.map((file) => ({
|
||||
uri: `data:${file.mime};base64,${file.data}`,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention,
|
||||
})),
|
||||
agents: item.payload.agents,
|
||||
skills: item.payload.skills,
|
||||
metadata: item.payload.metadata,
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
}
|
||||
for (const item of current.slice(changed)) {
|
||||
await client.session.inbox.cancel({ sessionID: props.sessionId, inboxID: item.id })
|
||||
}
|
||||
}
|
||||
|
||||
async function moveQueuedPrompt(item: SessionInboxUser, direction: -1 | 1) {
|
||||
const ids = queuedPrompts().map((entry) => entry.id)
|
||||
const index = ids.indexOf(item.id)
|
||||
const target = index + direction
|
||||
if (index < 0 || target < 0 || target >= ids.length) return
|
||||
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
||||
setQueueBusyId(item.id)
|
||||
try {
|
||||
await rewriteQueuedPrompts(ids)
|
||||
} catch (error) {
|
||||
showQueueError(error)
|
||||
} finally {
|
||||
await syncOpenCodeSessionInbox(props.instanceId, props.sessionId, props.instanceFolder).catch(() => undefined)
|
||||
setQueueBusyId(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async function manageQueuedPrompt(item: SessionInboxUser, action: "delivery" | "remove") {
|
||||
setQueueBusyId(item.id)
|
||||
try {
|
||||
|
|
@ -715,7 +654,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
onEdit={handleEditQueuedPrompt}
|
||||
onCancelEdit={cancelQueuedPromptEdit}
|
||||
onRemove={(item) => void manageQueuedPrompt(item, "remove")}
|
||||
onMove={(item, direction) => void moveQueuedPrompt(item, direction)}
|
||||
/>
|
||||
|
||||
<Show when={attachments().length > 0}>
|
||||
|
|
|
|||
|
|
@ -206,8 +206,6 @@ export const messagingMessages = {
|
|||
"promptQueue.actions.steer": "Jetzt einschieben",
|
||||
"promptQueue.actions.queue": "In Warteschlange verschieben",
|
||||
"promptQueue.actions.edit": "Prompt bearbeiten",
|
||||
"promptQueue.actions.moveUp": "Prompt nach oben verschieben",
|
||||
"promptQueue.actions.moveDown": "Prompt nach unten verschieben",
|
||||
"promptQueue.actions.cancelEdit": "Bearbeitung abbrechen",
|
||||
"promptQueue.actions.remove": "Prompt entfernen",
|
||||
"promptQueue.error.title": "Warteschlangenaktion fehlgeschlagen",
|
||||
|
|
|
|||
|
|
@ -206,8 +206,6 @@ export const messagingMessages = {
|
|||
"promptQueue.actions.steer": "Steer now",
|
||||
"promptQueue.actions.queue": "Move to queue",
|
||||
"promptQueue.actions.edit": "Edit queued prompt",
|
||||
"promptQueue.actions.moveUp": "Move queued prompt up",
|
||||
"promptQueue.actions.moveDown": "Move queued prompt down",
|
||||
"promptQueue.actions.cancelEdit": "Cancel editing",
|
||||
"promptQueue.actions.remove": "Remove queued prompt",
|
||||
"promptQueue.error.title": "Queue action failed",
|
||||
|
|
|
|||
|
|
@ -209,8 +209,6 @@ export const messagingMessages = {
|
|||
"promptQueue.actions.steer": "Intervenir ahora",
|
||||
"promptQueue.actions.queue": "Mover a la cola",
|
||||
"promptQueue.actions.edit": "Editar prompt en cola",
|
||||
"promptQueue.actions.moveUp": "Subir prompt en cola",
|
||||
"promptQueue.actions.moveDown": "Bajar prompt en cola",
|
||||
"promptQueue.actions.cancelEdit": "Cancelar edición",
|
||||
"promptQueue.actions.remove": "Eliminar prompt en cola",
|
||||
"promptQueue.error.title": "Falló la acción de cola",
|
||||
|
|
|
|||
|
|
@ -209,8 +209,6 @@ export const messagingMessages = {
|
|||
"promptQueue.actions.steer": "Injecter maintenant",
|
||||
"promptQueue.actions.queue": "Remettre en file",
|
||||
"promptQueue.actions.edit": "Modifier le prompt en attente",
|
||||
"promptQueue.actions.moveUp": "Monter le prompt en attente",
|
||||
"promptQueue.actions.moveDown": "Descendre le prompt en attente",
|
||||
"promptQueue.actions.cancelEdit": "Annuler la modification",
|
||||
"promptQueue.actions.remove": "Retirer le prompt en attente",
|
||||
"promptQueue.error.title": "Échec de l'action sur la file",
|
||||
|
|
|
|||
|
|
@ -206,8 +206,6 @@ export const messagingMessages = {
|
|||
"promptQueue.actions.steer": "הזרק עכשיו",
|
||||
"promptQueue.actions.queue": "העבר לתור",
|
||||
"promptQueue.actions.edit": "ערוך הנחיה בתור",
|
||||
"promptQueue.actions.moveUp": "העבר הנחיה למעלה",
|
||||
"promptQueue.actions.moveDown": "העבר הנחיה למטה",
|
||||
"promptQueue.actions.cancelEdit": "בטל עריכה",
|
||||
"promptQueue.actions.remove": "הסר הנחיה מהתור",
|
||||
"promptQueue.error.title": "פעולת התור נכשלה",
|
||||
|
|
|
|||
|
|
@ -209,8 +209,6 @@ export const messagingMessages = {
|
|||
"promptQueue.actions.steer": "今すぐ割り込む",
|
||||
"promptQueue.actions.queue": "キューへ移動",
|
||||
"promptQueue.actions.edit": "待機中のプロンプトを編集",
|
||||
"promptQueue.actions.moveUp": "待機中のプロンプトを上へ移動",
|
||||
"promptQueue.actions.moveDown": "待機中のプロンプトを下へ移動",
|
||||
"promptQueue.actions.cancelEdit": "編集をキャンセル",
|
||||
"promptQueue.actions.remove": "待機中のプロンプトを削除",
|
||||
"promptQueue.error.title": "キュー操作に失敗しました",
|
||||
|
|
|
|||
|
|
@ -206,8 +206,6 @@ export const messagingMessages = {
|
|||
"promptQueue.actions.steer": "अहिले पठाउनुहोस्",
|
||||
"promptQueue.actions.queue": "लाममा सार्नुहोस्",
|
||||
"promptQueue.actions.edit": "लामको प्रम्प्ट सम्पादन गर्नुहोस्",
|
||||
"promptQueue.actions.moveUp": "लामको प्रम्प्ट माथि सार्नुहोस्",
|
||||
"promptQueue.actions.moveDown": "लामको प्रम्प्ट तल सार्नुहोस्",
|
||||
"promptQueue.actions.cancelEdit": "सम्पादन रद्द गर्नुहोस्",
|
||||
"promptQueue.actions.remove": "लामको प्रम्प्ट हटाउनुहोस्",
|
||||
"promptQueue.error.title": "लाम कार्य असफल भयो",
|
||||
|
|
|
|||
|
|
@ -209,8 +209,6 @@ export const messagingMessages = {
|
|||
"promptQueue.actions.steer": "Направить сейчас",
|
||||
"promptQueue.actions.queue": "Переместить в очередь",
|
||||
"promptQueue.actions.edit": "Изменить запрос в очереди",
|
||||
"promptQueue.actions.moveUp": "Переместить запрос выше",
|
||||
"promptQueue.actions.moveDown": "Переместить запрос ниже",
|
||||
"promptQueue.actions.cancelEdit": "Отменить редактирование",
|
||||
"promptQueue.actions.remove": "Удалить запрос из очереди",
|
||||
"promptQueue.error.title": "Ошибка действия с очередью",
|
||||
|
|
|
|||
|
|
@ -208,8 +208,6 @@ export const messagingMessages = {
|
|||
"promptQueue.actions.steer": "立即插入",
|
||||
"promptQueue.actions.queue": "移至队列",
|
||||
"promptQueue.actions.edit": "编辑排队提示词",
|
||||
"promptQueue.actions.moveUp": "上移排队提示词",
|
||||
"promptQueue.actions.moveDown": "下移排队提示词",
|
||||
"promptQueue.actions.cancelEdit": "取消编辑",
|
||||
"promptQueue.actions.remove": "移除排队提示词",
|
||||
"promptQueue.error.title": "队列操作失败",
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ import {
|
|||
import { setHasInstances } from "./ui"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { applyOpenCodeDataEvent, destroyOpenCodeData, projectOpenCodeMessages, syncOpenCodeSessionInbox } from "./opencode-data"
|
||||
import { shellStore } from "./shells"
|
||||
import { isLatestWindow } from "./message-v2/message-window"
|
||||
import { upsertPermissionV2, removePermissionV2, removeMessageV2 } from "./message-v2/bridge"
|
||||
import {
|
||||
|
|
@ -342,7 +341,6 @@ const connectionResyncs = new TrailingResyncCoordinator(
|
|||
syncPendingRequests(instanceId),
|
||||
refreshVolatileInstanceState(instanceId),
|
||||
syncLoadedSessionInboxes(instanceId),
|
||||
shellStore.refreshForEvent(instanceId, { type: "server.connected" }),
|
||||
])
|
||||
if (sessionError) throw sessionError
|
||||
const loadedMessages = messagesLoaded().get(instanceId) ?? new Set<string>()
|
||||
|
|
@ -365,9 +363,13 @@ async function syncLoadedSessionInboxes(instanceId: string): Promise<void> {
|
|||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client || instance.status !== "ready") return
|
||||
const loaded = messagesLoaded().get(instanceId) ?? new Set<string>()
|
||||
await Promise.all(Array.from(loaded, (sessionId) => {
|
||||
await Promise.all(Array.from(loaded, async (sessionId) => {
|
||||
const directory = sessions().get(instanceId)?.get(sessionId)?.location.directory ?? instance.folder
|
||||
return syncOpenCodeSessionInbox(instanceId, sessionId, directory)
|
||||
try {
|
||||
await syncOpenCodeSessionInbox(instanceId, sessionId, directory)
|
||||
} catch (error) {
|
||||
log.warn("Failed to resync session inbox after connection", { instanceId, sessionId, error })
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { createEffect, createRoot } from "solid-js"
|
||||
import { messageStoreBus } from "./message-v2/bus.ts"
|
||||
import { seedSessionMessagesV2 } from "./message-v2/bridge.ts"
|
||||
import { normalizeSessionMessage } from "./message-v2/normalizers.ts"
|
||||
import { applyOpenCodeDataEvent, destroyOpenCodeData, getOpenCodeMessageRevision, getOpenCodeMutationRevision, projectOpenCodeMessages } from "./opencode-data.ts"
|
||||
import { applyOpenCodeDataEvent, destroyOpenCodeData, getOpenCodeMessageRevision, getOpenCodeMutationRevision, getOpenCodeSessionInbox, projectOpenCodeMessages } from "./opencode-data.ts"
|
||||
import { emptyLatestWindow } from "./message-v2/message-window.ts"
|
||||
import { getRootClient } from "./opencode-client.ts"
|
||||
import { sdkManager } from "../lib/sdk-manager.ts"
|
||||
|
|
@ -995,6 +996,38 @@ describe("OpenCode data projection", () => {
|
|||
}
|
||||
})
|
||||
|
||||
it("rebinds inbox observers after server.connected", () => {
|
||||
const instanceId = "opencode-data-inbox-reconnect"
|
||||
const sessionId = "session"
|
||||
const client = getRootClient(instanceId)
|
||||
;(client.location as any).get = async () => ({ directory: "/work" })
|
||||
;(client.vcs as any).get = async () => ({ location: { directory: "/work" }, data: {} })
|
||||
;(client.project as any).list = async () => []
|
||||
let ids: string[] = []
|
||||
const dispose = createRoot((dispose) => {
|
||||
createEffect(() => {
|
||||
ids = getOpenCodeSessionInbox(instanceId, sessionId, "/work").map((item) => item.id)
|
||||
})
|
||||
return dispose
|
||||
})
|
||||
try {
|
||||
applyOpenCodeDataEvent(instanceId, "/work", {
|
||||
id: "queued", type: "session.inbox.enqueued", created: 1,
|
||||
data: { sessionID: sessionId, inboxID: "queued", item: { type: "user", payload: { text: "old" }, delivery: "queue" } },
|
||||
} as any)
|
||||
assert.deepEqual(ids, ["queued"])
|
||||
|
||||
applyOpenCodeDataEvent(instanceId, "/work", {
|
||||
id: "connected", type: "server.connected", created: 2, data: {},
|
||||
} as any)
|
||||
assert.deepEqual(ids, [])
|
||||
} finally {
|
||||
dispose()
|
||||
destroyOpenCodeData(instanceId)
|
||||
sdkManager.destroyClientsForInstance(instanceId)
|
||||
}
|
||||
})
|
||||
|
||||
it("replaces the optimistic prompt part with its native projection", () => {
|
||||
const instanceId = "opencode-data-optimistic-prompt"
|
||||
const sessionId = "session"
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const mutationRevisions = new Map<string, ReturnType<typeof createSignal<number>
|
|||
const messageRevisions = new Map<string, number>()
|
||||
const fullDataRevisions = new Map<string, number>()
|
||||
const instanceGenerations = new Map<string, number>()
|
||||
const instanceDataRevisions = new Map<string, ReturnType<typeof createSignal<number>>>()
|
||||
let nextInstanceGeneration = 0
|
||||
|
||||
function messageRevisionKey(instanceId: string, sessionId: string): string {
|
||||
|
|
@ -66,6 +67,15 @@ function mutationRevision(key: string): ReturnType<typeof createSignal<number>>
|
|||
return revision
|
||||
}
|
||||
|
||||
function instanceDataRevision(instanceId: string): ReturnType<typeof createSignal<number>> {
|
||||
let revision = instanceDataRevisions.get(instanceId)
|
||||
if (!revision) {
|
||||
revision = createSignal(0)
|
||||
instanceDataRevisions.set(instanceId, revision)
|
||||
}
|
||||
return revision
|
||||
}
|
||||
|
||||
function bumpMutationRevision(key: string): void {
|
||||
mutationRevision(key)[1]((current) => current + 1)
|
||||
}
|
||||
|
|
@ -569,6 +579,7 @@ export function getOpenCodeMutationRevision(instanceId: string, sessionId: strin
|
|||
}
|
||||
|
||||
export function getOpenCodeSessionInbox(instanceId: string, sessionId: string, directory: string) {
|
||||
instanceDataRevision(instanceId)[0]()
|
||||
return ensureTranscript(instanceId, sessionId, directory).entry.data.session.pending.list(sessionId)
|
||||
}
|
||||
|
||||
|
|
@ -622,6 +633,7 @@ export function destroyOpenCodeData(instanceId: string): void {
|
|||
for (const key of fullDataRevisions.keys()) {
|
||||
if (key.startsWith(prefix)) fullDataRevisions.delete(key)
|
||||
}
|
||||
instanceDataRevision(instanceId)[1]((current) => current + 1)
|
||||
for (const key of mutationRevisions.keys()) {
|
||||
if (key.startsWith(prefix)) mutationRevisions.delete(key)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue