mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f543a66be7 | |||
| d4bd41c4ba |
@@ -57,7 +57,12 @@ import {
|
|||||||
type LocalAttachment,
|
type LocalAttachment,
|
||||||
} from "./local-attachment"
|
} from "./local-attachment"
|
||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
|
import { usePromptRef } from "../../context/prompt"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
|
import type { LocationRef, PromptFileAttachment, PromptSkillAttachment, SkillInfo } from "@opencode-ai/client"
|
||||||
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
|
import { SessionID } from "@opencode-ai/schema/session-id"
|
||||||
|
import { useSessionTabs } from "../../context/session-tabs"
|
||||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { abbreviateHome } from "../../runtime"
|
import { abbreviateHome } from "../../runtime"
|
||||||
import { Slot } from "../../plugin/render"
|
import { Slot } from "../../plugin/render"
|
||||||
@@ -100,6 +105,59 @@ export type PromptRef = {
|
|||||||
|
|
||||||
const DRAFT_RETENTION_MIN_CHARS = 20
|
const DRAFT_RETENTION_MIN_CHARS = 20
|
||||||
|
|
||||||
|
// Serialize background prompt submissions per session so admission order matches
|
||||||
|
// the on-screen optimistic order, even across Prompt remounts and route changes.
|
||||||
|
const submitTails = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
|
function enqueueSubmit(sessionID: string, task: () => Promise<void>) {
|
||||||
|
const tail = (submitTails.get(sessionID) ?? Promise.resolve()).then(task, task)
|
||||||
|
submitTails.set(sessionID, tail)
|
||||||
|
void tail.finally(() => {
|
||||||
|
if (submitTails.get(sessionID) === tail) submitTails.delete(sessionID)
|
||||||
|
})
|
||||||
|
return tail
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximate the server's materialized attachment shape for the local echo. Pasted
|
||||||
|
// data: URIs carry real content so images preview immediately; file references render
|
||||||
|
// as labels until the admission echo replaces them with server truth.
|
||||||
|
function optimisticFiles(files: PromptInfo["files"]): PromptFileAttachment[] | undefined {
|
||||||
|
if (!files?.length) return undefined
|
||||||
|
return files.map((file) => {
|
||||||
|
const match = /^data:([^;,]*);base64,(.*)$/.exec(file.uri)
|
||||||
|
if (match)
|
||||||
|
return {
|
||||||
|
data: match[2] ?? "",
|
||||||
|
mime: match[1] || "application/octet-stream",
|
||||||
|
source: { type: "inline" as const },
|
||||||
|
name: file.name,
|
||||||
|
description: file.description,
|
||||||
|
mention: file.mention,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: "",
|
||||||
|
mime: file.uri.endsWith("/") ? "application/x-directory" : "text/plain",
|
||||||
|
source: { type: "uri" as const, uri: file.uri },
|
||||||
|
name: file.name,
|
||||||
|
description: file.description,
|
||||||
|
mention: file.mention,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimisticSkills(
|
||||||
|
skills: PromptInfo["skills"],
|
||||||
|
available: SkillInfo[],
|
||||||
|
): PromptSkillAttachment[] | undefined {
|
||||||
|
if (!skills?.length) return undefined
|
||||||
|
return skills.map((attachment) => ({
|
||||||
|
id: attachment.id,
|
||||||
|
name: available.find((skill) => skill.id === attachment.id)?.name ?? attachment.id,
|
||||||
|
text: "",
|
||||||
|
mention: attachment.mention,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
function randomIndex(count: number) {
|
function randomIndex(count: number) {
|
||||||
if (count <= 0) return 0
|
if (count <= 0) return 0
|
||||||
return Math.floor(Math.random() * count)
|
return Math.floor(Math.random() * count)
|
||||||
@@ -201,6 +259,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
const editor = useEditorContext()
|
const editor = useEditorContext()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
|
const activePrompt = usePromptRef()
|
||||||
|
const sessionTabs = useSessionTabs()
|
||||||
const directoryRecents = useDirectoryRecents()
|
const directoryRecents = useDirectoryRecents()
|
||||||
const keymapCommands = Keymap.useCommands()
|
const keymapCommands = Keymap.useCommands()
|
||||||
const currentLocation = useLocation()
|
const currentLocation = useLocation()
|
||||||
@@ -1070,6 +1130,20 @@ export function Prompt(props: PromptProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Return a failed background submission to its author. When the target tab's
|
||||||
|
// editor is live and empty, restore directly; otherwise stash a draft for the
|
||||||
|
// next mount. A non-empty editor is never clobbered—prompt history retains the
|
||||||
|
// failed prompt either way.
|
||||||
|
function restorePrompt(sessionID: string, snapshot: PromptInfo) {
|
||||||
|
const active = route.data
|
||||||
|
if (active.type === "session" && active.sessionID === sessionID) {
|
||||||
|
const live = activePrompt.current
|
||||||
|
if (live && !live.current.text.trim()) live.set(snapshot)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveDraft(sessionID, { prompt: snapshot, cursor: snapshot.text.length })
|
||||||
|
}
|
||||||
|
|
||||||
let submitting = false
|
let submitting = false
|
||||||
async function submit(delivery: SessionInbox.Delivery = "steer") {
|
async function submit(delivery: SessionInbox.Delivery = "steer") {
|
||||||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||||
@@ -1170,8 +1244,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
const variant = selection.variant
|
const variant = selection.variant
|
||||||
let sessionID = props.sessionID
|
let sessionID = props.sessionID
|
||||||
let session = sessionID ? data.session.get(sessionID) : undefined
|
|
||||||
let finishMoveProgress = false
|
let finishMoveProgress = false
|
||||||
|
let createSession: (() => Promise<unknown>) | undefined
|
||||||
|
let createLocation: LocationRef | undefined
|
||||||
if (sessionID == null) {
|
if (sessionID == null) {
|
||||||
const directory = await move.getDirectory()
|
const directory = await move.getDirectory()
|
||||||
if (move.pending() && !directory) return false
|
if (move.pending() && !directory) return false
|
||||||
@@ -1179,32 +1254,46 @@ export function Prompt(props: PromptProps) {
|
|||||||
// The location context is where the next session is created: seeded by the home
|
// The location context is where the next session is created: seeded by the home
|
||||||
// route (launch cwd, inherited session location, or picked project) and updated
|
// route (launch cwd, inherited session location, or picked project) and updated
|
||||||
// by /cd before a session exists.
|
// by /cd before a session exists.
|
||||||
const location = currentLocation.ref ?? data.location.default()
|
const location = directory ? { directory } : (currentLocation.ref ?? data.location.default())
|
||||||
|
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||||
const created = await client.api.session
|
// Seed the session locally with a client-generated ID and create it in the
|
||||||
.create({
|
// background so navigation and the prompt echo render immediately. Optimistic
|
||||||
location: directory ? { directory } : location,
|
// creation covers the plain prompt path when the location's project is known.
|
||||||
|
// Worktree-backed creation keeps its progress flow, and command, skill, and
|
||||||
|
// shell submissions target the session right away, so those await creation.
|
||||||
|
const plainPrompt = store.mode !== "shell" && !(slashHead && isCommand) && !isSkill
|
||||||
|
const projectID = directory ? undefined : data.location.info(location)?.project.id
|
||||||
|
if (plainPrompt && projectID) {
|
||||||
|
const created = SessionID.create()
|
||||||
|
data.session.optimistic.create({
|
||||||
|
sessionID: created,
|
||||||
|
projectID,
|
||||||
|
location,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: {
|
model,
|
||||||
providerID: selection.providerID,
|
|
||||||
id: selection.modelID,
|
|
||||||
variant,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
sessionID = created
|
||||||
|
createLocation = location
|
||||||
if (!created) {
|
createSession = () => client.api.session.create({ id: created, location, agent: agent.id, model })
|
||||||
if (finishMoveProgress) move.finishSubmit()
|
|
||||||
toast.show({
|
|
||||||
message: "Creating a session failed. Open console for more details.",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID = created.id
|
if (sessionID == null) {
|
||||||
session = created
|
const created = await client.api.session
|
||||||
|
.create({ location, agent: agent.id, model })
|
||||||
|
.catch(() => undefined)
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
if (finishMoveProgress) move.finishSubmit()
|
||||||
|
toast.show({
|
||||||
|
message: "Creating a session failed. Open console for more details.",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID = created.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture mode before it gets reset
|
// Capture mode before it gets reset
|
||||||
@@ -1245,70 +1334,87 @@ export function Prompt(props: PromptProps) {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
if (!session) {
|
// Echo the prompt locally and admit it in the background: the editor clears and
|
||||||
await data.session.sync(sessionID)
|
// the message renders immediately, while a per-session queue preserves admission
|
||||||
session = data.session.get(sessionID)
|
// order. Failure rolls the echo back and restores the captured prompt.
|
||||||
}
|
const submitSessionID = sessionID
|
||||||
if (session?.agent !== agent.id) {
|
const messageID = SessionMessage.ID.create()
|
||||||
await client.api.session.switchAgent({ sessionID, agent: agent.id })
|
const snapshot = structuredClone(unwrap(store.prompt))
|
||||||
}
|
const editorContextText = pendingEditorSelection ? formatEditorContext(pendingEditorSelection) : undefined
|
||||||
if (
|
const targetAgent = agent.id
|
||||||
session?.model?.providerID !== selection.providerID ||
|
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||||
session.model.id !== selection.modelID ||
|
data.session.optimistic.prompt({
|
||||||
(session.model.variant ?? "default") !== (variant ?? "default")
|
sessionID: submitSessionID,
|
||||||
) {
|
messageID,
|
||||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
delivery,
|
||||||
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
text: inputText,
|
||||||
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
|
files: optimisticFiles(snapshot.files),
|
||||||
cancelCommit()
|
agents: snapshot.agents?.length ? snapshot.agents : undefined,
|
||||||
throw error
|
skills: optimisticSkills(snapshot.skills, data.location.skill.list(currentLocation.ref) ?? []),
|
||||||
})
|
})
|
||||||
}
|
// Mark the editor context sent with the echo so a rapid follow-up submit
|
||||||
if (session?.revert) {
|
// does not re-attach the same selection while admission is in flight.
|
||||||
const error = await client.api.session.revert.commit({ sessionID }).then(
|
if (editorContextText) editor.markSelectionSent()
|
||||||
() => undefined,
|
let createFailed = false
|
||||||
(error) => error,
|
void enqueueSubmit(submitSessionID, async () => {
|
||||||
)
|
const error = await (async () => {
|
||||||
if (error) {
|
if (createSession)
|
||||||
toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
|
await createSession().catch((error) => {
|
||||||
return false
|
createFailed = true
|
||||||
}
|
throw error
|
||||||
}
|
})
|
||||||
if (pendingEditorSelection) {
|
let session = data.session.get(submitSessionID)
|
||||||
// Keep editor context hidden while admitting it before the corresponding user prompt.
|
if (!session) {
|
||||||
const error = await client.api.session
|
await data.session.sync(submitSessionID)
|
||||||
.synthetic({
|
session = data.session.get(submitSessionID)
|
||||||
sessionID,
|
}
|
||||||
text: formatEditorContext(pendingEditorSelection),
|
if (session?.agent !== targetAgent) {
|
||||||
resume: false,
|
await client.api.session.switchAgent({ sessionID: submitSessionID, agent: targetAgent })
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
session?.model?.providerID !== model.providerID ||
|
||||||
|
session.model.id !== model.id ||
|
||||||
|
(session.model.variant ?? "default") !== (model.variant ?? "default")
|
||||||
|
) {
|
||||||
|
const cancelCommit = local.model.trackSessionCommit(submitSessionID, model)
|
||||||
|
await client.api.session.switchModel({ sessionID: submitSessionID, model }).catch((error) => {
|
||||||
|
cancelCommit()
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (session?.revert) await client.api.session.revert.commit({ sessionID: submitSessionID })
|
||||||
|
// Keep editor context hidden while admitting it before the corresponding user prompt.
|
||||||
|
if (editorContextText)
|
||||||
|
await client.api.session.synthetic({ sessionID: submitSessionID, text: editorContextText, resume: false })
|
||||||
|
await client.api.session.prompt({
|
||||||
|
sessionID: submitSessionID,
|
||||||
|
id: messageID,
|
||||||
|
text: inputText,
|
||||||
|
files: snapshot.files,
|
||||||
|
agents: snapshot.agents,
|
||||||
|
skills: snapshot.skills?.length ? snapshot.skills : undefined,
|
||||||
|
delivery,
|
||||||
})
|
})
|
||||||
.then(
|
})().then(
|
||||||
() => undefined,
|
|
||||||
(error) => error,
|
|
||||||
)
|
|
||||||
if (error) {
|
|
||||||
toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const error = await client.api.session
|
|
||||||
.prompt({
|
|
||||||
sessionID,
|
|
||||||
text: inputText,
|
|
||||||
files: store.prompt.files,
|
|
||||||
agents: store.prompt.agents,
|
|
||||||
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
|
|
||||||
delivery,
|
|
||||||
})
|
|
||||||
.then(
|
|
||||||
() => undefined,
|
() => undefined,
|
||||||
(error) => error,
|
(error) => error,
|
||||||
)
|
)
|
||||||
if (error) {
|
if (error === undefined) return
|
||||||
|
data.session.optimistic.rollback(submitSessionID, messageID)
|
||||||
|
if (createFailed) {
|
||||||
|
// The session never existed server-side: leave the optimistic tab, return
|
||||||
|
// to the home screen with the prompt restored, and drop the seed.
|
||||||
|
if (route.data.type === "session" && route.data.sessionID === submitSessionID)
|
||||||
|
route.navigate({ type: "home", prompt: snapshot, location: createLocation })
|
||||||
|
else saveDraft(undefined, { prompt: snapshot, cursor: snapshot.text.length })
|
||||||
|
sessionTabs.close(submitSessionID)
|
||||||
|
data.session.optimistic.rollbackCreate(submitSessionID)
|
||||||
|
toast.show({ title: "Failed to create session", message: errorMessage(error), variant: "error" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
restorePrompt(submitSessionID, snapshot)
|
||||||
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
||||||
return false
|
})
|
||||||
}
|
|
||||||
if (pendingEditorSelection) editor.markSelectionSent()
|
|
||||||
}
|
}
|
||||||
history.append({
|
history.append({
|
||||||
...store.prompt,
|
...store.prompt,
|
||||||
@@ -1319,15 +1425,14 @@ export function Prompt(props: PromptProps) {
|
|||||||
setStore("extmarkToPart", new Map())
|
setStore("extmarkToPart", new Map())
|
||||||
props.onSubmit?.()
|
props.onSubmit?.()
|
||||||
|
|
||||||
// temporary hack to make sure the message is sent
|
|
||||||
if (!props.sessionID) {
|
if (!props.sessionID) {
|
||||||
if (pendingEditorSelection) editor.preserveSelectionFromNewSession()
|
if (pendingEditorSelection) editor.preserveSelectionFromNewSession()
|
||||||
setTimeout(() => {
|
// The optimistic echo is already in the data store, so the session route
|
||||||
route.navigate({
|
// renders the prompt immediately; admission continues in the background.
|
||||||
type: "session",
|
route.navigate({
|
||||||
sessionID,
|
type: "session",
|
||||||
})
|
sessionID,
|
||||||
}, 50)
|
})
|
||||||
}
|
}
|
||||||
input.clear()
|
input.clear()
|
||||||
if (finishMoveProgress) move.finishSubmit()
|
if (finishMoveProgress) move.finishSubmit()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
ProviderInfo,
|
ProviderInfo,
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
SessionMessageInfo,
|
SessionMessageInfo,
|
||||||
|
SessionMessageUser,
|
||||||
SessionMessageAssistant,
|
SessionMessageAssistant,
|
||||||
SessionMessageAssistantReasoning,
|
SessionMessageAssistantReasoning,
|
||||||
SessionMessageAssistantText,
|
SessionMessageAssistantText,
|
||||||
@@ -40,7 +41,7 @@ import { useClient } from "./client"
|
|||||||
import { nonEmptyToolContent } from "../util/tool-display"
|
import { nonEmptyToolContent } from "../util/tool-display"
|
||||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
import { batch, createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
|
|
||||||
export type DataSessionStatus = "idle" | "running"
|
export type DataSessionStatus = "idle" | "running"
|
||||||
|
|
||||||
@@ -161,6 +162,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
const messageIndex = new Map<string, Map<string, number>>()
|
const messageIndex = new Map<string, Map<string, number>>()
|
||||||
const sync = createSync()
|
const sync = createSync()
|
||||||
|
|
||||||
|
// Optimistic prompt echoes: user prompts applied locally before server admission,
|
||||||
|
// keyed sessionID -> messageID. The session.inbox.enqueued echo carrying the same
|
||||||
|
// ID replaces the local copy with server truth (admission materializes file
|
||||||
|
// attachments and expands skills). Entries survive wholesale sync replaces until
|
||||||
|
// the server confirms them or the submitter rolls back.
|
||||||
|
type OptimisticPrompt = { message: SessionMessageInfo; pending: SessionInboxInfo }
|
||||||
|
const optimisticPrompts = new Map<string, Map<string, OptimisticPrompt>>()
|
||||||
|
|
||||||
|
function confirmOptimistic(sessionID: string, messageID: string) {
|
||||||
|
const entries = optimisticPrompts.get(sessionID)
|
||||||
|
if (!entries?.delete(messageID)) return false
|
||||||
|
if (entries.size === 0) optimisticPrompts.delete(sessionID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimisticMessages(sessionID: string) {
|
||||||
|
return [...(optimisticPrompts.get(sessionID)?.values() ?? [])]
|
||||||
|
}
|
||||||
|
|
||||||
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
||||||
setStore("session", "active", sessionID, status)
|
setStore("session", "active", sessionID, status)
|
||||||
}
|
}
|
||||||
@@ -188,6 +208,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeMessage(sessionID: string, messageID: string) {
|
||||||
|
if (!messageIndex.get(sessionID)?.has(messageID)) return
|
||||||
|
message.update(sessionID, (draft, index) => {
|
||||||
|
const position = index.get(messageID)
|
||||||
|
if (position === undefined) return
|
||||||
|
draft.splice(position, 1)
|
||||||
|
index.delete(messageID)
|
||||||
|
message.reindex(draft, index, position)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function removePermission(sessionID: string, requestID: string) {
|
function removePermission(sessionID: string, requestID: string) {
|
||||||
const requests = store.session.permission[sessionID]
|
const requests = store.session.permission[sessionID]
|
||||||
if (!requests?.some((request) => request.id === requestID)) return
|
if (!requests?.some((request) => request.id === requestID)) return
|
||||||
@@ -313,7 +344,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
|
|
||||||
function removeSession(sessionID: string) {
|
function removeSession(sessionID: string) {
|
||||||
messageIndex.delete(sessionID)
|
messageIndex.delete(sessionID)
|
||||||
|
optimisticPrompts.delete(sessionID)
|
||||||
sync.invalidate(`session:${sessionID}`)
|
sync.invalidate(`session:${sessionID}`)
|
||||||
|
sync.invalidate(`session.family:${sessionID}`)
|
||||||
sync.invalidate(`session.pending:${sessionID}`)
|
sync.invalidate(`session.pending:${sessionID}`)
|
||||||
sync.invalidate(`session.message:${sessionID}`)
|
sync.invalidate(`session.message:${sessionID}`)
|
||||||
sync.invalidate(`session.permission:${sessionID}`)
|
sync.invalidate(`session.permission:${sessionID}`)
|
||||||
@@ -471,6 +504,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.inbox.delivered": {
|
case "session.inbox.delivered": {
|
||||||
|
// Delivery implies the message is projected server-side, so future message
|
||||||
|
// fetches include it and the optimistic entry no longer needs re-appending.
|
||||||
|
confirmOptimistic(event.data.sessionID, event.data.inboxID)
|
||||||
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
|
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
|
||||||
removePending(event.data.sessionID, event.data.inboxID)
|
removePending(event.data.sessionID, event.data.inboxID)
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
@@ -489,25 +525,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
|
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
|
||||||
break
|
break
|
||||||
case "session.inbox.cancelled": {
|
case "session.inbox.cancelled": {
|
||||||
|
confirmOptimistic(event.data.sessionID, event.data.inboxID)
|
||||||
removePending(event.data.sessionID, event.data.inboxID)
|
removePending(event.data.sessionID, event.data.inboxID)
|
||||||
if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID))
|
removeMessage(event.data.sessionID, event.data.inboxID)
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
|
||||||
const position = index.get(event.data.inboxID)
|
|
||||||
if (position === undefined) return
|
|
||||||
draft.splice(position, 1)
|
|
||||||
index.delete(event.data.inboxID)
|
|
||||||
message.reindex(draft, index, position)
|
|
||||||
})
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.inbox.enqueued": {
|
case "session.inbox.enqueued": {
|
||||||
const item = event.data.item
|
const item = event.data.item
|
||||||
addPending({
|
// The admission echo is authoritative for an optimistic local copy: it
|
||||||
|
// materializes file attachments and expands skills, so replace in place.
|
||||||
|
const confirmed = confirmOptimistic(event.data.sessionID, event.data.inboxID)
|
||||||
|
const pendingItem: SessionInboxInfo = {
|
||||||
id: event.data.inboxID,
|
id: event.data.inboxID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
timeCreated: event.created,
|
timeCreated: event.created,
|
||||||
...item,
|
...item,
|
||||||
})
|
}
|
||||||
|
const pendingList = store.session.pending[event.data.sessionID]
|
||||||
|
if (confirmed && pendingList?.some((pending) => pending.id === event.data.inboxID))
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
"pending",
|
||||||
|
event.data.sessionID,
|
||||||
|
pendingList.map((pending) => (pending.id === event.data.inboxID ? pendingItem : pending)),
|
||||||
|
)
|
||||||
|
else addPending(pendingItem)
|
||||||
if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
|
if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
|
||||||
setStore("session", "input", event.data.sessionID, [
|
setStore("session", "input", event.data.sessionID, [
|
||||||
...(store.session.input[event.data.sessionID] ?? []),
|
...(store.session.input[event.data.sessionID] ?? []),
|
||||||
@@ -515,9 +557,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
])
|
])
|
||||||
if (item.type !== "user" && item.type !== "synthetic") break
|
if (item.type !== "user" && item.type !== "synthetic") break
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
message.append(
|
const next: SessionMessageInfo =
|
||||||
draft,
|
|
||||||
index,
|
|
||||||
item.type === "user"
|
item.type === "user"
|
||||||
? {
|
? {
|
||||||
id: event.data.inboxID,
|
id: event.data.inboxID,
|
||||||
@@ -530,8 +570,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
...item.payload,
|
...item.payload,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
},
|
}
|
||||||
)
|
const position = index.get(event.data.inboxID)
|
||||||
|
if (confirmed && position !== undefined) {
|
||||||
|
draft[position] = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.append(draft, index, next)
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -825,22 +870,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
if (store.session.info[event.data.sessionID])
|
if (store.session.info[event.data.sessionID])
|
||||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||||
break
|
break
|
||||||
case "session.revert.committed":
|
case "session.revert.committed": {
|
||||||
if (store.session.info[event.data.sessionID]) {
|
if (store.session.info[event.data.sessionID]) {
|
||||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||||
}
|
}
|
||||||
|
// Unconfirmed optimistic prompts postdate the revert boundary but were never
|
||||||
|
// part of the reverted history: the server admits them after the commit.
|
||||||
|
const local = optimisticPrompts.get(event.data.sessionID)
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
"input",
|
"input",
|
||||||
event.data.sessionID,
|
event.data.sessionID,
|
||||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
|
(store.session.input[event.data.sessionID] ?? []).filter(
|
||||||
|
(id) => id < event.data.to || local?.has(id) === true,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const position = draft.findIndex((item) => item.id >= event.data.to)
|
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||||
if (position === -1) return
|
if (position === -1) return
|
||||||
for (const item of draft.splice(position)) index.delete(item.id)
|
const dropped = draft.splice(position)
|
||||||
|
for (const item of dropped) index.delete(item.id)
|
||||||
|
for (const item of dropped) if (local?.has(item.id)) message.append(draft, index, item)
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case "session.compaction.delta":
|
case "session.compaction.delta":
|
||||||
message.update(event.data.sessionID, (draft) => {
|
message.update(event.data.sessionID, (draft) => {
|
||||||
const current = message.compaction(draft)
|
const current = message.compaction(draft)
|
||||||
@@ -1013,13 +1066,109 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
return store.session.input[sessionID]?.includes(inboxID) ?? false
|
return store.session.input[sessionID]?.includes(inboxID) ?? false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimistic: {
|
||||||
|
// Seed a session locally before server creation so navigation and the first
|
||||||
|
// prompt echo render immediately. Reads are marked complete so the session
|
||||||
|
// route does not fetch a session the server does not know yet; the
|
||||||
|
// session.created echo invalidates the info read and loads server truth.
|
||||||
|
create(input: {
|
||||||
|
sessionID: string
|
||||||
|
projectID: string
|
||||||
|
location: LocationRef
|
||||||
|
agent?: string
|
||||||
|
model?: SessionInfo["model"]
|
||||||
|
}) {
|
||||||
|
const now = Date.now()
|
||||||
|
const info: SessionInfo = {
|
||||||
|
id: input.sessionID,
|
||||||
|
projectID: input.projectID,
|
||||||
|
location: input.location,
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: now, updated: now },
|
||||||
|
}
|
||||||
|
batch(() => {
|
||||||
|
setStore("session", "info", input.sessionID, info)
|
||||||
|
registerSession(input.sessionID)
|
||||||
|
})
|
||||||
|
sync.complete(`session:${input.sessionID}`)
|
||||||
|
sync.complete(`session.family:${input.sessionID}`)
|
||||||
|
sync.complete(`session.pending:${input.sessionID}`)
|
||||||
|
sync.complete(`session.message:${input.sessionID}`)
|
||||||
|
},
|
||||||
|
// Remove a seeded session after creation fails. Callers navigate away first.
|
||||||
|
rollbackCreate(sessionID: string) {
|
||||||
|
removeSession(sessionID)
|
||||||
|
},
|
||||||
|
// Locally echo a user prompt before server admission. The session.inbox.enqueued
|
||||||
|
// echo carrying the same message ID replaces the copy with server truth; rollback
|
||||||
|
// removes the echo when submission fails.
|
||||||
|
prompt(input: {
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
delivery: SessionInbox.Delivery
|
||||||
|
text: string
|
||||||
|
files?: SessionMessageUser["files"]
|
||||||
|
agents?: SessionMessageUser["agents"]
|
||||||
|
skills?: SessionMessageUser["skills"]
|
||||||
|
}) {
|
||||||
|
const created = Date.now()
|
||||||
|
const pendingItem: SessionInboxInfo = {
|
||||||
|
id: input.messageID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
timeCreated: created,
|
||||||
|
type: "user",
|
||||||
|
payload: { text: input.text, files: input.files, agents: input.agents, skills: input.skills },
|
||||||
|
delivery: input.delivery,
|
||||||
|
}
|
||||||
|
const messageItem: SessionMessageInfo = {
|
||||||
|
id: input.messageID,
|
||||||
|
type: "user",
|
||||||
|
text: input.text,
|
||||||
|
files: input.files,
|
||||||
|
agents: input.agents,
|
||||||
|
skills: input.skills,
|
||||||
|
time: { created },
|
||||||
|
}
|
||||||
|
const entries = optimisticPrompts.get(input.sessionID) ?? new Map<string, OptimisticPrompt>()
|
||||||
|
optimisticPrompts.set(input.sessionID, entries)
|
||||||
|
entries.set(input.messageID, { message: messageItem, pending: pendingItem })
|
||||||
|
batch(() => {
|
||||||
|
addPending(pendingItem)
|
||||||
|
if (!store.session.input[input.sessionID]?.includes(input.messageID))
|
||||||
|
setStore("session", "input", input.sessionID, [
|
||||||
|
...(store.session.input[input.sessionID] ?? []),
|
||||||
|
input.messageID,
|
||||||
|
])
|
||||||
|
message.update(input.sessionID, (draft, index) => message.append(draft, index, messageItem))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
rollback(sessionID: string, messageID: string) {
|
||||||
|
if (!confirmOptimistic(sessionID, messageID)) return
|
||||||
|
batch(() => {
|
||||||
|
removePending(sessionID, messageID)
|
||||||
|
removeMessage(sessionID, messageID)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
pending: {
|
pending: {
|
||||||
list(sessionID: string) {
|
list(sessionID: string) {
|
||||||
return store.session.pending[sessionID] ?? []
|
return store.session.pending[sessionID] ?? []
|
||||||
},
|
},
|
||||||
sync(sessionID: string) {
|
sync(sessionID: string) {
|
||||||
return sync.run(`session.pending:${sessionID}`, async () => {
|
return sync.run(`session.pending:${sessionID}`, async () => {
|
||||||
const pending = await client.api.session.inbox.list({ sessionID })
|
const fetched = await client.api.session.inbox.list({ sessionID })
|
||||||
|
// Keep unconfirmed optimistic prompts pending across the wholesale replace.
|
||||||
|
// Server presence here is not treated as confirmation: until the enqueued
|
||||||
|
// echo or a projected message arrives, the ledger still guards message.sync.
|
||||||
|
const pending = [
|
||||||
|
...fetched,
|
||||||
|
...optimisticMessages(sessionID)
|
||||||
|
.map((entry) => entry.pending)
|
||||||
|
.filter((entry) => !fetched.some((item) => item.id === entry.id)),
|
||||||
|
]
|
||||||
setStore("session", "pending", sessionID, reconcile(pending))
|
setStore("session", "pending", sessionID, reconcile(pending))
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
@@ -1069,9 +1218,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
sync(sessionID: string) {
|
sync(sessionID: string) {
|
||||||
return sync.run(`session.message:${sessionID}`, async () => {
|
return sync.run(`session.message:${sessionID}`, async () => {
|
||||||
const messages = (
|
const fetched = (
|
||||||
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
|
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
|
||||||
).data.toReversed()
|
).data.toReversed()
|
||||||
|
// A wholesale replace would drop optimistic prompts the server has not
|
||||||
|
// admitted yet. A fetched ID is server confirmation; the rest re-append.
|
||||||
|
for (const entry of optimisticMessages(sessionID))
|
||||||
|
if (fetched.some((item) => item.id === entry.message.id))
|
||||||
|
confirmOptimistic(sessionID, entry.message.id)
|
||||||
|
const messages = [...fetched, ...optimisticMessages(sessionID).map((entry) => entry.message)]
|
||||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||||
setStore("session", "message", sessionID, reconcile(messages))
|
setStore("session", "message", sessionID, reconcile(messages))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { testRender } from "@opentui/solid"
|
||||||
|
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client"
|
||||||
|
import { createEffect, type ParentProps } from "solid-js"
|
||||||
|
import { ConfigProvider } from "../../../src/config"
|
||||||
|
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||||
|
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||||
|
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||||
|
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||||
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
|
|
||||||
|
async function wait(fn: () => boolean, timeout = 2000) {
|
||||||
|
const start = Date.now()
|
||||||
|
while (!fn()) {
|
||||||
|
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||||
|
await Bun.sleep(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
|
||||||
|
events.emit({ ...event, location: { directory } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = createTuiResolvedConfig()
|
||||||
|
|
||||||
|
function DataProvider(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<ConfigProvider config={config}>
|
||||||
|
<DataProviderBase>
|
||||||
|
<LocationProvider>
|
||||||
|
<SyncLocation />
|
||||||
|
{props.children}
|
||||||
|
</LocationProvider>
|
||||||
|
</DataProviderBase>
|
||||||
|
</ConfigProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncLocation() {
|
||||||
|
const data = useData()
|
||||||
|
const location = useLocation()
|
||||||
|
createEffect(() => location.set(data.location.default()))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function durable(sessionID: string, seq = 0): { aggregateID: string; seq: number; version: 1 } {
|
||||||
|
return { aggregateID: sessionID, seq, version: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
type Harness = {
|
||||||
|
data: ReturnType<typeof useData>
|
||||||
|
client: ReturnType<typeof useClient>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderData(fetch: ReturnType<typeof createFetch>["fetch"]) {
|
||||||
|
const harness = {} as Harness
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
harness.client = useClient()
|
||||||
|
harness.data = useData()
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<ClientProvider api={createApi(fetch)}>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ClientProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
await wait(() => harness.client.connection.status() === "connected")
|
||||||
|
return { app, ...harness }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("echoes an optimistic prompt and replaces it with the admission echo", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const sessionID = "session-optimistic-echo"
|
||||||
|
const calls = createFetch(undefined, events)
|
||||||
|
const { app, data } = await renderData(calls.fetch)
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.session.optimistic.prompt({
|
||||||
|
sessionID,
|
||||||
|
messageID: "msg_optimistic",
|
||||||
|
delivery: "steer",
|
||||||
|
text: "Hello",
|
||||||
|
files: [{ data: "", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/a.ts" }, name: "a.ts" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const echoed = data.session.message.get(sessionID, "msg_optimistic")
|
||||||
|
expect(echoed?.type === "user" && echoed.text).toBe("Hello")
|
||||||
|
expect(echoed?.type === "user" && echoed.files?.[0]?.data).toBe("")
|
||||||
|
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_optimistic"])
|
||||||
|
expect(data.session.input.has(sessionID, "msg_optimistic")).toBe(true)
|
||||||
|
|
||||||
|
// Admission materializes attachments, so the echo must be replaced in place.
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_admitted",
|
||||||
|
created: 9,
|
||||||
|
type: "session.inbox.enqueued",
|
||||||
|
durable: durable(sessionID),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
inboxID: "msg_optimistic",
|
||||||
|
item: {
|
||||||
|
type: "user",
|
||||||
|
payload: {
|
||||||
|
text: "Hello",
|
||||||
|
files: [{ data: "QUJD", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/a.ts" }, name: "a.ts" }],
|
||||||
|
},
|
||||||
|
delivery: "steer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wait(() => {
|
||||||
|
const message = data.session.message.get(sessionID, "msg_optimistic")
|
||||||
|
return message?.type === "user" && message.files?.[0]?.data === "QUJD"
|
||||||
|
})
|
||||||
|
expect(data.session.message.list(sessionID)).toHaveLength(1)
|
||||||
|
expect(data.session.pending.list(sessionID)).toHaveLength(1)
|
||||||
|
expect(data.session.pending.list(sessionID)[0]?.timeCreated).toBe(9)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rolls back a failed optimistic prompt", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const sessionID = "session-optimistic-rollback"
|
||||||
|
const calls = createFetch(undefined, events)
|
||||||
|
const { app, data } = await renderData(calls.fetch)
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.session.optimistic.prompt({
|
||||||
|
sessionID,
|
||||||
|
messageID: "msg_failed",
|
||||||
|
delivery: "queue",
|
||||||
|
text: "Will fail",
|
||||||
|
})
|
||||||
|
expect(data.session.message.get(sessionID, "msg_failed")).toBeDefined()
|
||||||
|
|
||||||
|
data.session.optimistic.rollback(sessionID, "msg_failed")
|
||||||
|
expect(data.session.message.get(sessionID, "msg_failed")).toBeUndefined()
|
||||||
|
expect(data.session.pending.list(sessionID)).toHaveLength(0)
|
||||||
|
expect(data.session.input.has(sessionID, "msg_failed")).toBe(false)
|
||||||
|
|
||||||
|
// Rollback of an unknown or already-settled echo is a no-op.
|
||||||
|
data.session.optimistic.rollback(sessionID, "msg_failed")
|
||||||
|
expect(data.session.message.list(sessionID)).toHaveLength(0)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("optimistic prompts survive sync replaces until the server confirms them", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const sessionID = "session-optimistic-sync"
|
||||||
|
let serverMessages: SessionMessageInfo[] = []
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: serverMessages, cursor: {} })
|
||||||
|
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] })
|
||||||
|
}, events)
|
||||||
|
const { app, data } = await renderData(calls.fetch)
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.session.optimistic.prompt({
|
||||||
|
sessionID,
|
||||||
|
messageID: "msg_pending",
|
||||||
|
delivery: "steer",
|
||||||
|
text: "Survive the sync",
|
||||||
|
})
|
||||||
|
|
||||||
|
// A wholesale replace from an empty server page keeps the unconfirmed echo.
|
||||||
|
await data.session.message.sync(sessionID)
|
||||||
|
expect(data.session.message.get(sessionID, "msg_pending")).toBeDefined()
|
||||||
|
|
||||||
|
await data.session.pending.sync(sessionID)
|
||||||
|
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_pending"])
|
||||||
|
expect(data.session.input.has(sessionID, "msg_pending")).toBe(true)
|
||||||
|
|
||||||
|
// A fetched page containing the ID is server confirmation: the projected copy
|
||||||
|
// wins and later rollback attempts become no-ops.
|
||||||
|
serverMessages = [{ id: "msg_pending", type: "user", text: "Survive the sync", time: { created: 5 } }]
|
||||||
|
data.session.message.invalidate(sessionID)
|
||||||
|
await data.session.message.sync(sessionID)
|
||||||
|
const confirmed = data.session.message.get(sessionID, "msg_pending")
|
||||||
|
expect(confirmed?.type === "user" && confirmed.time.created).toBe(5)
|
||||||
|
expect(data.session.message.list(sessionID)).toHaveLength(1)
|
||||||
|
|
||||||
|
data.session.optimistic.rollback(sessionID, "msg_pending")
|
||||||
|
expect(data.session.message.get(sessionID, "msg_pending")).toBeDefined()
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cancellation clears the optimistic echo for good", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const sessionID = "session-optimistic-cancel"
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||||
|
}, events)
|
||||||
|
const { app, data } = await renderData(calls.fetch)
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.session.optimistic.prompt({
|
||||||
|
sessionID,
|
||||||
|
messageID: "msg_cancelled",
|
||||||
|
delivery: "queue",
|
||||||
|
text: "Cancel me",
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_cancelled",
|
||||||
|
created: 2,
|
||||||
|
type: "session.inbox.cancelled",
|
||||||
|
durable: durable(sessionID),
|
||||||
|
data: { sessionID, inboxID: "msg_cancelled" },
|
||||||
|
})
|
||||||
|
await wait(() => data.session.message.get(sessionID, "msg_cancelled") === undefined)
|
||||||
|
expect(data.session.pending.list(sessionID)).toHaveLength(0)
|
||||||
|
|
||||||
|
// The ledger entry is gone too: a sync replace must not resurrect the echo.
|
||||||
|
await data.session.message.sync(sessionID)
|
||||||
|
expect(data.session.message.get(sessionID, "msg_cancelled")).toBeUndefined()
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("optimistic session creation seeds info and suppresses initial reads", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const sessionID = "ses_optimistic"
|
||||||
|
const fetched: string[] = []
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (!url.pathname.startsWith(`/api/session/${sessionID}`)) return
|
||||||
|
fetched.push(url.pathname)
|
||||||
|
if (url.pathname === `/api/session/${sessionID}`)
|
||||||
|
return json({
|
||||||
|
data: {
|
||||||
|
id: sessionID,
|
||||||
|
projectID: "proj_test",
|
||||||
|
location: { directory },
|
||||||
|
agent: "build",
|
||||||
|
title: "Server title",
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, events)
|
||||||
|
const { app, data } = await renderData(calls.fetch)
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.session.optimistic.create({
|
||||||
|
sessionID,
|
||||||
|
projectID: "proj_test",
|
||||||
|
location: { directory },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "provider", id: "model" },
|
||||||
|
})
|
||||||
|
const seeded = data.session.get(sessionID)
|
||||||
|
expect(seeded?.projectID).toBe("proj_test")
|
||||||
|
expect(seeded?.agent).toBe("build")
|
||||||
|
|
||||||
|
// Seeded reads are marked complete: the server does not know the session yet.
|
||||||
|
await data.session.sync(sessionID, { children: true })
|
||||||
|
await data.session.message.sync(sessionID)
|
||||||
|
await data.session.pending.sync(sessionID)
|
||||||
|
expect(fetched).toEqual([])
|
||||||
|
|
||||||
|
// The session.created echo invalidates the info read and loads server truth.
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_created",
|
||||||
|
created: 2,
|
||||||
|
type: "session.created",
|
||||||
|
durable: durable(sessionID),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
projectID: "proj_test",
|
||||||
|
location: { directory },
|
||||||
|
slug: "server-slug",
|
||||||
|
agent: "build",
|
||||||
|
version: "test",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wait(() => data.session.get(sessionID)?.title === "Server title")
|
||||||
|
expect(fetched).toEqual([`/api/session/${sessionID}`])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rollbackCreate removes the seeded session and re-enables reads", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const sessionID = "ses_rollback"
|
||||||
|
const fetched: string[] = []
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname !== `/api/session/${sessionID}/message`) return
|
||||||
|
fetched.push(url.pathname)
|
||||||
|
return json({ data: [], cursor: {} })
|
||||||
|
}, events)
|
||||||
|
const { app, data } = await renderData(calls.fetch)
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.session.optimistic.create({
|
||||||
|
sessionID,
|
||||||
|
projectID: "proj_test",
|
||||||
|
location: { directory },
|
||||||
|
})
|
||||||
|
data.session.optimistic.prompt({
|
||||||
|
sessionID,
|
||||||
|
messageID: "msg_first",
|
||||||
|
delivery: "steer",
|
||||||
|
text: "First prompt",
|
||||||
|
})
|
||||||
|
expect(data.session.message.list(sessionID)).toHaveLength(1)
|
||||||
|
|
||||||
|
data.session.optimistic.rollback(sessionID, "msg_first")
|
||||||
|
data.session.optimistic.rollbackCreate(sessionID)
|
||||||
|
expect(data.session.get(sessionID)).toBeUndefined()
|
||||||
|
expect(data.session.message.list(sessionID)).toHaveLength(0)
|
||||||
|
expect(data.session.pending.list(sessionID)).toHaveLength(0)
|
||||||
|
|
||||||
|
// The seed's completed read markers are gone with it.
|
||||||
|
await data.session.message.sync(sessionID)
|
||||||
|
expect(fetched).toEqual([`/api/session/${sessionID}/message`])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("revert commit preserves unconfirmed optimistic prompts", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const sessionID = "session-optimistic-revert"
|
||||||
|
const calls = createFetch(undefined, events)
|
||||||
|
const { app, data } = await renderData(calls.fetch)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [seq, id] of [
|
||||||
|
[0, "msg_1"],
|
||||||
|
[1, "msg_2"],
|
||||||
|
] as const) {
|
||||||
|
emitEvent(events, {
|
||||||
|
id: `evt_seed_${id}`,
|
||||||
|
created: seq + 1,
|
||||||
|
type: "session.inbox.enqueued",
|
||||||
|
durable: durable(sessionID, seq),
|
||||||
|
data: { sessionID, inboxID: id, item: { type: "user", payload: { text: id }, delivery: "steer" } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await wait(() => data.session.message.list(sessionID).length === 2)
|
||||||
|
|
||||||
|
data.session.optimistic.prompt({
|
||||||
|
sessionID,
|
||||||
|
messageID: "msg_9",
|
||||||
|
delivery: "steer",
|
||||||
|
text: "After the revert boundary",
|
||||||
|
})
|
||||||
|
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_revert",
|
||||||
|
created: 4,
|
||||||
|
type: "session.revert.committed",
|
||||||
|
durable: durable(sessionID, 2),
|
||||||
|
data: { sessionID, to: "msg_2" },
|
||||||
|
})
|
||||||
|
await wait(() => data.session.message.get(sessionID, "msg_2") === undefined)
|
||||||
|
expect(data.session.message.get(sessionID, "msg_1")).toBeDefined()
|
||||||
|
expect(data.session.message.get(sessionID, "msg_9")).toBeDefined()
|
||||||
|
expect(data.session.input.has(sessionID, "msg_9")).toBe(true)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user